clarify desktop ai bridge recovery in settings

This commit is contained in:
dirtydishes 2026-05-20 19:01:54 -04:00
parent 7b87f976a2
commit 17b030f01f
5 changed files with 1237 additions and 151 deletions

View file

@ -17,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-dy2","title":"Clarify desktop AI settings when bridge is unavailable","description":"The /settings desktop AI panel currently renders disabled ChatGPT login buttons and empty-feeling model controls when the native bridge is unavailable. Users read this as broken UI because the controls do not clearly explain that the desktop shell is missing its bridge session and therefore cannot load login or model options. Update the settings surface to explain the unavailable state, provide direct recovery guidance, and make disabled controls self-explanatory.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T22:56:03Z","created_by":"dirtydishes","updated_at":"2026-05-20T23:01:33Z","started_at":"2026-05-20T22:56:26Z","closed_at":"2026-05-20T23:01:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-c8f","title":"fix packages/types ts-extension imports for next build","description":"## Why\\nThe web production build fails during type-checking because packages/types/src/desktop-ai.ts imports sibling files with explicit .ts extensions, which Next's TypeScript config rejects without allowImportingTsExtensions.\\n\\n## What\\nNormalize the packages/types import specifiers so Next can type-check the shared package during app builds, or adjust the shared tsconfig/build strategy in a deliberate way.\\n\\n## Acceptance Criteria\\n- bun --cwd=apps/web run build no longer fails on .ts-extension import paths from packages/types\\n- The chosen import-specifier strategy is consistent across packages/types","status":"open","priority":2,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-20T22:35:30Z","created_by":"dirtydishes","updated_at":"2026-05-20T22:35:30Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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}

View file

@ -13,7 +13,7 @@ import type {
IslandflowAiTaskKind, IslandflowAiTaskKind,
OptionFlowFilters, OptionFlowFilters,
OptionPrint, OptionPrint,
SmartMoneyEvent SmartMoneyEvent,
} from "@islandflow/types"; } from "@islandflow/types";
import { useDesktopAi } from "./desktop-ai"; import { useDesktopAi } from "./desktop-ai";
@ -22,7 +22,7 @@ const usdFormatter = new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 4 maximumFractionDigits: 4,
}); });
const humanizeValue = (value: string | null | undefined): string => { const humanizeValue = (value: string | null | undefined): string => {
@ -36,7 +36,8 @@ const humanizeValue = (value: string | null | undefined): string => {
const formatTokens = (value: number): string => numberFormatter.format(value); const formatTokens = (value: number): string => numberFormatter.format(value);
const formatUsd = (value: number | null): string => (value === null ? "Unavailable" : usdFormatter.format(value)); const formatUsd = (value: number | null): string =>
value === null ? "Unavailable" : usdFormatter.format(value);
const formatTimestamp = (value: number | null): string => { const formatTimestamp = (value: number | null): string => {
if (!value) { if (!value) {
@ -44,7 +45,7 @@ const formatTimestamp = (value: number | null): string => {
} }
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium", dateStyle: "medium",
timeStyle: "short" timeStyle: "short",
}).format(value); }).format(value);
}; };
@ -52,14 +53,94 @@ const formatPercent = (value: number): string => `${Math.round(value)}%`;
const getTaskStatusLabel = (value: string): string => humanizeValue(value); const getTaskStatusLabel = (value: string): string => humanizeValue(value);
const findTask = <T extends { taskId: string }>(tasks: T[], taskId: string | null): T | null => { type DesktopAiSettingsNotice = {
title: string;
body: string;
};
export const getDesktopAiSettingsBridgeNotice = (
shellAvailable: boolean,
bridgeAvailable: boolean,
): DesktopAiSettingsNotice | null => {
if (!shellAvailable) {
return {
title: "Desktop app required",
body: "Open Islandflow Desktop to connect ChatGPT, load managed models, and use native Copilot controls.",
};
}
if (!bridgeAvailable) {
return {
title: "Bridge unavailable in this window",
body: "This Islandflow Desktop window is missing its native AI bridge, so login actions and model controls stay disabled until the bridge reconnects. Reload the window or restart Islandflow if this keeps happening.",
};
}
return null;
};
export const getDesktopAiModelSelectLabel = (
shellAvailable: boolean,
bridgeAvailable: boolean,
loggedIn: boolean,
modelCount: number,
): string => {
if (!shellAvailable) {
return "Desktop app required";
}
if (!bridgeAvailable) {
return "Bridge unavailable";
}
if (!loggedIn) {
return "Connect ChatGPT to load models";
}
if (modelCount === 0) {
return "Waiting for managed models";
}
return "Use server default";
};
export const getDesktopAiModelListEmptyCopy = (
shellAvailable: boolean,
bridgeAvailable: boolean,
loggedIn: boolean,
): string => {
if (!shellAvailable) {
return "Open Islandflow Desktop to load managed model options.";
}
if (!bridgeAvailable) {
return "Managed model options will appear here after the native AI bridge reconnects.";
}
if (!loggedIn) {
return "Connect a ChatGPT or Codex account to load the managed model catalog.";
}
return "No model catalog has been reported yet.";
};
export const getDesktopAiProfileBadgeLabel = (
selected: boolean,
statusLabel: string,
bridgeAvailable: boolean,
): string => {
if (!bridgeAvailable) {
return statusLabel;
}
return selected ? "Selected" : statusLabel;
};
const findTask = <T extends { taskId: string }>(
tasks: T[],
taskId: string | null,
): T | null => {
if (!taskId) { if (!taskId) {
return null; return null;
} }
return tasks.find((task) => task.taskId === taskId) ?? null; return tasks.find((task) => task.taskId === taskId) ?? null;
}; };
const getCompiledScreenSummary = (compiled: IslandflowAiCompiledScreen): string[] => { const getCompiledScreenSummary = (
compiled: IslandflowAiCompiledScreen,
): string[] => {
const filters = compiled.compiledFilters; const filters = compiled.compiledFilters;
if (!filters) { if (!filters) {
return []; return [];
@ -90,7 +171,7 @@ const CopilotPane = ({
eyebrow, eyebrow,
actions, actions,
wide = false, wide = false,
children children,
}: { }: {
title: string; title: string;
eyebrow?: string; eyebrow?: string;
@ -99,7 +180,9 @@ const CopilotPane = ({
children: ReactNode; children: ReactNode;
}) => { }) => {
return ( return (
<section className={`terminal-pane copilot-pane${wide ? " copilot-pane-wide" : ""}`}> <section
className={`terminal-pane copilot-pane${wide ? " copilot-pane-wide" : ""}`}
>
<div className="terminal-pane-head"> <div className="terminal-pane-head">
<div className="terminal-pane-title-row"> <div className="terminal-pane-title-row">
<div> <div>
@ -107,7 +190,9 @@ const CopilotPane = ({
<h2 className="terminal-pane-title">{title}</h2> <h2 className="terminal-pane-title">{title}</h2>
</div> </div>
</div> </div>
{actions ? <div className="terminal-pane-actions">{actions}</div> : null} {actions ? (
<div className="terminal-pane-actions">{actions}</div>
) : null}
</div> </div>
<div className="terminal-pane-body copilot-pane-body">{children}</div> <div className="terminal-pane-body copilot-pane-body">{children}</div>
</section> </section>
@ -119,7 +204,7 @@ const UsageBreakdown = ({
breakdown, breakdown,
normalizedCostUsd, normalizedCostUsd,
turnCount, turnCount,
activeDays activeDays,
}: { }: {
title: string; title: string;
breakdown: { breakdown: {
@ -137,7 +222,9 @@ const UsageBreakdown = ({
<div className="copilot-usage-block"> <div className="copilot-usage-block">
<div className="copilot-usage-title-row"> <div className="copilot-usage-title-row">
<h3>{title}</h3> <h3>{title}</h3>
<span className="copilot-usage-cost">{formatUsd(normalizedCostUsd)}</span> <span className="copilot-usage-cost">
{formatUsd(normalizedCostUsd)}
</span>
</div> </div>
<div className="copilot-token-grid"> <div className="copilot-token-grid">
<div className="copilot-token-row"> <div className="copilot-token-row">
@ -173,31 +260,48 @@ const UsageBreakdown = ({
); );
}; };
const RateLimitBoard = ({ limit }: { limit: IslandflowAiRateLimitSnapshot }) => { const RateLimitBoard = ({
limit,
}: {
limit: IslandflowAiRateLimitSnapshot;
}) => {
return ( return (
<div className="copilot-limit-card" key={limit.limitId ?? limit.limitName ?? "default"}> <div
className="copilot-limit-card"
key={limit.limitId ?? limit.limitName ?? "default"}
>
<div className="copilot-limit-head"> <div className="copilot-limit-head">
<div> <div>
<strong>{limit.limitName ?? "Default rate window"}</strong> <strong>{limit.limitName ?? "Default rate window"}</strong>
<p className="copilot-note"> <p className="copilot-note">
{limit.planType ? `Plan ${humanizeValue(limit.planType)}` : "Plan not reported"} {limit.planType
? `Plan ${humanizeValue(limit.planType)}`
: "Plan not reported"}
</p> </p>
</div> </div>
{limit.reachedType ? <span className="copilot-badge warning">{humanizeValue(limit.reachedType)}</span> : null} {limit.reachedType ? (
<span className="copilot-badge warning">
{humanizeValue(limit.reachedType)}
</span>
) : null}
</div> </div>
<div className="copilot-limit-grid"> <div className="copilot-limit-grid">
{limit.primary ? ( {limit.primary ? (
<div className="copilot-limit-window"> <div className="copilot-limit-window">
<span>Primary</span> <span>Primary</span>
<strong>{formatPercent(limit.primary.usedPercent)}</strong> <strong>{formatPercent(limit.primary.usedPercent)}</strong>
<p className="copilot-note">Resets {formatTimestamp(limit.primary.resetsAt)}</p> <p className="copilot-note">
Resets {formatTimestamp(limit.primary.resetsAt)}
</p>
</div> </div>
) : null} ) : null}
{limit.secondary ? ( {limit.secondary ? (
<div className="copilot-limit-window"> <div className="copilot-limit-window">
<span>Secondary</span> <span>Secondary</span>
<strong>{formatPercent(limit.secondary.usedPercent)}</strong> <strong>{formatPercent(limit.secondary.usedPercent)}</strong>
<p className="copilot-note">Resets {formatTimestamp(limit.secondary.resetsAt)}</p> <p className="copilot-note">
Resets {formatTimestamp(limit.secondary.resetsAt)}
</p>
</div> </div>
) : null} ) : null}
</div> </div>
@ -207,10 +311,10 @@ const RateLimitBoard = ({ limit }: { limit: IslandflowAiRateLimitSnapshot }) =>
{limit.unlimitedCredits {limit.unlimitedCredits
? "unlimited" ? "unlimited"
: limit.creditsBalance : limit.creditsBalance
? limit.creditsBalance ? limit.creditsBalance
: limit.hasCredits === false : limit.hasCredits === false
? "none" ? "none"
: "not reported"} : "not reported"}
</p> </p>
) : null} ) : null}
</div> </div>
@ -219,7 +323,7 @@ const RateLimitBoard = ({ limit }: { limit: IslandflowAiRateLimitSnapshot }) =>
const TaskOutput = ({ const TaskOutput = ({
taskId, taskId,
emptyMessage emptyMessage,
}: { }: {
taskId: string | null; taskId: string | null;
emptyMessage: string; emptyMessage: string;
@ -240,16 +344,24 @@ const TaskOutput = ({
{task.subtitle} · {getTaskStatusLabel(task.status)} {task.subtitle} · {getTaskStatusLabel(task.status)}
</p> </p>
</div> </div>
<span className={`copilot-badge status-${task.status}`}>{getTaskStatusLabel(task.status)}</span> <span className={`copilot-badge status-${task.status}`}>
{getTaskStatusLabel(task.status)}
</span>
</div> </div>
{task.error ? <p className="copilot-error">{task.error}</p> : null} {task.error ? <p className="copilot-error">{task.error}</p> : null}
{task.text ? <pre className="copilot-task-text">{task.text}</pre> : null} {task.text ? <pre className="copilot-task-text">{task.text}</pre> : null}
{task.compiledScreen ? <CompiledScreenResult compiled={task.compiledScreen} /> : null} {task.compiledScreen ? (
<CompiledScreenResult compiled={task.compiledScreen} />
) : null}
</div> </div>
); );
}; };
const CompiledScreenResult = ({ compiled }: { compiled: IslandflowAiCompiledScreen }) => { const CompiledScreenResult = ({
compiled,
}: {
compiled: IslandflowAiCompiledScreen;
}) => {
const summary = getCompiledScreenSummary(compiled); const summary = getCompiledScreenSummary(compiled);
return ( return (
@ -263,7 +375,9 @@ const CompiledScreenResult = ({ compiled }: { compiled: IslandflowAiCompiledScre
))} ))}
</div> </div>
) : ( ) : (
<p className="copilot-note">No filter fields were compiled from this prompt.</p> <p className="copilot-note">
No filter fields were compiled from this prompt.
</p>
)} )}
{compiled.unhandledClauses.length > 0 ? ( {compiled.unhandledClauses.length > 0 ? (
<div className="copilot-unhandled-list"> <div className="copilot-unhandled-list">
@ -282,7 +396,7 @@ const CompiledScreenResult = ({ compiled }: { compiled: IslandflowAiCompiledScre
const AccountSummary = ({ const AccountSummary = ({
loggedIn, loggedIn,
email, email,
planType planType,
}: { }: {
loggedIn: boolean; loggedIn: boolean;
email: string | null; email: string | null;
@ -294,18 +408,21 @@ const AccountSummary = ({
<p className="copilot-kicker">Desktop-only official Codex bridge</p> <p className="copilot-kicker">Desktop-only official Codex bridge</p>
<h1 className="page-title">Analyst Copilot</h1> <h1 className="page-title">Analyst Copilot</h1>
<p className="copilot-hero-copy"> <p className="copilot-hero-copy">
Managed ChatGPT login stays user-scoped, deterministic smart-money classification stays in charge, and every Managed ChatGPT login stays user-scoped, deterministic smart-money
AI turn is tracked with exact token telemetry from the app-server. classification stays in charge, and every AI turn is tracked with
exact token telemetry from the app-server.
</p> </p>
</div> </div>
<div className="copilot-hero-meta"> <div className="copilot-hero-meta">
<div className="copilot-stat"> <div className="copilot-stat">
<span>Account</span> <span>Account</span>
<strong>{loggedIn ? email ?? "Connected" : "Disconnected"}</strong> <strong>{loggedIn ? (email ?? "Connected") : "Disconnected"}</strong>
</div> </div>
<div className="copilot-stat"> <div className="copilot-stat">
<span>Plan</span> <span>Plan</span>
<strong>{loggedIn ? humanizeValue(planType) : "Not connected"}</strong> <strong>
{loggedIn ? humanizeValue(planType) : "Not connected"}
</strong>
</div> </div>
</div> </div>
</div> </div>
@ -313,11 +430,23 @@ const AccountSummary = ({
}; };
const LoginStatePanel = () => { const LoginStatePanel = () => {
const { bridgeAvailable, state, loginWithBrowser, loginWithDeviceCode, cancelLogin, logout } = useDesktopAi(); const {
bridgeAvailable,
shellAvailable,
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 || !bridgeAvailable; const actionsDisabled = busyAction !== null || !bridgeAvailable;
const bridgeNotice = getDesktopAiSettingsBridgeNotice(
shellAvailable,
bridgeAvailable,
);
const runAction = async (label: string, action: () => Promise<void>) => { const runAction = async (label: string, action: () => Promise<void>) => {
setBusyAction(label); setBusyAction(label);
@ -339,14 +468,24 @@ const LoginStatePanel = () => {
actions={ actions={
<> <>
{state.account.loggedIn ? ( {state.account.loggedIn ? (
<button
className="terminal-button"
type="button"
onClick={() => void runAction("logout", logout)}
disabled={actionsDisabled}
>
{busyAction === "logout" ? "Logging out" : "Logout"}
</button>
) : bridgeNotice ? (
shellAvailable ? (
<button <button
className="terminal-button" className="terminal-button terminal-button-primary"
type="button" type="button"
onClick={() => void runAction("logout", logout)} onClick={() => window.location.reload()}
disabled={actionsDisabled}
> >
{busyAction === "logout" ? "Logging out" : "Logout"} Reload window
</button> </button>
) : null
) : ( ) : (
<> <>
<button <button
@ -367,15 +506,17 @@ const LoginStatePanel = () => {
</button> </button>
</> </>
)} )}
{(loginState.status === "browser_pending" || loginState.status === "device_code_pending") && !state.account.loggedIn ? ( {(loginState.status === "browser_pending" ||
<button loginState.status === "device_code_pending") &&
className="terminal-button" !state.account.loggedIn ? (
type="button" <button
onClick={() => void runAction("cancel", cancelLogin)} className="terminal-button"
disabled={actionsDisabled} type="button"
> onClick={() => void runAction("cancel", cancelLogin)}
Cancel disabled={actionsDisabled}
</button> >
Cancel
</button>
) : null} ) : null}
</> </>
} }
@ -385,6 +526,12 @@ const LoginStatePanel = () => {
email={state.account.email} email={state.account.email}
planType={state.account.planType} planType={state.account.planType}
/> />
{bridgeNotice ? (
<div className="copilot-callout copilot-callout-warning">
<strong>{bridgeNotice.title}</strong>
<p className="copilot-note">{bridgeNotice.body}</p>
</div>
) : null}
<div className="copilot-account-grid"> <div className="copilot-account-grid">
<div className="copilot-account-card"> <div className="copilot-account-card">
<div className="copilot-list-title">Profile slots</div> <div className="copilot-list-title">Profile slots</div>
@ -394,8 +541,14 @@ const LoginStatePanel = () => {
<strong>{profile.label}</strong> <strong>{profile.label}</strong>
<p className="copilot-note">{profile.description}</p> <p className="copilot-note">{profile.description}</p>
</div> </div>
<span className={`copilot-badge${profile.enabled ? "" : " muted"}`}> <span
{profile.selected ? "Selected" : profile.statusLabel} className={`copilot-badge${profile.enabled ? "" : " muted"}`}
>
{getDesktopAiProfileBadgeLabel(
profile.selected,
profile.statusLabel,
bridgeAvailable,
)}
</span> </span>
</div> </div>
))} ))}
@ -414,19 +567,29 @@ const LoginStatePanel = () => {
<span>OpenAI auth required</span> <span>OpenAI auth required</span>
<strong>{state.account.requiresOpenaiAuth ? "Yes" : "No"}</strong> <strong>{state.account.requiresOpenaiAuth ? "Yes" : "No"}</strong>
</div> </div>
{state.transportError ? <p className="copilot-error">{state.transportError}</p> : null} {state.transportError ? (
{loginState.message ? <p className="copilot-note">{loginState.message}</p> : null} <p className="copilot-error">{state.transportError}</p>
) : null}
{loginState.message ? (
<p className="copilot-note">{loginState.message}</p>
) : null}
{loginState.status === "browser_pending" ? ( {loginState.status === "browser_pending" ? (
<div className="copilot-callout"> <div className="copilot-callout">
<strong>Browser login in progress</strong> <strong>Browser login in progress</strong>
<p className="copilot-note">Finish the ChatGPT sign-in flow in your browser. Islandflow will update automatically.</p> <p className="copilot-note">
Finish the ChatGPT sign-in flow in your browser. Islandflow will
update automatically.
</p>
</div> </div>
) : null} ) : null}
{loginState.status === "device_code_pending" ? ( {loginState.status === "device_code_pending" ? (
<div className="copilot-callout"> <div className="copilot-callout">
<strong>Device code</strong> <strong>Device code</strong>
<pre className="copilot-device-code">{loginState.userCode}</pre> <pre className="copilot-device-code">{loginState.userCode}</pre>
<p className="copilot-note">Visit {loginState.verificationUrl} in any browser and enter the code above.</p> <p className="copilot-note">
Visit {loginState.verificationUrl} in any browser and enter the
code above.
</p>
</div> </div>
) : null} ) : null}
{actionError ? <p className="copilot-error">{actionError}</p> : null} {actionError ? <p className="copilot-error">{actionError}</p> : null}
@ -437,23 +600,46 @@ const LoginStatePanel = () => {
}; };
export function DesktopAiSettingsRoute() { export function DesktopAiSettingsRoute() {
const { bridgeAvailable, shellAvailable, state, updatePreferences } = useDesktopAi(); const { bridgeAvailable, shellAvailable, state, updatePreferences } =
const [busyPreference, setBusyPreference] = useState<"model" | "reasoning" | null>(null); useDesktopAi();
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);
const selectedModel = state.preferences.model ?? ""; const selectedModel = state.preferences.model ?? "";
const selectedReasoning = state.preferences.reasoningEffort ?? ""; const selectedReasoning = state.preferences.reasoningEffort ?? "";
const modelControlsNotice = getDesktopAiSettingsBridgeNotice(
shellAvailable,
bridgeAvailable,
);
const modelSelectLabel = getDesktopAiModelSelectLabel(
shellAvailable,
bridgeAvailable,
state.account.loggedIn,
state.models.length,
);
const modelListEmptyCopy = getDesktopAiModelListEmptyCopy(
shellAvailable,
bridgeAvailable,
state.account.loggedIn,
);
const savePreference = async ( const savePreference = async (
key: "model" | "reasoning", key: "model" | "reasoning",
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }> next: Partial<{
model: string | null;
reasoningEffort: IslandflowAiReasoningEffort | null;
}>,
) => { ) => {
setBusyPreference(key); setBusyPreference(key);
setPreferenceError(null); setPreferenceError(null);
try { try {
await updatePreferences(next); await updatePreferences(next);
} catch (error) { } catch (error) {
setPreferenceError(error instanceof Error ? error.message : String(error)); setPreferenceError(
error instanceof Error ? error.message : String(error),
);
} finally { } finally {
setBusyPreference(null); setBusyPreference(null);
} }
@ -462,11 +648,16 @@ export function DesktopAiSettingsRoute() {
return ( return (
<div className="page-shell"> <div className="page-shell">
{!shellAvailable ? ( {!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>
AI controls are intentionally read-only in the browser build. Open Islandflow Desktop to use managed ChatGPT AI controls are intentionally read-only in the browser build. Open
login, structured Copilot turns, and app-server token telemetry. Islandflow Desktop to use managed ChatGPT login, structured
Copilot turns, and app-server token telemetry.
</p> </p>
</div> </div>
</CopilotPane> </CopilotPane>
@ -476,6 +667,17 @@ export function DesktopAiSettingsRoute() {
<div className="page-grid page-grid-settings"> <div className="page-grid page-grid-settings">
<CopilotPane title="Model controls" eyebrow="Execution"> <CopilotPane title="Model controls" eyebrow="Execution">
{modelControlsNotice ? (
<div className="copilot-callout copilot-callout-warning">
<strong>{modelControlsNotice.title}</strong>
<p className="copilot-note">{modelControlsNotice.body}</p>
</div>
) : state.models.length === 0 ? (
<div className="copilot-callout">
<strong>Managed models are not loaded yet</strong>
<p className="copilot-note">{modelListEmptyCopy}</p>
</div>
) : null}
<div className="copilot-field-grid"> <div className="copilot-field-grid">
<label className="copilot-field"> <label className="copilot-field">
<span className="copilot-field-label">Model</span> <span className="copilot-field-label">Model</span>
@ -483,11 +685,19 @@ export function DesktopAiSettingsRoute() {
className="copilot-select" className="copilot-select"
value={selectedModel} value={selectedModel}
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 ||
!bridgeAvailable
} }
disabled={busyPreference !== null || state.models.length === 0 || !bridgeAvailable}
> >
<option value="">Use server default</option> <option value="">{modelSelectLabel}</option>
{state.models.map((model) => ( {state.models.map((model) => (
<option key={model.id} value={model.model}> <option key={model.id} value={model.model}>
{model.displayName} {model.displayName}
@ -504,7 +714,7 @@ export function DesktopAiSettingsRoute() {
void savePreference("reasoning", { void savePreference("reasoning", {
reasoningEffort: event.target.value.trim() reasoningEffort: event.target.value.trim()
? (event.target.value as IslandflowAiReasoningEffort) ? (event.target.value as IslandflowAiReasoningEffort)
: null : null,
}) })
} }
disabled={busyPreference !== null || !bridgeAvailable} disabled={busyPreference !== null || !bridgeAvailable}
@ -520,40 +730,62 @@ export function DesktopAiSettingsRoute() {
</label> </label>
</div> </div>
<div className="copilot-model-list"> <div className="copilot-model-list">
{state.models.map((model) => ( {state.models.length === 0 ? (
<div className="copilot-model-row" key={model.id}> <p className="copilot-empty">{modelListEmptyCopy}</p>
<div> ) : (
<strong>{model.displayName}</strong> state.models.map((model) => (
<p className="copilot-note">{model.description}</p> <div className="copilot-model-row" key={model.id}>
<div>
<strong>{model.displayName}</strong>
<p className="copilot-note">{model.description}</p>
</div>
<div className="copilot-model-meta">
<span>{model.model}</span>
{model.pricing ? (
<span>
{formatUsd(model.pricing.inputUsdPer1MTokens)} / 1M
input
</span>
) : null}
</div>
</div> </div>
<div className="copilot-model-meta"> ))
<span>{model.model}</span> )}
{model.pricing ? <span>{formatUsd(model.pricing.inputUsdPer1MTokens)} / 1M input</span> : null}
</div>
</div>
))}
</div> </div>
{state.models.find((model) => model.model === state.preferences.model)?.pricing ? ( {state.models.find((model) => model.model === state.preferences.model)
?.pricing ? (
<p className="copilot-note"> <p className="copilot-note">
Normalized estimates use current API pricing for the selected model, not your literal ChatGPT subscription bill. Normalized estimates use current API pricing for the selected
model, not your literal ChatGPT subscription bill.
</p> </p>
) : null} ) : null}
{preferenceError ? <p className="copilot-error">{preferenceError}</p> : null} {preferenceError ? (
<p className="copilot-error">{preferenceError}</p>
) : null}
</CopilotPane> </CopilotPane>
<CopilotPane title="Rate limits" eyebrow="Live windows"> <CopilotPane title="Rate limits" eyebrow="Live windows">
{rateLimits.length === 0 ? ( {rateLimits.length === 0 ? (
<p className="copilot-empty">No rate-limit snapshots have been reported yet.</p> <p className="copilot-empty">
No rate-limit snapshots have been reported yet.
</p>
) : ( ) : (
<div className="copilot-limit-list"> <div className="copilot-limit-list">
{rateLimits.map((limit) => ( {rateLimits.map((limit) => (
<RateLimitBoard key={limit.limitId ?? limit.limitName ?? "default"} limit={limit} /> <RateLimitBoard
key={limit.limitId ?? limit.limitName ?? "default"}
limit={limit}
/>
))} ))}
</div> </div>
)} )}
</CopilotPane> </CopilotPane>
<CopilotPane title="Usage dashboard" eyebrow="Exact app-server telemetry" wide> <CopilotPane
title="Usage dashboard"
eyebrow="Exact app-server telemetry"
wide
>
<div className="copilot-usage-grid"> <div className="copilot-usage-grid">
<UsageBreakdown <UsageBreakdown
title="Today" title="Today"
@ -578,11 +810,15 @@ export function DesktopAiSettingsRoute() {
) : ( ) : (
<div className="copilot-turn-list"> <div className="copilot-turn-list">
{state.usage.recentTurns.map((turn) => ( {state.usage.recentTurns.map((turn) => (
<div className="copilot-turn-row" key={`${turn.threadId}:${turn.turnId}`}> <div
className="copilot-turn-row"
key={`${turn.threadId}:${turn.turnId}`}
>
<div> <div>
<strong>{turn.taskTitle ?? "Ad hoc turn"}</strong> <strong>{turn.taskTitle ?? "Ad hoc turn"}</strong>
<p className="copilot-note"> <p className="copilot-note">
{turn.model ?? "default"} · {formatTimestamp(turn.updatedAt)} {turn.model ?? "default"} ·{" "}
{formatTimestamp(turn.updatedAt)}
</p> </p>
</div> </div>
<div className="copilot-turn-metrics"> <div className="copilot-turn-metrics">
@ -608,7 +844,9 @@ export function DesktopAiSettingsRoute() {
{task.subtitle} · {humanizeValue(task.model)} {task.subtitle} · {humanizeValue(task.model)}
</p> </p>
</div> </div>
<span className={`copilot-badge status-${task.status}`}>{getTaskStatusLabel(task.status)}</span> <span className={`copilot-badge status-${task.status}`}>
{getTaskStatusLabel(task.status)}
</span>
</div> </div>
))} ))}
</div> </div>
@ -622,7 +860,7 @@ export function DesktopAiSettingsRoute() {
export const requireDesktopActionCopy = ( export const requireDesktopActionCopy = (
shellAvailable: boolean, shellAvailable: boolean,
bridgeAvailable: boolean, bridgeAvailable: boolean,
loggedIn: boolean loggedIn: boolean,
): string => { ): string => {
if (!shellAvailable) { 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.";
@ -642,7 +880,7 @@ const SmartMoneyTaskButton = ({
symbol, symbol,
disabled, disabled,
busyKind, busyKind,
onRun onRun,
}: { }: {
label: string; label: string;
kind: IslandflowAiTaskKind; kind: IslandflowAiTaskKind;
@ -668,7 +906,7 @@ export function SmartMoneyCopilotPanel({
event, event,
flowPacket, flowPacket,
evidencePrints, evidencePrints,
relatedPackets relatedPackets,
}: { }: {
event: SmartMoneyEvent; event: SmartMoneyEvent;
flowPacket: FlowPacket | null; flowPacket: FlowPacket | null;
@ -679,7 +917,11 @@ export function SmartMoneyCopilotPanel({
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(shellAvailable, 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) => {
@ -696,8 +938,8 @@ export function SmartMoneyCopilotPanel({
event, event,
flowPacket, flowPacket,
evidencePrints, evidencePrints,
relatedPackets relatedPackets,
} },
}); });
setActiveTaskId(result.taskId); setActiveTaskId(result.taskId);
} catch (error) { } catch (error) {
@ -712,7 +954,10 @@ export function SmartMoneyCopilotPanel({
<div className="copilot-inline-head"> <div className="copilot-inline-head">
<div> <div>
<div className="copilot-list-title">Analyst Copilot</div> <div className="copilot-list-title">Analyst Copilot</div>
<p className="copilot-note">Structured interpretation only, the deterministic classifier remains the source of truth.</p> <p className="copilot-note">
Structured interpretation only, the deterministic classifier remains
the source of truth.
</p>
</div> </div>
<Link className="terminal-button" href="/settings"> <Link className="terminal-button" href="/settings">
AI settings AI settings
@ -754,7 +999,10 @@ export function SmartMoneyCopilotPanel({
</div> </div>
{disabledCopy ? <p className="copilot-note">{disabledCopy}</p> : null} {disabledCopy ? <p className="copilot-note">{disabledCopy}</p> : null}
{taskError ? <p className="copilot-error">{taskError}</p> : null} {taskError ? <p className="copilot-error">{taskError}</p> : null}
<TaskOutput taskId={activeTaskId} emptyMessage="Run an explanation, skepticism pass, burst summary, or watchlist synthesis to see the result here." /> <TaskOutput
taskId={activeTaskId}
emptyMessage="Run an explanation, skepticism pass, burst summary, or watchlist synthesis to see the result here."
/>
</div> </div>
); );
} }
@ -766,7 +1014,7 @@ export function ReplayCopilotPanel({
smartMoneyEvents, smartMoneyEvents,
classifierHits, classifierHits,
flowPackets, flowPackets,
optionPrints optionPrints,
}: { }: {
ticker: string | null; ticker: string | null;
flowFilters: OptionFlowFilters; flowFilters: OptionFlowFilters;
@ -780,7 +1028,11 @@ export function ReplayCopilotPanel({
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(shellAvailable, 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 () => {
@ -796,8 +1048,8 @@ export function ReplayCopilotPanel({
smartMoneyEvents, smartMoneyEvents,
classifierHits, classifierHits,
flowPackets, flowPackets,
optionPrints optionPrints,
} },
}); });
setActiveTaskId(result.taskId); setActiveTaskId(result.taskId);
} catch (error) { } catch (error) {
@ -828,18 +1080,22 @@ export function ReplayCopilotPanel({
} }
> >
<p className="copilot-note"> <p className="copilot-note">
Copilot uses the current replay slice only: ticker scope, flow filters, visible alerts, classifier hits, packets, and option prints. Copilot uses the current replay slice only: ticker scope, flow filters,
visible alerts, classifier hits, packets, and option prints.
</p> </p>
{disabledCopy ? <p className="copilot-note">{disabledCopy}</p> : null} {disabledCopy ? <p className="copilot-note">{disabledCopy}</p> : null}
{taskError ? <p className="copilot-error">{taskError}</p> : null} {taskError ? <p className="copilot-error">{taskError}</p> : null}
<TaskOutput taskId={activeTaskId} emptyMessage="Generate a replay postmortem to capture the cleanest read from the current session slice." /> <TaskOutput
taskId={activeTaskId}
emptyMessage="Generate a replay postmortem to capture the cleanest read from the current session slice."
/>
</CopilotPane> </CopilotPane>
); );
} }
export function ScreenCompilerPanel({ export function ScreenCompilerPanel({
currentFilters, currentFilters,
onApplyFilters onApplyFilters,
}: { }: {
currentFilters: OptionFlowFilters; currentFilters: OptionFlowFilters;
onApplyFilters: (next: OptionFlowFilters) => void; onApplyFilters: (next: OptionFlowFilters) => void;
@ -849,8 +1105,15 @@ export function ScreenCompilerPanel({
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(
const disabledCopy = requireDesktopActionCopy(shellAvailable, bridgeAvailable, state.account.loggedIn); () => findTask(state.tasks, activeTaskId),
[state.tasks, activeTaskId],
);
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 () => {
@ -867,8 +1130,8 @@ export function ScreenCompilerPanel({
kind: "screen-compile", kind: "screen-compile",
context: { context: {
prompt: trimmedPrompt, prompt: trimmedPrompt,
currentFilters currentFilters,
} },
}); });
setActiveTaskId(result.taskId); setActiveTaskId(result.taskId);
} catch (error) { } catch (error) {
@ -913,19 +1176,28 @@ export function ScreenCompilerPanel({
</label> </label>
<div className="copilot-current-filters"> <div className="copilot-current-filters">
<div className="copilot-list-title">Current filter baseline</div> <div className="copilot-list-title">Current filter baseline</div>
<pre className="copilot-json-block">{JSON.stringify(currentFilters, null, 2)}</pre> <pre className="copilot-json-block">
{JSON.stringify(currentFilters, null, 2)}
</pre>
</div> </div>
</div> </div>
{disabledCopy ? <p className="copilot-note">{disabledCopy}</p> : null} {disabledCopy ? <p className="copilot-note">{disabledCopy}</p> : null}
{taskError ? <p className="copilot-error">{taskError}</p> : null} {taskError ? <p className="copilot-error">{taskError}</p> : null}
{compiledFilters ? ( {compiledFilters ? (
<div className="copilot-apply-row"> <div className="copilot-apply-row">
<button className="terminal-button" type="button" onClick={() => onApplyFilters(compiledFilters)}> <button
className="terminal-button"
type="button"
onClick={() => onApplyFilters(compiledFilters)}
>
Apply compiled filters Apply compiled filters
</button> </button>
</div> </div>
) : null} ) : null}
<TaskOutput taskId={activeTaskId} emptyMessage="Compile a natural-language screen to preview the translated filter set and rationale." /> <TaskOutput
taskId={activeTaskId}
emptyMessage="Compile a natural-language screen to preview the translated filter set and rationale."
/>
</CopilotPane> </CopilotPane>
); );
} }

View file

@ -3,14 +3,22 @@ import { describe, expect, it } from "bun:test";
import { import {
createUnavailableState, createUnavailableState,
detectDesktopShell, detectDesktopShell,
resolveDesktopAiRuntime resolveDesktopAiRuntime,
} from "./desktop-ai"; } from "./desktop-ai";
import { requireDesktopActionCopy } from "./desktop-ai-panels"; import {
getDesktopAiModelListEmptyCopy,
getDesktopAiModelSelectLabel,
getDesktopAiProfileBadgeLabel,
getDesktopAiSettingsBridgeNotice,
requireDesktopActionCopy,
} from "./desktop-ai-panels";
describe("desktop ai runtime detection", () => { describe("desktop ai runtime detection", () => {
it("recognizes Electron user agents before the bridge is available", () => { it("recognizes Electron user agents before the bridge is available", () => {
const runtime = resolveDesktopAiRuntime({ const runtime = resolveDesktopAiRuntime({
navigator: { userAgent: "Mozilla/5.0 Islandflow Electron/39.0.0 Safari/537.36" } navigator: {
userAgent: "Mozilla/5.0 Islandflow Electron/39.0.0 Safari/537.36",
},
}); });
expect(runtime.shellAvailable).toBe(true); expect(runtime.shellAvailable).toBe(true);
@ -22,17 +30,21 @@ describe("desktop ai runtime detection", () => {
const runtime = resolveDesktopAiRuntime({ const runtime = resolveDesktopAiRuntime({
islandflowDesktop: { islandflowDesktop: {
ai: { ai: {
getState: async () => createUnavailableState({ shellAvailable: true, bridgeAvailable: true }), getState: async () =>
createUnavailableState({
shellAvailable: true,
bridgeAvailable: true,
}),
loginWithBrowser: async () => {}, loginWithBrowser: async () => {},
loginWithDeviceCode: async () => {}, loginWithDeviceCode: async () => {},
cancelLogin: async () => {}, cancelLogin: async () => {},
logout: async () => {}, logout: async () => {},
updatePreferences: async () => {}, updatePreferences: async () => {},
runTask: async () => ({ taskId: "task-1" }), runTask: async () => ({ taskId: "task-1" }),
subscribe: () => () => {} subscribe: () => () => {},
} },
}, },
navigator: { userAgent: "Mozilla/5.0" } navigator: { userAgent: "Mozilla/5.0" },
}); });
expect(runtime.shellAvailable).toBe(true); expect(runtime.shellAvailable).toBe(true);
@ -62,15 +74,21 @@ describe("desktop ai unavailable state", () => {
describe("desktop action copy", () => { describe("desktop action copy", () => {
it("asks for the desktop app only when the shell is genuinely absent", () => { it("asks for the desktop app only when the shell is genuinely absent", () => {
expect(requireDesktopActionCopy(false, false, false)).toContain("Open Islandflow Desktop"); expect(requireDesktopActionCopy(false, false, false)).toContain(
"Open Islandflow Desktop",
);
}); });
it("surfaces bridge recovery guidance inside the desktop shell", () => { it("surfaces bridge recovery guidance inside the desktop shell", () => {
expect(requireDesktopActionCopy(true, false, false)).toContain("missing the native AI bridge"); expect(requireDesktopActionCopy(true, false, false)).toContain(
"missing the native AI bridge",
);
}); });
it("asks for login once the bridge is present", () => { it("asks for login once the bridge is present", () => {
expect(requireDesktopActionCopy(true, true, false)).toContain("Connect a ChatGPT or Codex account"); expect(requireDesktopActionCopy(true, true, false)).toContain(
"Connect a ChatGPT or Codex account",
);
}); });
it("clears helper copy when the action is ready", () => { it("clears helper copy when the action is ready", () => {
@ -81,6 +99,50 @@ describe("desktop action copy", () => {
describe("desktop shell detection", () => { describe("desktop shell detection", () => {
it("matches Electron signatures", () => { it("matches Electron signatures", () => {
expect(detectDesktopShell("Mozilla/5.0 Electron/39.0.0")).toBe(true); 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); expect(
detectDesktopShell("Mozilla/5.0 Chrome/136.0.0.0 Safari/537.36"),
).toBe(false);
});
});
describe("desktop ai settings copy", () => {
it("explains when the desktop app itself is required", () => {
expect(getDesktopAiSettingsBridgeNotice(false, false)).toEqual({
title: "Desktop app required",
body: "Open Islandflow Desktop to connect ChatGPT, load managed models, and use native Copilot controls.",
});
});
it("explains when the native bridge is missing from the desktop window", () => {
expect(getDesktopAiSettingsBridgeNotice(true, false)?.title).toBe(
"Bridge unavailable in this window",
);
});
it("keeps the model selector explicit before login", () => {
expect(getDesktopAiModelSelectLabel(true, true, false, 0)).toBe(
"Connect ChatGPT to load models",
);
expect(getDesktopAiModelListEmptyCopy(true, true, false)).toContain(
"Connect a ChatGPT or Codex account",
);
});
it("keeps the model selector explicit while the bridge is disconnected", () => {
expect(getDesktopAiModelSelectLabel(true, false, false, 0)).toBe(
"Bridge unavailable",
);
expect(getDesktopAiModelListEmptyCopy(true, false, false)).toContain(
"native AI bridge reconnects",
);
});
it("shows the real status label when a selected profile is unusable", () => {
expect(
getDesktopAiProfileBadgeLabel(true, "Bridge unavailable", false),
).toBe("Bridge unavailable");
expect(
getDesktopAiProfileBadgeLabel(true, "Bridge unavailable", true),
).toBe("Selected");
}); });
}); });

View file

@ -37,7 +37,11 @@ body {
font-family: var(--font-sans), sans-serif; font-family: var(--font-sans), sans-serif;
color: var(--text); color: var(--text);
background: background:
radial-gradient(circle at top left, oklch(0.78 0.12 74 / 0.08), transparent 30%), radial-gradient(
circle at top left,
oklch(0.78 0.12 74 / 0.08),
transparent 30%
),
linear-gradient(180deg, oklch(0.15 0.012 250) 0%, oklch(0.11 0.01 250) 100%); linear-gradient(180deg, oklch(0.15 0.012 250) 0%, oklch(0.11 0.01 250) 100%);
} }
@ -89,7 +93,11 @@ input {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-columns: var(--rail-width) minmax(0, 1fr); grid-template-columns: var(--rail-width) minmax(0, 1fr);
background: linear-gradient(180deg, oklch(0.14 0.011 250) 0%, oklch(0.11 0.01 250) 100%); background: linear-gradient(
180deg,
oklch(0.14 0.011 250) 0%,
oklch(0.11 0.01 250) 100%
);
} }
.terminal-rail { .terminal-rail {
@ -100,7 +108,11 @@ input {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
background: linear-gradient(180deg, oklch(0.16 0.012 250 / 0.98), oklch(0.13 0.011 250 / 0.98)); background: linear-gradient(
180deg,
oklch(0.16 0.012 250 / 0.98),
oklch(0.13 0.011 250 / 0.98)
);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
} }
@ -140,7 +152,10 @@ input {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.14em; letter-spacing: 0.14em;
font-size: 0.76rem; font-size: 0.76rem;
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease; transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
} }
.terminal-nav-link:hover { .terminal-nav-link:hover {
@ -1009,8 +1024,16 @@ h3 {
.copilot-pane { .copilot-pane {
background: background:
radial-gradient(circle at top right, oklch(0.8 0.12 74 / 0.07), transparent 36%), radial-gradient(
linear-gradient(180deg, oklch(0.18 0.013 250) 0%, oklch(0.16 0.012 250) 100%); circle at top right,
oklch(0.8 0.12 74 / 0.07),
transparent 36%
),
linear-gradient(
180deg,
oklch(0.18 0.013 250) 0%,
oklch(0.16 0.012 250) 100%
);
} }
.copilot-pane-body { .copilot-pane-body {
@ -1038,7 +1061,11 @@ h3 {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 14px; border-radius: 14px;
background: background:
linear-gradient(135deg, oklch(0.2 0.017 250 / 0.92), oklch(0.16 0.012 250 / 0.96)), linear-gradient(
135deg,
oklch(0.2 0.017 250 / 0.92),
oklch(0.16 0.012 250 / 0.96)
),
var(--bg-pane-2); var(--bg-pane-2);
} }
@ -1076,6 +1103,11 @@ h3 {
background: oklch(0.13 0.01 250 / 0.64); background: oklch(0.13 0.01 250 / 0.64);
} }
.copilot-callout-warning {
border-color: oklch(0.74 0.08 68 / 0.42);
background: oklch(0.2 0.03 68 / 0.28);
}
.copilot-stat span, .copilot-stat span,
.copilot-token-row span, .copilot-token-row span,
.copilot-limit-window span { .copilot-limit-window span {
@ -1516,19 +1548,34 @@ h3 {
.data-table-row-classified { .data-table-row-classified {
background: background:
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.012 + var(--classifier-intensity, 0) * 0.06)), transparent 62%), linear-gradient(
90deg,
rgba(
var(--classifier-rgb, 192, 200, 210),
calc(0.012 + var(--classifier-intensity, 0) * 0.06)
),
transparent 62%
),
oklch(0.98 0.008 250 / 0.008); oklch(0.98 0.008 250 / 0.008);
} }
.data-table-row-classified:hover, .data-table-row-classified:hover,
.data-table-row-classified:focus-visible { .data-table-row-classified:focus-visible {
background: background:
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.02 + var(--classifier-intensity, 0) * 0.1)), transparent 68%), linear-gradient(
90deg,
rgba(
var(--classifier-rgb, 192, 200, 210),
calc(0.02 + var(--classifier-intensity, 0) * 0.1)
),
transparent 68%
),
oklch(0.78 0.12 74 / 0.035); oklch(0.78 0.12 74 / 0.035);
} }
.data-table-row-classified.is-classified { .data-table-row-classified.is-classified {
box-shadow: inset 0 0 0 1px rgba(var(--classifier-rgb), calc(0.16 + var(--classifier-intensity) * 0.12)); box-shadow: inset 0 0 0 1px
rgba(var(--classifier-rgb), calc(0.16 + var(--classifier-intensity) * 0.12));
} }
.data-table-row-warn, .data-table-row-warn,
@ -1549,32 +1596,62 @@ h3 {
.data-table-options .data-table-head, .data-table-options .data-table-head,
.data-table-options .data-table-row { .data-table-options .data-table-row {
grid-template-columns: minmax(72px, 0.8fr) minmax(50px, 0.55fr) minmax(64px, 0.7fr) minmax(58px, 0.6fr) minmax(34px, 0.35fr) minmax(62px, 0.65fr) minmax(104px, 1fr) minmax(54px, 0.55fr) minmax(66px, 0.7fr) minmax(48px, 0.5fr) minmax(42px, 0.45fr) minmax(92px, 0.9fr); grid-template-columns: minmax(72px, 0.8fr) minmax(50px, 0.55fr) minmax(
64px,
0.7fr
) minmax(58px, 0.6fr) minmax(34px, 0.35fr) minmax(62px, 0.65fr) minmax(
104px,
1fr
) minmax(54px, 0.55fr) minmax(66px, 0.7fr) minmax(48px, 0.5fr) minmax(
42px,
0.45fr
) minmax(92px, 0.9fr);
} }
.data-table-equities .data-table-head, .data-table-equities .data-table-head,
.data-table-equities .data-table-row { .data-table-equities .data-table-row {
grid-template-columns: minmax(76px, 0.9fr) minmax(70px, 0.8fr) minmax(76px, 0.8fr) minmax(70px, 0.75fr) minmax(80px, 0.8fr) minmax(76px, 0.75fr); grid-template-columns: minmax(76px, 0.9fr) minmax(70px, 0.8fr) minmax(
76px,
0.8fr
) minmax(70px, 0.75fr) minmax(80px, 0.8fr) minmax(76px, 0.75fr);
} }
.data-table-flow .data-table-head, .data-table-flow .data-table-head,
.data-table-flow .data-table-row { .data-table-flow .data-table-row {
grid-template-columns: minmax(148px, 1.1fr) minmax(180px, 1.4fr) minmax(62px, 0.45fr) minmax(70px, 0.5fr) minmax(88px, 0.7fr) minmax(74px, 0.55fr) minmax(132px, 1fr) minmax(110px, 0.8fr) minmax(210px, 1.6fr); grid-template-columns: minmax(148px, 1.1fr) minmax(180px, 1.4fr) minmax(
62px,
0.45fr
) minmax(70px, 0.5fr) minmax(88px, 0.7fr) minmax(74px, 0.55fr) minmax(
132px,
1fr
) minmax(110px, 0.8fr) minmax(210px, 1.6fr);
} }
.data-table-alerts .data-table-head, .data-table-alerts .data-table-head,
.data-table-alerts .data-table-row { .data-table-alerts .data-table-row {
grid-template-columns: minmax(76px, 0.75fr) minmax(170px, 1.4fr) minmax(52px, 0.45fr) minmax(58px, 0.45fr) minmax(52px, 0.4fr) minmax(66px, 0.55fr) minmax(260px, 2fr); grid-template-columns: minmax(76px, 0.75fr) minmax(170px, 1.4fr) minmax(
52px,
0.45fr
) minmax(58px, 0.45fr) minmax(52px, 0.4fr) minmax(66px, 0.55fr) minmax(
260px,
2fr
);
} }
.data-table-classifier .data-table-head, .data-table-classifier .data-table-head,
.data-table-classifier .data-table-row { .data-table-classifier .data-table-row {
grid-template-columns: minmax(76px, 0.75fr) minmax(180px, 1.45fr) minmax(70px, 0.6fr) minmax(74px, 0.65fr) minmax(300px, 2.2fr); grid-template-columns: minmax(76px, 0.75fr) minmax(180px, 1.45fr) minmax(
70px,
0.6fr
) minmax(74px, 0.65fr) minmax(300px, 2.2fr);
} }
.data-table-dark .data-table-head, .data-table-dark .data-table-head,
.data-table-dark .data-table-row { .data-table-dark .data-table-row {
grid-template-columns: minmax(76px, 0.75fr) minmax(170px, 1.35fr) minmax(76px, 0.65fr) minmax(74px, 0.65fr) minmax(74px, 0.65fr) minmax(260px, 2fr); grid-template-columns: minmax(76px, 0.75fr) minmax(170px, 1.35fr) minmax(
76px,
0.65fr
) minmax(74px, 0.65fr) minmax(74px, 0.65fr) minmax(260px, 2fr);
} }
.data-table-cell { .data-table-cell {
@ -1606,7 +1683,16 @@ h3 {
.options-table-head, .options-table-head,
.options-table-row { .options-table-row {
display: grid; display: grid;
grid-template-columns: minmax(72px, 0.8fr) minmax(50px, 0.55fr) minmax(64px, 0.7fr) minmax(58px, 0.6fr) minmax(34px, 0.35fr) minmax(62px, 0.65fr) minmax(104px, 1fr) minmax(54px, 0.55fr) minmax(66px, 0.7fr) minmax(48px, 0.5fr) minmax(42px, 0.45fr) minmax(92px, 0.9fr); grid-template-columns: minmax(72px, 0.8fr) minmax(50px, 0.55fr) minmax(
64px,
0.7fr
) minmax(58px, 0.6fr) minmax(34px, 0.35fr) minmax(62px, 0.65fr) minmax(
104px,
1fr
) minmax(54px, 0.55fr) minmax(66px, 0.7fr) minmax(48px, 0.5fr) minmax(
42px,
0.45fr
) minmax(92px, 0.9fr);
align-items: center; align-items: center;
column-gap: 8px; column-gap: 8px;
} }
@ -1637,7 +1723,14 @@ h3 {
border: 0; border: 0;
border-bottom: 1px solid oklch(0.72 0.012 250 / 0.08); border-bottom: 1px solid oklch(0.72 0.012 250 / 0.08);
background: background:
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.012 + var(--classifier-intensity, 0) * 0.06)), transparent 62%), linear-gradient(
90deg,
rgba(
var(--classifier-rgb, 192, 200, 210),
calc(0.012 + var(--classifier-intensity, 0) * 0.06)
),
transparent 62%
),
oklch(0.98 0.008 250 / 0.012); oklch(0.98 0.008 250 / 0.012);
color: inherit; color: inherit;
font: inherit; font: inherit;
@ -1648,13 +1741,21 @@ h3 {
.options-table-row:focus-visible { .options-table-row:focus-visible {
outline: none; outline: none;
background: background:
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.02 + var(--classifier-intensity, 0) * 0.1)), transparent 68%), linear-gradient(
90deg,
rgba(
var(--classifier-rgb, 192, 200, 210),
calc(0.02 + var(--classifier-intensity, 0) * 0.1)
),
transparent 68%
),
oklch(0.78 0.12 74 / 0.03); oklch(0.78 0.12 74 / 0.03);
} }
.options-table-row.is-classified { .options-table-row.is-classified {
cursor: pointer; cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(var(--classifier-rgb), calc(0.16 + var(--classifier-intensity) * 0.12)); box-shadow: inset 0 0 0 1px
rgba(var(--classifier-rgb), calc(0.16 + var(--classifier-intensity) * 0.12));
} }
.options-table-row > span { .options-table-row > span {
@ -1669,17 +1770,39 @@ h3 {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.classifier-green { --classifier-rgb: 37, 193, 122; } .classifier-green {
.classifier-red { --classifier-rgb: 255, 107, 95; } --classifier-rgb: 37, 193, 122;
.classifier-amber { --classifier-rgb: 245, 166, 35; } }
.classifier-copper { --classifier-rgb: 198, 122, 75; } .classifier-red {
.classifier-blue { --classifier-rgb: 77, 163, 255; } --classifier-rgb: 255, 107, 95;
.classifier-teal { --classifier-rgb: 64, 210, 190; } }
.classifier-yellowgreen { --classifier-rgb: 174, 210, 78; } .classifier-amber {
.classifier-violet { --classifier-rgb: 170, 130, 255; } --classifier-rgb: 245, 166, 35;
.classifier-cyan { --classifier-rgb: 94, 214, 255; } }
.classifier-magenta { --classifier-rgb: 255, 92, 205; } .classifier-copper {
.classifier-neutral { --classifier-rgb: 192, 200, 210; } --classifier-rgb: 198, 122, 75;
}
.classifier-blue {
--classifier-rgb: 77, 163, 255;
}
.classifier-teal {
--classifier-rgb: 64, 210, 190;
}
.classifier-yellowgreen {
--classifier-rgb: 174, 210, 78;
}
.classifier-violet {
--classifier-rgb: 170, 130, 255;
}
.classifier-cyan {
--classifier-rgb: 94, 214, 255;
}
.classifier-magenta {
--classifier-rgb: 255, 92, 205;
}
.classifier-neutral {
--classifier-rgb: 192, 200, 210;
}
.contract, .contract,
.drawer-row-title { .drawer-row-title {
@ -1829,7 +1952,9 @@ h3 {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transform: translateY(8px); transform: translateY(8px);
transition: opacity 0.15s ease, transform 0.15s ease; transition:
opacity 0.15s ease,
transform 0.15s ease;
z-index: 5; z-index: 5;
} }
@ -1955,7 +2080,10 @@ h3 {
color: var(--text-dim); color: var(--text-dim);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28);
z-index: 45; z-index: 45;
transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease; transition:
border-color 0.16s ease,
background-color 0.16s ease,
color 0.16s ease;
} }
.synthetic-control-gear:hover, .synthetic-control-gear:hover,
@ -2121,7 +2249,9 @@ h3 {
background: oklch(0.18 0.012 250 / 0.6); background: oklch(0.18 0.012 250 / 0.6);
color: var(--text); color: var(--text);
text-align: left; text-align: left;
transition: border-color 150ms ease, background 150ms ease; transition:
border-color 150ms ease,
background 150ms ease;
} }
.news-row:hover { .news-row:hover {
@ -2245,7 +2375,12 @@ h3 {
width: 64%; width: 64%;
height: 12px; height: 12px;
border-radius: 999px; border-radius: 999px;
background: linear-gradient(90deg, var(--bg-soft), rgba(245, 166, 35, 0.14), var(--bg-soft)); background: linear-gradient(
90deg,
var(--bg-soft),
rgba(245, 166, 35, 0.14),
var(--bg-soft)
);
background-size: 180% 100%; background-size: 180% 100%;
animation: drawer-skeleton 1.2s ease-out infinite; animation: drawer-skeleton 1.2s ease-out infinite;
} }
@ -2438,7 +2573,11 @@ h3 {
@media (max-width: 720px) { @media (max-width: 720px) {
.terminal-shell { .terminal-shell {
background-size: 24px 24px, 24px 24px, 100% 100%, auto; background-size:
24px 24px,
24px 24px,
100% 100%,
auto;
} }
.terminal-rail { .terminal-rail {

View file

@ -0,0 +1,612 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>
2026-05-20 18:59 EDT · Clarify Desktop AI Settings Bridge State
</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);
--warn: oklch(0.74 0.08 68);
--warn-soft: oklch(0.2 0.03 68 / 0.28);
--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,
p,
ul {
margin: 0;
}
p,
li {
color: var(--muted);
line-height: 1.7;
}
a {
color: inherit;
}
.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-bottom: 10px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.76rem;
}
.hero h1 {
font-size: clamp(2rem, 3.2vw, 3.3rem);
line-height: 0.96;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.hero-copy {
max-width: 72ch;
margin-top: 14px;
}
.hero-grid,
.validation-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.stat,
.validation-card {
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: 16px;
background: oklch(0.12 0.01 250 / 0.5);
}
.stat span,
.meta-label {
display: block;
margin-bottom: 8px;
color: var(--faint);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.68rem;
}
.stat strong {
display: block;
color: var(--text);
font-size: 1rem;
}
.section-grid {
display: grid;
gap: 18px;
margin-top: 22px;
}
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 {
padding-left: 20px;
}
li + li,
p + p,
p + ul,
ul + p,
.callout + .callout,
.diff-title + pre,
pre + .diff-title {
margin-top: 10px;
}
.two-col {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 18px;
}
.callout {
padding: 16px 18px;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--blue-soft);
}
.callout.warn {
border-color: oklch(0.74 0.08 68 / 0.42);
background: var(--warn-soft);
}
.callout strong {
color: var(--text);
}
.meta {
color: var(--faint);
font-size: 0.82rem;
}
.validation-card.good {
background: var(--green-soft);
}
.validation-card.warn {
background: var(--red-soft);
}
pre.diff {
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 {
color: var(--faint);
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.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;
}
@media (max-width: 860px) {
.hero-grid,
.two-col,
.validation-grid {
grid-template-columns: minmax(0, 1fr);
}
main {
width: min(100vw - 24px, 1160px);
padding-top: 18px;
}
}
</style>
</head>
<body>
<main>
<article class="hero">
<div>
<div class="eyebrow">Turn Document · 2026-05-20 18:59 EDT</div>
<h1>Clarify Desktop AI Settings Bridge State</h1>
<p class="hero-copy">
The <code>/settings</code> desktop AI surface now explains why
ChatGPT login and model controls are unavailable when a desktop
window loses its native bridge, instead of looking like broken
controls.
</p>
</div>
<div class="hero-grid">
<div class="stat">
<span>Issue</span>
<strong>islandflow-dy2</strong>
</div>
<div class="stat">
<span>Primary Surface</span>
<strong><code>apps/web/app/desktop-ai-panels.tsx</code></strong>
</div>
<div class="stat">
<span>User Outcome</span>
<strong>Actionable bridge recovery state</strong>
</div>
</div>
</article>
<div class="section-grid">
<section>
<h2>Summary</h2>
<p>
The settings page previously showed disabled ChatGPT login buttons
and sparse model controls whenever the Electron shell was open but
its native AI bridge was missing. That looked indistinguishable from
broken UI. The fix makes the unavailable state explicit, swaps the
dead login affordance for a recovery action, and gives the model
area state-aware empty copy.
</p>
</section>
<section class="two-col">
<div>
<h2>Changes Made</h2>
<ul>
<li>
Added pure helper functions that centralize settings copy for
desktop-only, bridge-missing, and logged-out model states.
</li>
<li>
Changed the account panel so a missing bridge shows a prominent
warning callout and a <code>Reload window</code> action instead
of inert login buttons.
</li>
<li>
Changed the profile slot badge so a selected managed login
profile reports <code>Bridge unavailable</code> when the current
desktop window cannot use it.
</li>
<li>
Added state-specific labels and empty-state copy for the model
selector and model list so they explain whether the app is
waiting on desktop, bridge recovery, or account login.
</li>
<li>
Added a warning callout style and unit coverage for the new
copy-selection helpers.
</li>
</ul>
</div>
<div>
<h2>Context</h2>
<p>
This work came directly from a user report against
<code>/settings</code>: in a desktop session missing its native
bridge, the login controls did not function and the model dropdown
area appeared blank or broken.
</p>
<div class="callout warn">
<strong>Why this mattered</strong>
<p>
The underlying bridge failure was already being detected. The
real problem was that the UI expressed that failure as disabled
controls without enough explanation, which created the
impression that auth itself was broken.
</p>
</div>
</div>
</section>
<section class="two-col">
<div>
<h2>Important Implementation Details</h2>
<ul>
<li>
The fix stays in the web renderer layer and does not alter
Electron auth or preference persistence behavior.
</li>
<li>
<code>updatePreferences</code> still remains bridge-backed and
login-independent, so the change focuses on clearer messaging
rather than adding new backend gating.
</li>
<li>
The account panel now derives a bridge notice from
<code>shellAvailable</code> and <code>bridgeAvailable</code>,
which keeps the browser-only and missing-bridge states distinct.
</li>
<li>
The model area uses the same availability helper family, so the
disabled select label and the list empty-state copy stay
aligned.
</li>
<li>
The UI now prefers truthful status language over optimistic
language. A selected profile is no longer presented as fully
usable when the active window cannot reach the native bridge.
</li>
</ul>
</div>
<div>
<h2>Expected Impact for End-Users</h2>
<ul>
<li>
Users on a broken bridge session immediately see that the
problem is window connectivity, not a silent ChatGPT login
failure.
</li>
<li>
Users get a local recovery path from the settings page through
the new <code>Reload window</code> action.
</li>
<li>
Model controls no longer appear mysteriously empty. They explain
whether the app is waiting for bridge recovery or for account
login.
</li>
<li>
The managed profile card now reflects actual usability in the
current window, which reduces false confidence.
</li>
</ul>
</div>
</section>
<section>
<h2>Relevant Diff Snippets</h2>
<p class="meta">
These snippets are formatted as unified patches so they can be
consumed by Diffs
<code>parsePatchFiles</code> or <code>PatchDiff</code> flows from
<a href="https://diffs.com/docs">diffs.com/docs</a>.
</p>
<div class="diff-title">
Settings state helpers and bridge recovery action
</div>
<pre
class="diff"
><code>diff --git a/apps/web/app/desktop-ai-panels.tsx b/apps/web/app/desktop-ai-panels.tsx
@@
+export const getDesktopAiSettingsBridgeNotice = (shellAvailable, bridgeAvailable) =&gt; {
+ if (!shellAvailable) {
+ return {
+ title: "Desktop app required",
+ body: "Open Islandflow Desktop to connect ChatGPT, load managed models, and use native Copilot controls."
+ };
+ }
+ if (!bridgeAvailable) {
+ return {
+ title: "Bridge unavailable in this window",
+ body: "This Islandflow Desktop window is missing its native AI bridge, so login actions and model controls stay disabled until the bridge reconnects. Reload the window or restart Islandflow if this keeps happening."
+ };
+ }
+ return null;
+};
@@
- &lt;button className="terminal-button terminal-button-primary"&gt;Browser login&lt;/button&gt;
- &lt;button className="terminal-button"&gt;Device code&lt;/button&gt;
+ &lt;button
+ className="terminal-button terminal-button-primary"
+ type="button"
+ onClick={() =&gt; window.location.reload()}
+ &gt;
+ Reload window
+ &lt;/button&gt;
@@
+ {bridgeNotice ? (
+ &lt;div className="copilot-callout copilot-callout-warning"&gt;
+ &lt;strong&gt;{bridgeNotice.title}&lt;/strong&gt;
+ &lt;p className="copilot-note"&gt;{bridgeNotice.body}&lt;/p&gt;
+ &lt;/div&gt;
+ ) : null}</code></pre>
<div class="diff-title">Model controls copy and empty states</div>
<pre
class="diff"
><code>diff --git a/apps/web/app/desktop-ai-panels.tsx b/apps/web/app/desktop-ai-panels.tsx
@@
+const modelSelectLabel = getDesktopAiModelSelectLabel(
+ shellAvailable,
+ bridgeAvailable,
+ state.account.loggedIn,
+ state.models.length
+);
+const modelListEmptyCopy = getDesktopAiModelListEmptyCopy(
+ shellAvailable,
+ bridgeAvailable,
+ state.account.loggedIn
+);
@@
- &lt;option value=""&gt;Use server default&lt;/option&gt;
+ &lt;option value=""&gt;{modelSelectLabel}&lt;/option&gt;
@@
- {state.models.map((model) =&gt; (
- &lt;div className="copilot-model-row" key={model.id}&gt;...&lt;/div&gt;
- ))}
+ {state.models.length === 0 ? (
+ &lt;p className="copilot-empty"&gt;{modelListEmptyCopy}&lt;/p&gt;
+ ) : (
+ state.models.map((model) =&gt; (
+ &lt;div className="copilot-model-row" key={model.id}&gt;...&lt;/div&gt;
+ ))
+ )}</code></pre>
<div class="diff-title">Unit coverage and warning presentation</div>
<pre
class="diff"
><code>diff --git a/apps/web/app/desktop-ai.test.ts b/apps/web/app/desktop-ai.test.ts
@@
+describe("desktop ai settings copy", () =&gt; {
+ it("explains when the native bridge is missing from the desktop window", () =&gt; {
+ expect(getDesktopAiSettingsBridgeNotice(true, false)?.title).toBe(
+ "Bridge unavailable in this window"
+ );
+ });
+
+ it("keeps the model selector explicit while the bridge is disconnected", () =&gt; {
+ expect(getDesktopAiModelSelectLabel(true, false, false, 0)).toBe("Bridge unavailable");
+ expect(getDesktopAiModelListEmptyCopy(true, false, false)).toContain(
+ "native AI bridge reconnects"
+ );
+ });
+});
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
@@
+.copilot-callout-warning {
+ border-color: oklch(0.74 0.08 68 / 0.42);
+ background: oklch(0.2 0.03 68 / 0.28);
+}</code></pre>
</section>
<section>
<h2>Validation</h2>
<div class="validation-grid">
<div class="validation-card good">
<span class="meta-label">Automated</span>
<strong
><code>bun test apps/web/app/desktop-ai.test.ts</code></strong
>
<p>
Passed with 14 assertions groups green, including new coverage
for bridge-state copy helpers.
</p>
</div>
<div class="validation-card good">
<span class="meta-label">Manual</span>
<strong>Electron app state inspection</strong>
<p>
Verified the live <code>127.0.0.1:3000/settings</code> window
now shows <code>Reload window</code>,
<code>Bridge unavailable</code>, and the new model-controls
explanatory copy.
</p>
</div>
<div class="validation-card warn">
<span class="meta-label">Build</span>
<strong><code>bun --cwd=apps/web run build</code></strong>
<p>
Still fails on an existing repo-wide TypeScript import-extension
problem in <code>packages/types/src/desktop-ai.ts</code>,
unrelated to this change.
</p>
</div>
</div>
</section>
<section class="two-col">
<div>
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li>
The fix does not repair the missing native bridge itself. It
makes that failure mode explicit and recoverable from the page.
</li>
<li>
<code>Reload window</code> is a best-effort recovery action. If
the bridge is absent because of a deeper shell startup issue,
the user may still need to restart Islandflow.
</li>
<li>
The production web build could not serve as a final validation
gate because of the pre-existing <code>.ts</code>-extension
import issue already tracked elsewhere in Beads.
</li>
</ul>
</div>
<div>
<h2>Follow-up Work</h2>
<ul>
<li>
No new follow-up issue was required for this UX patch. The
reported confusion is addressed in <code>islandflow-dy2</code>.
</li>
<li>
The repo-wide Next.js build blocker remains separate work,
already represented by <code>islandflow-c8f</code>.
</li>
<li>
If bridge loss keeps happening in practice, the next useful step
would be adding an in-app bridge diagnostics surface instead of
relying on copy and window reload alone.
</li>
</ul>
</div>
</section>
<section>
<h2>Changes at a Glance</h2>
<div class="chip-row">
<span class="chip">settings UX hardening</span>
<span class="chip">desktop bridge state</span>
<span class="chip">managed auth recovery</span>
<span class="chip">explicit model empty states</span>
<span class="chip">unit test coverage</span>
</div>
</section>
</div>
</main>
</body>
</html>