fix desktop copilot fallback inside electron
This commit is contained in:
parent
1543f419e6
commit
7b87f976a2
5 changed files with 751 additions and 94 deletions
|
|
@ -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<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const loginState = state.account.login;
|
||||
const actionsDisabled = busyAction !== null || !state.desktopAvailable;
|
||||
const actionsDisabled = busyAction !== null || !bridgeAvailable;
|
||||
|
||||
const runAction = async (label: string, action: () => Promise<void>) => {
|
||||
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<string | null>(null);
|
||||
const rateLimits = Object.values(state.rateLimitsByLimitId);
|
||||
|
|
@ -461,7 +461,7 @@ export function DesktopAiSettingsRoute() {
|
|||
|
||||
return (
|
||||
<div className="page-shell">
|
||||
{!state.desktopAvailable ? (
|
||||
{!shellAvailable ? (
|
||||
<CopilotPane title="Desktop required" eyebrow="Browser-only fallback" wide>
|
||||
<div className="copilot-unavailable">
|
||||
<p>
|
||||
|
|
@ -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}
|
||||
>
|
||||
<option value="">Use server default</option>
|
||||
{state.models.map((model) => (
|
||||
|
|
@ -507,7 +507,7 @@ export function DesktopAiSettingsRoute() {
|
|||
: null
|
||||
})
|
||||
}
|
||||
disabled={busyPreference !== null || !state.desktopAvailable}
|
||||
disabled={busyPreference !== null || !bridgeAvailable}
|
||||
>
|
||||
<option value="">Use model default</option>
|
||||
<option value="none">None</option>
|
||||
|
|
@ -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<IslandflowAiTaskKind | null>(null);
|
||||
const [activeTaskId, setActiveTaskId] = 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 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<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
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 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<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [taskError, setTaskError] = useState<string | null>(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 () => {
|
||||
|
|
|
|||
86
apps/web/app/desktop-ai.test.ts
Normal file
86
apps/web/app/desktop-ai.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>;
|
||||
loginWithDeviceCode: () => Promise<void>;
|
||||
|
|
@ -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<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: [
|
||||
{
|
||||
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<DesktopAiContextValue | null>(null);
|
||||
|
||||
|
|
@ -121,34 +172,81 @@ 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);
|
||||
|
||||
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<DesktopAiContextValue>(
|
||||
() => ({
|
||||
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 <DesktopAiContext.Provider value={value}>{children}</DesktopAiContext.Provider>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue