fix desktop copilot fallback inside electron

This commit is contained in:
dirtydishes 2026-05-20 18:37:57 -04:00
parent 1543f419e6
commit 7b87f976a2
5 changed files with 751 additions and 94 deletions

View file

@ -1,3 +1,4 @@
{"_type":"issue","id":"islandflow-199","title":"fix desktop copilot fallback inside electron","description":"## Why\\nThe settings page can render the browser-only fallback even when Islandflow is running inside the Electron desktop shell.\\n\\n## What\\nSeparate desktop-shell detection from desktop AI transport state, make the provider recover if the bridge appears late or initial state loading fails, and cover the regression with tests.\\n\\n## Acceptance Criteria\\n- The desktop shell no longer shows the browser-only fallback solely because initial bridge state failed or arrived late\\n- Desktop-only actions can distinguish between missing Electron bridge and transport/auth problems\\n- Automated tests cover the recovery behavior","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T22:30:16Z","created_by":"dirtydishes","updated_at":"2026-05-20T22:37:21Z","started_at":"2026-05-20T22:30:23Z","closed_at":"2026-05-20T22:37:21Z","close_reason":"Fixed desktop-shell Copilot fallback handling, added bridge recovery logic, updated desktop-vs-bridge UI messaging, and added regression tests. Follow-up tracked in islandflow-c8f for unrelated web build blocker.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-yza","title":"Persist historical flow packets for alert detail replay","description":"## Why\nAlert details can show a missing persisted flow packet when the packet is no longer present in the Redis hot cache, even though the associated historical alert and evidence were loaded from ClickHouse.\n\n## What needs to be done\nTrace the API path that resolves alert detail flow packets, compare Redis hot-cache lookups with ClickHouse historical fetches, and ensure historical flow packet payloads are treated as first-class persisted data with context preserved when replaying or loading older alerts.\n\n## Acceptance Criteria\n- Alert detail flow packets load for historical alerts even when the packet is absent from Redis hot cache\n- Historical ClickHouse-backed flow packet responses preserve the context required by the UI\n- Relevant automated tests cover the regression or the gap is explicitly documented","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T06:52:04Z","created_by":"dirtydishes","updated_at":"2026-05-20T06:59:26Z","started_at":"2026-05-20T06:52:09Z","closed_at":"2026-05-20T06:59:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-yza","title":"Persist historical flow packets for alert detail replay","description":"## Why\nAlert details can show a missing persisted flow packet when the packet is no longer present in the Redis hot cache, even though the associated historical alert and evidence were loaded from ClickHouse.\n\n## What needs to be done\nTrace the API path that resolves alert detail flow packets, compare Redis hot-cache lookups with ClickHouse historical fetches, and ensure historical flow packet payloads are treated as first-class persisted data with context preserved when replaying or loading older alerts.\n\n## Acceptance Criteria\n- Alert detail flow packets load for historical alerts even when the packet is absent from Redis hot cache\n- Historical ClickHouse-backed flow packet responses preserve the context required by the UI\n- Relevant automated tests cover the regression or the gap is explicitly documented","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T06:52:04Z","created_by":"dirtydishes","updated_at":"2026-05-20T06:59:26Z","started_at":"2026-05-20T06:52:09Z","closed_at":"2026-05-20T06:59:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-jor","title":"Support Forgejo pull request status in desktop git panel","description":"The desktop app currently reports pull request status unavailable when a repository only has a Forgejo remote. Add native Forgejo/Gitea-style remote detection and pull request status lookup so Forgejo-only repositories can show PR state in the Codex app git panel.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T20:55:15Z","created_by":"dirtydishes","updated_at":"2026-05-19T20:59:46Z","started_at":"2026-05-19T20:55:25Z","closed_at":"2026-05-19T20:59:46Z","close_reason":"Patched the installed Codex desktop app bundle with a Forgejo PR status fallback and documented the local change.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-jor","title":"Support Forgejo pull request status in desktop git panel","description":"The desktop app currently reports pull request status unavailable when a repository only has a Forgejo remote. Add native Forgejo/Gitea-style remote detection and pull request status lookup so Forgejo-only repositories can show PR state in the Codex app git panel.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T20:55:15Z","created_by":"dirtydishes","updated_at":"2026-05-19T20:59:46Z","started_at":"2026-05-19T20:55:25Z","closed_at":"2026-05-19T20:59:46Z","close_reason":"Patched the installed Codex desktop app bundle with a Forgejo PR status fallback and documented the local change.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-g3a","title":"Reconcile PR merge conflicts","description":"Resolve the current pull request conflicts for the nextjs-upgrade branch, validate the result, document the turn, and push the reconciled branch.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T18:44:51Z","created_by":"dirtydishes","updated_at":"2026-05-19T18:47:35Z","started_at":"2026-05-19T18:44:56Z","closed_at":"2026-05-19T18:47:35Z","close_reason":"Merged forgejo/main into nextjs-upgrade, resolved README and Beads conflicts, updated JetStream retention tests, validated deploy help, Docker workspace sync, API/bus tests, and web build, and added turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-g3a","title":"Reconcile PR merge conflicts","description":"Resolve the current pull request conflicts for the nextjs-upgrade branch, validate the result, document the turn, and push the reconciled branch.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T18:44:51Z","created_by":"dirtydishes","updated_at":"2026-05-19T18:47:35Z","started_at":"2026-05-19T18:44:56Z","closed_at":"2026-05-19T18:47:35Z","close_reason":"Merged forgejo/main into nextjs-upgrade, resolved README and Beads conflicts, updated JetStream retention tests, validated deploy help, Docker workspace sync, API/bus tests, and web build, and added turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
@ -16,6 +17,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-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-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-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-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}
{"_type":"issue","id":"islandflow-64s","title":"Fix desktop startup failure from @islandflow/types ESM imports","description":"Electron desktop startup fails with ERR_MODULE_NOT_FOUND because @islandflow/types exports TypeScript source and internal relative imports lacked .ts extensions under Node/Electron ESM resolution. Update type package internal imports and desktop tsconfig so desktop build and runtime can resolve modules consistently.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T22:26:45Z","created_by":"dirtydishes","updated_at":"2026-05-20T22:28:05Z","started_at":"2026-05-20T22:26:50Z","closed_at":"2026-05-20T22:28:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-64s","title":"Fix desktop startup failure from @islandflow/types ESM imports","description":"Electron desktop startup fails with ERR_MODULE_NOT_FOUND because @islandflow/types exports TypeScript source and internal relative imports lacked .ts extensions under Node/Electron ESM resolution. Update type package internal imports and desktop tsconfig so desktop build and runtime can resolve modules consistently.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T22:26:45Z","created_by":"dirtydishes","updated_at":"2026-05-20T22:28:05Z","started_at":"2026-05-20T22:26:50Z","closed_at":"2026-05-20T22:28:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-6tn","title":"Add Codex desktop login and usage bridge","description":"Implement a desktop-only Codex integration for the Islandflow Electron app using the official codex app-server with managed ChatGPT login, native IPC, settings UI, usage tracking, and clean web degradation.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T14:01:36Z","created_by":"dirtydishes","updated_at":"2026-05-20T14:40:49Z","started_at":"2026-05-20T14:01:48Z","closed_at":"2026-05-20T14:40:49Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-6tn","title":"Add Codex desktop login and usage bridge","description":"Implement a desktop-only Codex integration for the Islandflow Electron app using the official codex app-server with managed ChatGPT login, native IPC, settings UI, usage tracking, and clean web degradation.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T14:01:36Z","created_by":"dirtydishes","updated_at":"2026-05-20T14:40:49Z","started_at":"2026-05-20T14:01:48Z","closed_at":"2026-05-20T14:40:49Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-laq","title":"fix native alpaca news deploy and auth","description":"Why this issue exists and what needs to be done:\\n\\nNative Islandflow rollout is incomplete because services/ingest-news is not healthy on the VPS. The checked-in native user units and helper scripts do not fully include ingest-news, and the current service uses bearer-style auth that returns 401 against Alpaca news endpoints.\\n\\nThis task should verify the current Alpaca news auth requirements against official docs, update the repo code and native deployment assets as needed, install and enable the missing VPS unit, verify news events flow end-to-end, and document the work.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T23:47:07Z","created_by":"dirtydishes","updated_at":"2026-05-20T00:05:20Z","started_at":"2026-05-19T23:47:12Z","closed_at":"2026-05-20T00:05:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-laq","title":"fix native alpaca news deploy and auth","description":"Why this issue exists and what needs to be done:\\n\\nNative Islandflow rollout is incomplete because services/ingest-news is not healthy on the VPS. The checked-in native user units and helper scripts do not fully include ingest-news, and the current service uses bearer-style auth that returns 401 against Alpaca news endpoints.\\n\\nThis task should verify the current Alpaca news auth requirements against official docs, update the repo code and native deployment assets as needed, install and enable the missing VPS unit, verify news events flow end-to-end, and document the work.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T23:47:07Z","created_by":"dirtydishes","updated_at":"2026-05-20T00:05:20Z","started_at":"2026-05-19T23:47:12Z","closed_at":"2026-05-20T00:05:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -313,11 +313,11 @@ const AccountSummary = ({
}; };
const LoginStatePanel = () => { const LoginStatePanel = () => {
const { state, loginWithBrowser, loginWithDeviceCode, cancelLogin, logout } = useDesktopAi(); const { bridgeAvailable, state, loginWithBrowser, loginWithDeviceCode, cancelLogin, logout } = useDesktopAi();
const [busyAction, setBusyAction] = useState<string | null>(null); const [busyAction, setBusyAction] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const loginState = state.account.login; const loginState = state.account.login;
const actionsDisabled = busyAction !== null || !state.desktopAvailable; const actionsDisabled = busyAction !== null || !bridgeAvailable;
const runAction = async (label: string, action: () => Promise<void>) => { const runAction = async (label: string, action: () => Promise<void>) => {
setBusyAction(label); setBusyAction(label);
@ -437,7 +437,7 @@ const LoginStatePanel = () => {
}; };
export function DesktopAiSettingsRoute() { export function DesktopAiSettingsRoute() {
const { state, updatePreferences } = useDesktopAi(); const { bridgeAvailable, shellAvailable, state, updatePreferences } = useDesktopAi();
const [busyPreference, setBusyPreference] = useState<"model" | "reasoning" | null>(null); const [busyPreference, setBusyPreference] = useState<"model" | "reasoning" | null>(null);
const [preferenceError, setPreferenceError] = useState<string | null>(null); const [preferenceError, setPreferenceError] = useState<string | null>(null);
const rateLimits = Object.values(state.rateLimitsByLimitId); const rateLimits = Object.values(state.rateLimitsByLimitId);
@ -461,7 +461,7 @@ export function DesktopAiSettingsRoute() {
return ( return (
<div className="page-shell"> <div className="page-shell">
{!state.desktopAvailable ? ( {!shellAvailable ? (
<CopilotPane title="Desktop required" eyebrow="Browser-only fallback" wide> <CopilotPane title="Desktop required" eyebrow="Browser-only fallback" wide>
<div className="copilot-unavailable"> <div className="copilot-unavailable">
<p> <p>
@ -485,7 +485,7 @@ export function DesktopAiSettingsRoute() {
onChange={(event) => onChange={(event) =>
void savePreference("model", { model: event.target.value.trim() ? event.target.value : null }) void savePreference("model", { model: event.target.value.trim() ? event.target.value : null })
} }
disabled={busyPreference !== null || state.models.length === 0 || !state.desktopAvailable} disabled={busyPreference !== null || state.models.length === 0 || !bridgeAvailable}
> >
<option value="">Use server default</option> <option value="">Use server default</option>
{state.models.map((model) => ( {state.models.map((model) => (
@ -507,7 +507,7 @@ export function DesktopAiSettingsRoute() {
: null : null
}) })
} }
disabled={busyPreference !== null || !state.desktopAvailable} disabled={busyPreference !== null || !bridgeAvailable}
> >
<option value="">Use model default</option> <option value="">Use model default</option>
<option value="none">None</option> <option value="none">None</option>
@ -619,10 +619,17 @@ export function DesktopAiSettingsRoute() {
); );
} }
const requireDesktopActionCopy = (desktopAvailable: boolean, loggedIn: boolean): string => { export const requireDesktopActionCopy = (
if (!desktopAvailable) { shellAvailable: boolean,
bridgeAvailable: boolean,
loggedIn: boolean
): string => {
if (!shellAvailable) {
return "This control is desktop-only. Open Islandflow Desktop to run Copilot tasks."; return "This control is desktop-only. Open Islandflow Desktop to run Copilot tasks.";
} }
if (!bridgeAvailable) {
return "Islandflow Desktop is open, but this window is missing the native AI bridge. Reload the window or restart the app.";
}
if (!loggedIn) { if (!loggedIn) {
return "Connect a ChatGPT or Codex account in Settings before running Copilot analysis."; return "Connect a ChatGPT or Codex account in Settings before running Copilot analysis.";
} }
@ -668,11 +675,11 @@ export function SmartMoneyCopilotPanel({
evidencePrints: OptionPrint[]; evidencePrints: OptionPrint[];
relatedPackets: FlowPacket[]; relatedPackets: FlowPacket[];
}) { }) {
const { bridgeAvailable, state, runTask } = useDesktopAi(); const { bridgeAvailable, shellAvailable, state, runTask } = useDesktopAi();
const [busyKind, setBusyKind] = useState<IslandflowAiTaskKind | null>(null); const [busyKind, setBusyKind] = useState<IslandflowAiTaskKind | null>(null);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null); const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [taskError, setTaskError] = useState<string | null>(null); const [taskError, setTaskError] = useState<string | null>(null);
const disabledCopy = requireDesktopActionCopy(bridgeAvailable, state.account.loggedIn); const disabledCopy = requireDesktopActionCopy(shellAvailable, bridgeAvailable, state.account.loggedIn);
const actionsDisabled = !bridgeAvailable || !state.account.loggedIn; const actionsDisabled = !bridgeAvailable || !state.account.loggedIn;
const handleRun = async (kind: IslandflowAiTaskKind) => { const handleRun = async (kind: IslandflowAiTaskKind) => {
@ -769,11 +776,11 @@ export function ReplayCopilotPanel({
flowPackets: FlowPacket[]; flowPackets: FlowPacket[];
optionPrints: OptionPrint[]; optionPrints: OptionPrint[];
}) { }) {
const { bridgeAvailable, state, runTask } = useDesktopAi(); const { bridgeAvailable, shellAvailable, state, runTask } = useDesktopAi();
const [activeTaskId, setActiveTaskId] = useState<string | null>(null); const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [taskError, setTaskError] = useState<string | null>(null); const [taskError, setTaskError] = useState<string | null>(null);
const disabledCopy = requireDesktopActionCopy(bridgeAvailable, state.account.loggedIn); const disabledCopy = requireDesktopActionCopy(shellAvailable, bridgeAvailable, state.account.loggedIn);
const actionsDisabled = busy || !bridgeAvailable || !state.account.loggedIn; const actionsDisabled = busy || !bridgeAvailable || !state.account.loggedIn;
const handleRun = async () => { const handleRun = async () => {
@ -837,13 +844,13 @@ export function ScreenCompilerPanel({
currentFilters: OptionFlowFilters; currentFilters: OptionFlowFilters;
onApplyFilters: (next: OptionFlowFilters) => void; onApplyFilters: (next: OptionFlowFilters) => void;
}) { }) {
const { bridgeAvailable, state, runTask } = useDesktopAi(); const { bridgeAvailable, shellAvailable, state, runTask } = useDesktopAi();
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [activeTaskId, setActiveTaskId] = useState<string | null>(null); const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [taskError, setTaskError] = useState<string | null>(null); const [taskError, setTaskError] = useState<string | null>(null);
const activeTask = useMemo(() => findTask(state.tasks, activeTaskId), [state.tasks, activeTaskId]); const activeTask = useMemo(() => findTask(state.tasks, activeTaskId), [state.tasks, activeTaskId]);
const disabledCopy = requireDesktopActionCopy(bridgeAvailable, state.account.loggedIn); const disabledCopy = requireDesktopActionCopy(shellAvailable, bridgeAvailable, state.account.loggedIn);
const actionsDisabled = busy || !bridgeAvailable || !state.account.loggedIn; const actionsDisabled = busy || !bridgeAvailable || !state.account.loggedIn;
const handleCompile = async () => { const handleCompile = async () => {

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from "bun:test";
import {
createUnavailableState,
detectDesktopShell,
resolveDesktopAiRuntime
} from "./desktop-ai";
import { requireDesktopActionCopy } from "./desktop-ai-panels";
describe("desktop ai runtime detection", () => {
it("recognizes Electron user agents before the bridge is available", () => {
const runtime = resolveDesktopAiRuntime({
navigator: { userAgent: "Mozilla/5.0 Islandflow Electron/39.0.0 Safari/537.36" }
});
expect(runtime.shellAvailable).toBe(true);
expect(runtime.bridgeAvailable).toBe(false);
expect(runtime.bridge).toBeNull();
});
it("treats a bridged window as desktop even without an Electron user agent", () => {
const runtime = resolveDesktopAiRuntime({
islandflowDesktop: {
ai: {
getState: async () => createUnavailableState({ shellAvailable: true, bridgeAvailable: true }),
loginWithBrowser: async () => {},
loginWithDeviceCode: async () => {},
cancelLogin: async () => {},
logout: async () => {},
updatePreferences: async () => {},
runTask: async () => ({ taskId: "task-1" }),
subscribe: () => () => {}
}
},
navigator: { userAgent: "Mozilla/5.0" }
});
expect(runtime.shellAvailable).toBe(true);
expect(runtime.bridgeAvailable).toBe(true);
expect(runtime.bridge).not.toBeNull();
});
});
describe("desktop ai unavailable state", () => {
it("keeps desktopAvailable false for real browser fallbacks", () => {
const state = createUnavailableState();
expect(state.desktopAvailable).toBe(false);
expect(state.transportStatus).toBe("stopped");
expect(state.transportError).toContain("Electron app");
});
it("reports desktop-shell bridge failures without pretending the app is a browser", () => {
const state = createUnavailableState({ shellAvailable: true });
expect(state.desktopAvailable).toBe(true);
expect(state.transportStatus).toBe("error");
expect(state.transportError).toContain("native AI bridge");
expect(state.account.login.message).toContain("Reload the window");
});
});
describe("desktop action copy", () => {
it("asks for the desktop app only when the shell is genuinely absent", () => {
expect(requireDesktopActionCopy(false, false, false)).toContain("Open Islandflow Desktop");
});
it("surfaces bridge recovery guidance inside the desktop shell", () => {
expect(requireDesktopActionCopy(true, false, false)).toContain("missing the native AI bridge");
});
it("asks for login once the bridge is present", () => {
expect(requireDesktopActionCopy(true, true, false)).toContain("Connect a ChatGPT or Codex account");
});
it("clears helper copy when the action is ready", () => {
expect(requireDesktopActionCopy(true, true, true)).toBe("");
});
});
describe("desktop shell detection", () => {
it("matches Electron signatures", () => {
expect(detectDesktopShell("Mozilla/5.0 Electron/39.0.0")).toBe(true);
expect(detectDesktopShell("Mozilla/5.0 Chrome/136.0.0.0 Safari/537.36")).toBe(false);
});
});

View file

@ -29,6 +29,12 @@ type DesktopAiBridge = {
}; };
}; };
type DesktopAiRuntime = {
shellAvailable: boolean;
bridgeAvailable: boolean;
bridge: DesktopAiBridge | null;
};
declare global { declare global {
interface Window { interface Window {
islandflowDesktop?: DesktopAiBridge; islandflowDesktop?: DesktopAiBridge;
@ -37,6 +43,7 @@ declare global {
type DesktopAiContextValue = { type DesktopAiContextValue = {
bridgeAvailable: boolean; bridgeAvailable: boolean;
shellAvailable: boolean;
state: IslandflowAiState; state: IslandflowAiState;
loginWithBrowser: () => Promise<void>; loginWithBrowser: () => Promise<void>;
loginWithDeviceCode: () => Promise<void>; loginWithDeviceCode: () => Promise<void>;
@ -48,19 +55,62 @@ type DesktopAiContextValue = {
runTask: (request: IslandflowAiTaskRequest) => Promise<{ taskId: string }>; runTask: (request: IslandflowAiTaskRequest) => Promise<{ taskId: string }>;
}; };
const createUnavailableState = (): IslandflowAiState => ({ const BRIDGE_POLL_INTERVAL_MS = 250;
desktopAvailable: false, const BRIDGE_POLL_MAX_ATTEMPTS = 20;
transportStatus: "stopped", const ELECTRON_USER_AGENT_PATTERN = /\bElectron\/\S+/i;
transportError: "Desktop AI is only available inside the Islandflow Electron app.",
export const detectDesktopShell = (userAgent: string | null | undefined): boolean =>
Boolean(userAgent && ELECTRON_USER_AGENT_PATTERN.test(userAgent));
export const resolveDesktopAiRuntime = (
value:
| {
islandflowDesktop?: DesktopAiBridge;
navigator?: { userAgent?: string | null };
}
| null
| undefined
): DesktopAiRuntime => {
const bridge = value?.islandflowDesktop?.ai ? value.islandflowDesktop : null;
const bridgeAvailable = Boolean(bridge?.ai);
const shellAvailable = bridgeAvailable || detectDesktopShell(value?.navigator?.userAgent);
return {
shellAvailable,
bridgeAvailable,
bridge
};
};
export const createUnavailableState = (runtime?: Partial<DesktopAiRuntime>): IslandflowAiState => {
const shellAvailable = Boolean(runtime?.shellAvailable || runtime?.bridgeAvailable);
const bridgeAvailable = Boolean(runtime?.bridgeAvailable);
const transportError = !shellAvailable
? "Desktop AI is only available inside the Islandflow Electron app."
: bridgeAvailable
? "The desktop AI bridge loaded, but its initial state could not be read."
: "Islandflow Desktop is open, but the native AI bridge is unavailable in this session.";
const loginMessage = !shellAvailable
? "Open Islandflow Desktop to connect a ChatGPT or Codex account."
: bridgeAvailable
? "The desktop bridge connected, but its initial state did not load. Retry the action or restart Islandflow if this persists."
: "This desktop window is missing its native AI bridge. Reload the window or restart Islandflow if this persists.";
return {
desktopAvailable: shellAvailable,
transportStatus: shellAvailable ? "error" : "stopped",
transportError,
profiles: [ profiles: [
{ {
id: "managed-chatgpt", id: "managed-chatgpt",
label: "Managed ChatGPT login", label: "Managed ChatGPT login",
description: "Available only in the desktop app.", description: shellAvailable
? "Managed ChatGPT login belongs to the desktop shell, but this window is not connected to the native bridge yet."
: "Available only in the desktop app.",
mode: "managed-chatgpt", mode: "managed-chatgpt",
enabled: false, enabled: shellAvailable,
selected: true, selected: true,
statusLabel: "Desktop only" statusLabel: shellAvailable ? "Bridge unavailable" : "Desktop only"
} }
], ],
selectedProfileId: "managed-chatgpt", selectedProfileId: "managed-chatgpt",
@ -72,7 +122,7 @@ const createUnavailableState = (): IslandflowAiState => ({
requiresOpenaiAuth: true, requiresOpenaiAuth: true,
login: { login: {
status: "idle", status: "idle",
message: "Open Islandflow Desktop to connect a ChatGPT or Codex account." message: loginMessage
} }
}, },
preferences: { preferences: {
@ -110,7 +160,8 @@ const createUnavailableState = (): IslandflowAiState => ({
}, },
tasks: [], tasks: [],
updatedAt: Date.now() updatedAt: Date.now()
}); };
};
const DesktopAiContext = createContext<DesktopAiContextValue | null>(null); const DesktopAiContext = createContext<DesktopAiContextValue | null>(null);
@ -121,34 +172,81 @@ const rejectDesktopOnly = async (): Promise<never> => {
export function DesktopAiProvider({ children }: { children: ReactNode }) { export function DesktopAiProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<IslandflowAiState>(() => createUnavailableState()); const [state, setState] = useState<IslandflowAiState>(() => createUnavailableState());
const [bridge, setBridge] = useState<DesktopAiBridge | null>(null); const [bridge, setBridge] = useState<DesktopAiBridge | null>(null);
const [shellAvailable, setShellAvailable] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return; return;
} }
const nextBridge = window.islandflowDesktop ?? null; let disposed = false;
if (!nextBridge?.ai) { let unsubscribe = () => {};
let pollTimer: number | null = null;
let attempts = 0;
const connectBridge = (runtime: DesktopAiRuntime): boolean => {
if (!runtime.bridge) {
return false;
}
setShellAvailable(runtime.shellAvailable);
setBridge(runtime.bridge);
void runtime.bridge.ai.getState().then(
(nextState) => {
if (!disposed) {
setState(nextState);
}
},
() => {
if (!disposed) {
setState(createUnavailableState(runtime));
}
}
);
unsubscribe = runtime.bridge.ai.subscribe((nextState) => {
if (!disposed) {
setState(nextState);
}
});
return true;
};
const syncRuntime = (): boolean => {
const runtime = resolveDesktopAiRuntime(window);
setShellAvailable(runtime.shellAvailable);
if (connectBridge(runtime)) {
return true;
}
setBridge(null); setBridge(null);
setState(createUnavailableState()); setState(createUnavailableState(runtime));
return false;
};
if (!syncRuntime()) {
const pollForBridge = () => {
if (disposed) {
return; return;
} }
setBridge(nextBridge); attempts += 1;
let unsubscribe = () => {}; if (syncRuntime() || attempts >= BRIDGE_POLL_MAX_ATTEMPTS) {
void nextBridge.ai.getState().then(setState).catch(() => { return;
setState((current) => ({ }
...current,
transportStatus: "error",
transportError: "The desktop AI bridge could not load its initial state."
}));
});
unsubscribe = nextBridge.ai.subscribe((nextState) => { pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
setState(nextState); };
});
pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
}
return () => { return () => {
disposed = true;
if (pollTimer !== null) {
window.clearTimeout(pollTimer);
}
unsubscribe(); unsubscribe();
}; };
}, []); }, []);
@ -156,6 +254,7 @@ export function DesktopAiProvider({ children }: { children: ReactNode }) {
const value = useMemo<DesktopAiContextValue>( const value = useMemo<DesktopAiContextValue>(
() => ({ () => ({
bridgeAvailable: Boolean(bridge?.ai), bridgeAvailable: Boolean(bridge?.ai),
shellAvailable,
state, state,
loginWithBrowser: bridge?.ai.loginWithBrowser ?? rejectDesktopOnly, loginWithBrowser: bridge?.ai.loginWithBrowser ?? rejectDesktopOnly,
loginWithDeviceCode: bridge?.ai.loginWithDeviceCode ?? rejectDesktopOnly, loginWithDeviceCode: bridge?.ai.loginWithDeviceCode ?? rejectDesktopOnly,
@ -164,7 +263,7 @@ export function DesktopAiProvider({ children }: { children: ReactNode }) {
updatePreferences: bridge?.ai.updatePreferences ?? rejectDesktopOnly, updatePreferences: bridge?.ai.updatePreferences ?? rejectDesktopOnly,
runTask: bridge?.ai.runTask ?? rejectDesktopOnly runTask: bridge?.ai.runTask ?? rejectDesktopOnly
}), }),
[bridge, state] [bridge, shellAvailable, state]
); );
return <DesktopAiContext.Provider value={value}>{children}</DesktopAiContext.Provider>; return <DesktopAiContext.Provider value={value}>{children}</DesktopAiContext.Provider>;

View file

@ -0,0 +1,463 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>2026-05-20 · Fix Desktop Copilot Shell Detection</title>
<style>
:root {
color-scheme: dark;
--bg: oklch(0.11 0.01 250);
--panel: oklch(0.16 0.013 250 / 0.92);
--panel-2: oklch(0.14 0.012 250 / 0.9);
--text: oklch(0.93 0.014 250);
--muted: oklch(0.74 0.018 250);
--faint: oklch(0.6 0.016 250);
--line: oklch(0.75 0.014 250 / 0.14);
--accent: oklch(0.79 0.12 74);
--accent-soft: oklch(0.79 0.12 74 / 0.12);
--green-soft: oklch(0.75 0.12 151 / 0.12);
--red-soft: oklch(0.72 0.14 28 / 0.12);
--blue-soft: oklch(0.7 0.1 247 / 0.12);
--shadow: 0 24px 80px oklch(0.02 0.01 250 / 0.45);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
background:
radial-gradient(circle at top left, oklch(0.8 0.12 74 / 0.09), transparent 28%),
linear-gradient(180deg, oklch(0.15 0.012 250) 0%, oklch(0.1 0.01 250) 100%);
color: var(--text);
}
main {
width: min(1160px, calc(100vw - 40px));
margin: 0 auto;
padding: 36px 0 64px;
}
h1,
h2,
h3,
.eyebrow,
.meta,
code,
pre {
font-family: "IBM Plex Mono", ui-monospace, monospace;
}
h1,
h2,
h3 {
margin: 0;
}
p,
li {
color: var(--muted);
line-height: 1.7;
}
a {
color: inherit;
}
main > * + * {
margin-top: 22px;
}
.hero {
display: grid;
gap: 20px;
padding: 28px;
border: 1px solid var(--line);
border-radius: 24px;
background:
linear-gradient(135deg, oklch(0.2 0.017 250 / 0.96), oklch(0.14 0.012 250 / 0.98)),
var(--panel);
box-shadow: var(--shadow);
}
.eyebrow {
margin: 0 0 10px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.76rem;
}
.hero h1 {
font-size: clamp(2rem, 3.4vw, 3.6rem);
line-height: 0.95;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.hero-copy {
max-width: 72ch;
margin: 14px 0 0;
}
.hero-grid,
.section-grid,
.validation-grid,
.two-col {
display: grid;
gap: 18px;
}
.hero-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.two-col {
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
}
.stat,
.validation-card,
.callout {
padding: 16px 18px;
border-radius: 16px;
border: 1px solid var(--line);
background: oklch(0.12 0.01 250 / 0.5);
}
.stat span,
.validation-card span {
display: block;
margin-bottom: 8px;
color: var(--faint);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.68rem;
}
.stat strong,
.validation-card strong {
color: var(--text);
}
.validation-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.validation-card.good {
background: var(--green-soft);
}
.validation-card.warn {
background: var(--red-soft);
}
.callout {
background: var(--blue-soft);
}
section {
padding: 24px;
border: 1px solid var(--line);
border-radius: 20px;
background: var(--panel-2);
box-shadow: var(--shadow);
}
section h2 {
margin-bottom: 14px;
font-size: 1rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
ul {
margin: 0;
padding-left: 20px;
}
li + li {
margin-top: 10px;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid oklch(0.8 0.12 74 / 0.28);
background: var(--accent-soft);
color: var(--text);
font-size: 0.74rem;
}
.meta {
color: var(--faint);
font-size: 0.82rem;
}
pre.diff {
margin: 0;
padding: 16px 18px;
overflow: auto;
border-radius: 16px;
border: 1px solid var(--line);
background: oklch(0.08 0.008 250 / 0.95);
color: var(--text);
line-height: 1.5;
font-size: 0.78rem;
}
.diff-title {
margin-bottom: 10px;
color: var(--faint);
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
@media (max-width: 860px) {
main {
width: min(100vw - 24px, 1160px);
padding-top: 18px;
}
.hero-grid,
.two-col,
.validation-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
</head>
<body>
<main>
<section class="hero">
<div>
<p class="eyebrow">Turn Record · 2026-05-20 18:34 EDT</p>
<h1>Fix Desktop Copilot Shell Detection</h1>
<p class="hero-copy">
This turn fixed the case where Islandflow could be running inside the Electron desktop shell but still show
the browser-only Copilot fallback. The renderer now distinguishes between “not in desktop”, “desktop shell
detected but native bridge missing”, and “desktop bridge present but initial state failed”.
</p>
</div>
<div class="hero-grid">
<div class="stat">
<span>Main Issue</span>
<strong><code>islandflow-199</code></strong>
</div>
<div class="stat">
<span>Files Changed</span>
<strong>3</strong>
</div>
<div class="stat">
<span>Validation</span>
<strong>Targeted tests passed, web build blocked by existing repo issue</strong>
</div>
</div>
</section>
<section>
<h2>Summary</h2>
<p>
The Copilot settings surface no longer assumes that any failure to load desktop AI state means the app is
running in a browser. Instead, it recovers late bridge injection for a short window, exposes better shell-vs-
bridge state to the UI, and gives the user actionable recovery copy when the native bridge is missing.
</p>
</section>
<section>
<h2>Changes Made</h2>
<ul>
<li>Added desktop runtime helpers in <code>apps/web/app/desktop-ai.tsx</code> to detect Electron shell presence separately from bridge presence.</li>
<li>Changed the desktop AI provider to poll briefly for late bridge availability instead of giving up after the first missing-read.</li>
<li>Reworked the providers unavailable/error state so desktop sessions show bridge-focused recovery guidance instead of browser-only copy.</li>
<li>Updated settings and Copilot action panels in <code>apps/web/app/desktop-ai-panels.tsx</code> to gate actions on bridge availability while only showing the browser fallback when the shell is genuinely absent.</li>
<li>Added regression coverage in <code>apps/web/app/desktop-ai.test.ts</code> for Electron detection, bridge-missing fallback state, and action helper copy.</li>
<li>Filed follow-up issue <code>islandflow-c8f</code> for the unrelated shared-types import-path problem currently blocking <code>bun --cwd=apps/web run build</code>.</li>
</ul>
</section>
<section>
<h2>Context</h2>
<div class="two-col">
<div>
<p>
The reported symptom was a Settings screen that still rendered “Browser-only fallback” even though the
user was visibly inside the Islandflow desktop app. The pre-fix renderer had a single blunt interpretation:
if the preload bridge was not immediately readable, the state fell back to the same model used for a real
browser session.
</p>
<p>
That collapsed three different states into one misleading message:
</p>
<ul>
<li>Regular browser session, where desktop AI truly is unavailable.</li>
<li>Electron shell present, but the bridge appeared slightly later than the first effect pass.</li>
<li>Electron shell and bridge both present, but the initial <code>getState()</code> call failed.</li>
</ul>
</div>
<aside class="callout">
<strong>Why this matters</strong>
<p>
A desktop shell claiming “open the desktop app” is not just awkward copy. It also disables the login
actions that might still be recoverable, which turns a bridge problem into a dead-end user experience.
</p>
</aside>
</div>
</section>
<section>
<h2>Important Implementation Details</h2>
<ul>
<li><strong>Shell detection:</strong> the renderer now treats an Electron user-agent or a working preload bridge as evidence that the desktop shell is present.</li>
<li><strong>Bridge recovery:</strong> the provider polls for up to 20 attempts at 250ms intervals so late preload exposure does not immediately collapse to browser fallback.</li>
<li><strong>Better state semantics:</strong> unavailable-state generation now accepts runtime context and can mark <code>desktopAvailable</code> true while still reporting a bridge error.</li>
<li><strong>UI gating:</strong> Settings and Copilot actions key off <code>bridgeAvailable</code> for actionable controls, while the “Desktop required” banner keys off <code>shellAvailable</code>.</li>
<li><strong>Recovery copy:</strong> action helper text now distinguishes between “open the desktop app”, “reload or restart because the native bridge is missing”, and “connect a ChatGPT or Codex account”.</li>
</ul>
<div class="chip-row" style="margin-top: 14px;">
<span class="chip">Electron shell detection</span>
<span class="chip">Late bridge retry</span>
<span class="chip">Accurate fallback messaging</span>
<span class="chip">Regression tests</span>
</div>
</section>
<section>
<h2>Relevant Diff Snippets</h2>
<p class="meta">
Diff snippets are rendered as plain patch strings compatible with the format documented by
<a href="https://diffs.com/docs">diffs.com</a>.
</p>
<div class="section-grid">
<div>
<div class="diff-title">Renderer runtime detection and bridge polling</div>
<pre class="diff"><code>diff --git a/apps/web/app/desktop-ai.tsx b/apps/web/app/desktop-ai.tsx
@@
+const BRIDGE_POLL_INTERVAL_MS = 250;
+const BRIDGE_POLL_MAX_ATTEMPTS = 20;
+const ELECTRON_USER_AGENT_PATTERN = /\bElectron\/\S+/i;
+
+export const detectDesktopShell = (userAgent: string | null | undefined): boolean =&gt;
+ Boolean(userAgent &amp;&amp; ELECTRON_USER_AGENT_PATTERN.test(userAgent));
+
+export const resolveDesktopAiRuntime = (...) =&gt; {
+ const bridge = value?.islandflowDesktop?.ai ? value.islandflowDesktop : null;
+ const bridgeAvailable = Boolean(bridge?.ai);
+ const shellAvailable = bridgeAvailable || detectDesktopShell(value?.navigator?.userAgent);
+ return { shellAvailable, bridgeAvailable, bridge };
+};
@@
+ if (!syncRuntime()) {
+ const pollForBridge = () =&gt; {
+ attempts += 1;
+ if (syncRuntime() || attempts &gt;= BRIDGE_POLL_MAX_ATTEMPTS) {
+ return;
+ }
+ pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
+ };
+ pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
+ }</code></pre>
</div>
<div>
<div class="diff-title">Settings and task panels stop calling the desktop shell a browser</div>
<pre class="diff"><code>diff --git a/apps/web/app/desktop-ai-panels.tsx b/apps/web/app/desktop-ai-panels.tsx
@@
- const actionsDisabled = busyAction !== null || !state.desktopAvailable;
+ const actionsDisabled = busyAction !== null || !bridgeAvailable;
@@
- {!state.desktopAvailable ? (
+ {!shellAvailable ? (
&lt;CopilotPane title="Desktop required" eyebrow="Browser-only fallback" wide&gt;
@@
-const requireDesktopActionCopy = (desktopAvailable: boolean, loggedIn: boolean): string =&gt; {
- if (!desktopAvailable) {
+export const requireDesktopActionCopy = (shellAvailable: boolean, bridgeAvailable: boolean, loggedIn: boolean): string =&gt; {
+ if (!shellAvailable) {
return "This control is desktop-only. Open Islandflow Desktop to run Copilot tasks.";
}
+ if (!bridgeAvailable) {
+ return "Islandflow Desktop is open, but this window is missing the native AI bridge. Reload the window or restart the app.";
+ }</code></pre>
</div>
<div>
<div class="diff-title">Regression coverage for shell-vs-bridge behavior</div>
<pre class="diff"><code>diff --git a/apps/web/app/desktop-ai.test.ts b/apps/web/app/desktop-ai.test.ts
new file mode 100644
@@
+it("recognizes Electron user agents before the bridge is available", () =&gt; {
+ const runtime = resolveDesktopAiRuntime({ navigator: { userAgent: "... Electron/39.0.0 ..." } });
+ expect(runtime.shellAvailable).toBe(true);
+ expect(runtime.bridgeAvailable).toBe(false);
+});
+
+it("reports desktop-shell bridge failures without pretending the app is a browser", () =&gt; {
+ const state = createUnavailableState({ shellAvailable: true });
+ expect(state.desktopAvailable).toBe(true);
+ expect(state.transportError).toContain("native AI bridge");
+});</code></pre>
</div>
</div>
</section>
<section>
<h2>Expected Impact for End-Users</h2>
<ul>
<li>Desktop users no longer get told to “open Islandflow Desktop” when they are already in it.</li>
<li>If the preload bridge is delayed, the renderer now has a short recovery window before giving up.</li>
<li>If the bridge is missing or broken, the UI explains that exact condition and suggests a sensible recovery path.</li>
<li>Copilot action text now distinguishes between missing desktop shell, missing bridge, and missing account login.</li>
</ul>
</section>
<section>
<h2>Validation</h2>
<div class="validation-grid">
<div class="validation-card good">
<span>Passed</span>
<strong><code>bun test apps/web/app/desktop-ai.test.ts apps/web/app/routes.test.ts apps/web/app/terminal.test.ts</code></strong>
<p>All targeted renderer tests passed, including the new desktop shell and bridge regression coverage.</p>
</div>
<div class="validation-card warn">
<span>Blocked By Existing Repo Issue</span>
<strong><code>bun --cwd=apps/web run build</code></strong>
<p>
The build still fails during shared package type-checking because <code>packages/types/src/desktop-ai.ts</code>
imports sibling files with explicit <code>.ts</code> extensions. Follow-up issue: <code>islandflow-c8f</code>.
</p>
</div>
</div>
</section>
<section>
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li>The providers retry window is intentionally short. It helps with delayed bridge injection, but it does not try to hide a truly broken preload indefinitely.</li>
<li>The first client paint can still momentarily start from the generic unavailable state before the effect resolves runtime details. The mitigation is that the resolved UI now lands on an accurate desktop-shell state instead of a misleading browser fallback.</li>
<li>The production web build remains red for an unrelated shared-types import-path issue. That blocker is explicitly tracked in <code>islandflow-c8f</code> so it does not disappear into session history.</li>
</ul>
</section>
<section>
<h2>Follow-up Work</h2>
<ul>
<li><code>islandflow-c8f</code>: remove or normalize explicit <code>.ts</code> import specifiers in <code>packages/types</code> so the Next.js production build can complete.</li>
<li>Optionally add desktop-shell telemetry to the topbar summary so bridge-missing sessions are visible outside Settings too.</li>
<li>If bridge timing remains flaky in practice, consider emitting a preload-ready event instead of relying only on a short polling window.</li>
</ul>
</section>
</main>
</body>
</html>