diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 272fd30..313007d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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-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} @@ -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-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-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-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} diff --git a/apps/web/app/desktop-ai-panels.tsx b/apps/web/app/desktop-ai-panels.tsx index 65524dc..70e6d2c 100644 --- a/apps/web/app/desktop-ai-panels.tsx +++ b/apps/web/app/desktop-ai-panels.tsx @@ -313,11 +313,11 @@ const AccountSummary = ({ }; const LoginStatePanel = () => { - const { state, loginWithBrowser, loginWithDeviceCode, cancelLogin, logout } = useDesktopAi(); + const { bridgeAvailable, state, loginWithBrowser, loginWithDeviceCode, cancelLogin, logout } = useDesktopAi(); const [busyAction, setBusyAction] = useState(null); const [actionError, setActionError] = useState(null); const loginState = state.account.login; - const actionsDisabled = busyAction !== null || !state.desktopAvailable; + const actionsDisabled = busyAction !== null || !bridgeAvailable; const runAction = async (label: string, action: () => Promise) => { setBusyAction(label); @@ -437,7 +437,7 @@ const LoginStatePanel = () => { }; export function DesktopAiSettingsRoute() { - const { state, updatePreferences } = useDesktopAi(); + const { bridgeAvailable, shellAvailable, state, updatePreferences } = useDesktopAi(); const [busyPreference, setBusyPreference] = useState<"model" | "reasoning" | null>(null); const [preferenceError, setPreferenceError] = useState(null); const rateLimits = Object.values(state.rateLimitsByLimitId); @@ -461,7 +461,7 @@ export function DesktopAiSettingsRoute() { return (
- {!state.desktopAvailable ? ( + {!shellAvailable ? (

@@ -485,7 +485,7 @@ export function DesktopAiSettingsRoute() { onChange={(event) => 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} > {state.models.map((model) => ( @@ -507,7 +507,7 @@ export function DesktopAiSettingsRoute() { : null }) } - disabled={busyPreference !== null || !state.desktopAvailable} + disabled={busyPreference !== null || !bridgeAvailable} > @@ -619,10 +619,17 @@ export function DesktopAiSettingsRoute() { ); } -const requireDesktopActionCopy = (desktopAvailable: boolean, loggedIn: boolean): string => { - if (!desktopAvailable) { +export const requireDesktopActionCopy = ( + shellAvailable: boolean, + bridgeAvailable: boolean, + loggedIn: boolean +): string => { + 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."; + } if (!loggedIn) { return "Connect a ChatGPT or Codex account in Settings before running Copilot analysis."; } @@ -668,11 +675,11 @@ export function SmartMoneyCopilotPanel({ evidencePrints: OptionPrint[]; relatedPackets: FlowPacket[]; }) { - const { bridgeAvailable, state, runTask } = useDesktopAi(); + const { bridgeAvailable, shellAvailable, state, runTask } = useDesktopAi(); const [busyKind, setBusyKind] = useState(null); const [activeTaskId, setActiveTaskId] = useState(null); const [taskError, setTaskError] = useState(null); - const disabledCopy = requireDesktopActionCopy(bridgeAvailable, state.account.loggedIn); + const disabledCopy = requireDesktopActionCopy(shellAvailable, bridgeAvailable, state.account.loggedIn); const actionsDisabled = !bridgeAvailable || !state.account.loggedIn; const handleRun = async (kind: IslandflowAiTaskKind) => { @@ -769,11 +776,11 @@ export function ReplayCopilotPanel({ flowPackets: FlowPacket[]; optionPrints: OptionPrint[]; }) { - const { bridgeAvailable, state, runTask } = useDesktopAi(); + const { bridgeAvailable, shellAvailable, state, runTask } = useDesktopAi(); const [activeTaskId, setActiveTaskId] = useState(null); const [busy, setBusy] = useState(false); const [taskError, setTaskError] = useState(null); - const disabledCopy = requireDesktopActionCopy(bridgeAvailable, state.account.loggedIn); + const disabledCopy = requireDesktopActionCopy(shellAvailable, bridgeAvailable, state.account.loggedIn); const actionsDisabled = busy || !bridgeAvailable || !state.account.loggedIn; const handleRun = async () => { @@ -837,13 +844,13 @@ export function ScreenCompilerPanel({ currentFilters: OptionFlowFilters; onApplyFilters: (next: OptionFlowFilters) => void; }) { - const { bridgeAvailable, state, runTask } = useDesktopAi(); + const { bridgeAvailable, shellAvailable, state, runTask } = useDesktopAi(); const [prompt, setPrompt] = useState(""); const [activeTaskId, setActiveTaskId] = useState(null); const [busy, setBusy] = useState(false); const [taskError, setTaskError] = useState(null); 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 handleCompile = async () => { diff --git a/apps/web/app/desktop-ai.test.ts b/apps/web/app/desktop-ai.test.ts new file mode 100644 index 0000000..f9e8ee6 --- /dev/null +++ b/apps/web/app/desktop-ai.test.ts @@ -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); + }); +}); diff --git a/apps/web/app/desktop-ai.tsx b/apps/web/app/desktop-ai.tsx index c5f95ff..5ea2302 100644 --- a/apps/web/app/desktop-ai.tsx +++ b/apps/web/app/desktop-ai.tsx @@ -29,6 +29,12 @@ type DesktopAiBridge = { }; }; +type DesktopAiRuntime = { + shellAvailable: boolean; + bridgeAvailable: boolean; + bridge: DesktopAiBridge | null; +}; + declare global { interface Window { islandflowDesktop?: DesktopAiBridge; @@ -37,6 +43,7 @@ declare global { type DesktopAiContextValue = { bridgeAvailable: boolean; + shellAvailable: boolean; state: IslandflowAiState; loginWithBrowser: () => Promise; loginWithDeviceCode: () => Promise; @@ -48,69 +55,113 @@ type DesktopAiContextValue = { runTask: (request: IslandflowAiTaskRequest) => Promise<{ taskId: string }>; }; -const createUnavailableState = (): IslandflowAiState => ({ - desktopAvailable: false, - transportStatus: "stopped", - transportError: "Desktop AI is only available inside the Islandflow Electron app.", - profiles: [ - { - id: "managed-chatgpt", - label: "Managed ChatGPT login", - description: "Available only in the desktop app.", - mode: "managed-chatgpt", - enabled: false, - selected: true, - statusLabel: "Desktop only" - } - ], - selectedProfileId: "managed-chatgpt", - account: { - loggedIn: false, - email: null, - planType: null, - authMode: null, - requiresOpenaiAuth: true, - login: { - status: "idle", - message: "Open Islandflow Desktop to connect a ChatGPT or Codex account." - } - }, - preferences: { - model: null, - reasoningEffort: "high" - }, - models: [], - rateLimitsByLimitId: {}, - usage: { - today: { - breakdown: { - totalTokens: 0, - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0 - }, - normalizedCostUsd: 0, - turnCount: 0, - activeDays: 0 +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 => + 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): 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: [ + { + id: "managed-chatgpt", + label: "Managed ChatGPT login", + 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", + enabled: shellAvailable, + selected: true, + statusLabel: shellAvailable ? "Bridge unavailable" : "Desktop only" + } + ], + selectedProfileId: "managed-chatgpt", + account: { + loggedIn: false, + email: null, + planType: null, + authMode: null, + requiresOpenaiAuth: true, + login: { + status: "idle", + message: loginMessage + } }, - lifetime: { - breakdown: { - totalTokens: 0, - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0 - }, - normalizedCostUsd: 0, - turnCount: 0, - activeDays: 0 + preferences: { + model: null, + reasoningEffort: "high" }, - recentTurns: [] - }, - tasks: [], - updatedAt: Date.now() -}); + models: [], + rateLimitsByLimitId: {}, + usage: { + today: { + breakdown: { + totalTokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0 + }, + normalizedCostUsd: 0, + turnCount: 0, + activeDays: 0 + }, + lifetime: { + breakdown: { + totalTokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0 + }, + normalizedCostUsd: 0, + turnCount: 0, + activeDays: 0 + }, + recentTurns: [] + }, + tasks: [], + updatedAt: Date.now() + }; +}; const DesktopAiContext = createContext(null); @@ -121,34 +172,81 @@ const rejectDesktopOnly = async (): Promise => { export function DesktopAiProvider({ children }: { children: ReactNode }) { const [state, setState] = useState(() => createUnavailableState()); const [bridge, setBridge] = useState(null); + const [shellAvailable, setShellAvailable] = useState(false); useEffect(() => { if (typeof window === "undefined") { return; } - const nextBridge = window.islandflowDesktop ?? null; - if (!nextBridge?.ai) { + let disposed = false; + 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); - setState(createUnavailableState()); - return; + setState(createUnavailableState(runtime)); + return false; + }; + + if (!syncRuntime()) { + const pollForBridge = () => { + if (disposed) { + return; + } + + attempts += 1; + if (syncRuntime() || attempts >= BRIDGE_POLL_MAX_ATTEMPTS) { + return; + } + + pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS); + }; + + pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS); } - setBridge(nextBridge); - let unsubscribe = () => {}; - void nextBridge.ai.getState().then(setState).catch(() => { - setState((current) => ({ - ...current, - transportStatus: "error", - transportError: "The desktop AI bridge could not load its initial state." - })); - }); - - unsubscribe = nextBridge.ai.subscribe((nextState) => { - setState(nextState); - }); - return () => { + disposed = true; + if (pollTimer !== null) { + window.clearTimeout(pollTimer); + } unsubscribe(); }; }, []); @@ -156,6 +254,7 @@ export function DesktopAiProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ bridgeAvailable: Boolean(bridge?.ai), + shellAvailable, state, loginWithBrowser: bridge?.ai.loginWithBrowser ?? rejectDesktopOnly, loginWithDeviceCode: bridge?.ai.loginWithDeviceCode ?? rejectDesktopOnly, @@ -164,7 +263,7 @@ export function DesktopAiProvider({ children }: { children: ReactNode }) { updatePreferences: bridge?.ai.updatePreferences ?? rejectDesktopOnly, runTask: bridge?.ai.runTask ?? rejectDesktopOnly }), - [bridge, state] + [bridge, shellAvailable, state] ); return {children}; diff --git a/docs/turns/2026-05-20-fix-desktop-copilot-shell-detection.html b/docs/turns/2026-05-20-fix-desktop-copilot-shell-detection.html new file mode 100644 index 0000000..00904db --- /dev/null +++ b/docs/turns/2026-05-20-fix-desktop-copilot-shell-detection.html @@ -0,0 +1,463 @@ + + + + + + 2026-05-20 · Fix Desktop Copilot Shell Detection + + + +

+
+
+

Turn Record · 2026-05-20 18:34 EDT

+

Fix Desktop Copilot Shell Detection

+

+ 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”. +

+
+
+
+ Main Issue + islandflow-199 +
+
+ Files Changed + 3 +
+
+ Validation + Targeted tests passed, web build blocked by existing repo issue +
+
+
+ +
+

Summary

+

+ 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. +

+
+ +
+

Changes Made

+
    +
  • Added desktop runtime helpers in apps/web/app/desktop-ai.tsx to detect Electron shell presence separately from bridge presence.
  • +
  • Changed the desktop AI provider to poll briefly for late bridge availability instead of giving up after the first missing-read.
  • +
  • Reworked the provider’s unavailable/error state so desktop sessions show bridge-focused recovery guidance instead of browser-only copy.
  • +
  • Updated settings and Copilot action panels in apps/web/app/desktop-ai-panels.tsx to gate actions on bridge availability while only showing the browser fallback when the shell is genuinely absent.
  • +
  • Added regression coverage in apps/web/app/desktop-ai.test.ts for Electron detection, bridge-missing fallback state, and action helper copy.
  • +
  • Filed follow-up issue islandflow-c8f for the unrelated shared-types import-path problem currently blocking bun --cwd=apps/web run build.
  • +
+
+ +
+

Context

+
+
+

+ 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. +

+

+ That collapsed three different states into one misleading message: +

+
    +
  • Regular browser session, where desktop AI truly is unavailable.
  • +
  • Electron shell present, but the bridge appeared slightly later than the first effect pass.
  • +
  • Electron shell and bridge both present, but the initial getState() call failed.
  • +
+
+ +
+
+ +
+

Important Implementation Details

+
    +
  • Shell detection: the renderer now treats an Electron user-agent or a working preload bridge as evidence that the desktop shell is present.
  • +
  • Bridge recovery: the provider polls for up to 20 attempts at 250ms intervals so late preload exposure does not immediately collapse to browser fallback.
  • +
  • Better state semantics: unavailable-state generation now accepts runtime context and can mark desktopAvailable true while still reporting a bridge error.
  • +
  • UI gating: Settings and Copilot actions key off bridgeAvailable for actionable controls, while the “Desktop required” banner keys off shellAvailable.
  • +
  • Recovery copy: 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”.
  • +
+
+ Electron shell detection + Late bridge retry + Accurate fallback messaging + Regression tests +
+
+ +
+

Relevant Diff Snippets

+

+ Diff snippets are rendered as plain patch strings compatible with the format documented by + diffs.com. +

+
+
+
Renderer runtime detection and bridge polling
+
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 =>
++  Boolean(userAgent && ELECTRON_USER_AGENT_PATTERN.test(userAgent));
++
++export const resolveDesktopAiRuntime = (...) => {
++  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 = () => {
++        attempts += 1;
++        if (syncRuntime() || attempts >= BRIDGE_POLL_MAX_ATTEMPTS) {
++          return;
++        }
++        pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
++      };
++      pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
++    }
+
+
+
Settings and task panels stop calling the desktop shell a browser
+
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 ? (
+         <CopilotPane title="Desktop required" eyebrow="Browser-only fallback" wide>
+@@
+-const requireDesktopActionCopy = (desktopAvailable: boolean, loggedIn: boolean): string => {
+-  if (!desktopAvailable) {
++export const requireDesktopActionCopy = (shellAvailable: boolean, bridgeAvailable: boolean, loggedIn: boolean): string => {
++  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.";
++  }
+
+
+
Regression coverage for shell-vs-bridge behavior
+
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", () => {
++  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", () => {
++  const state = createUnavailableState({ shellAvailable: true });
++  expect(state.desktopAvailable).toBe(true);
++  expect(state.transportError).toContain("native AI bridge");
++});
+
+
+
+ +
+

Expected Impact for End-Users

+
    +
  • Desktop users no longer get told to “open Islandflow Desktop” when they are already in it.
  • +
  • If the preload bridge is delayed, the renderer now has a short recovery window before giving up.
  • +
  • If the bridge is missing or broken, the UI explains that exact condition and suggests a sensible recovery path.
  • +
  • Copilot action text now distinguishes between missing desktop shell, missing bridge, and missing account login.
  • +
+
+ +
+

Validation

+
+
+ Passed + bun test apps/web/app/desktop-ai.test.ts apps/web/app/routes.test.ts apps/web/app/terminal.test.ts +

All targeted renderer tests passed, including the new desktop shell and bridge regression coverage.

+
+
+ Blocked By Existing Repo Issue + bun --cwd=apps/web run build +

+ The build still fails during shared package type-checking because packages/types/src/desktop-ai.ts + imports sibling files with explicit .ts extensions. Follow-up issue: islandflow-c8f. +

+
+
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • The provider’s retry window is intentionally short. It helps with delayed bridge injection, but it does not try to hide a truly broken preload indefinitely.
  • +
  • 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.
  • +
  • The production web build remains red for an unrelated shared-types import-path issue. That blocker is explicitly tracked in islandflow-c8f so it does not disappear into session history.
  • +
+
+ +
+

Follow-up Work

+
    +
  • islandflow-c8f: remove or normalize explicit .ts import specifiers in packages/types so the Next.js production build can complete.
  • +
  • Optionally add desktop-shell telemetry to the topbar summary so bridge-missing sessions are visible outside Settings too.
  • +
  • If bridge timing remains flaky in practice, consider emitting a preload-ready event instead of relying only on a short polling window.
  • +
+
+
+ +