add desktop codex login and analyst copilot

This commit is contained in:
dirtydishes 2026-05-20 10:41:13 -04:00
parent fb25b5ac97
commit a8d183f38e
24 changed files with 4127 additions and 97 deletions

View file

@ -12,6 +12,9 @@
"package": "bun run build && electron-forge package",
"make": "bun run build && electron-forge make"
},
"dependencies": {
"@islandflow/types": "workspace:*"
},
"devDependencies": {
"@electron-forge/cli": "^7.8.1",
"@electron-forge/core": "^7.11.1",

View file

@ -0,0 +1,8 @@
export const DESKTOP_AI_STATE_CHANNEL = "islandflow:desktop-ai:state";
export const DESKTOP_AI_GET_STATE = "islandflow:desktop-ai:get-state";
export const DESKTOP_AI_LOGIN_BROWSER = "islandflow:desktop-ai:login-browser";
export const DESKTOP_AI_LOGIN_DEVICE = "islandflow:desktop-ai:login-device";
export const DESKTOP_AI_CANCEL_LOGIN = "islandflow:desktop-ai:cancel-login";
export const DESKTOP_AI_LOGOUT = "islandflow:desktop-ai:logout";
export const DESKTOP_AI_UPDATE_PREFERENCES = "islandflow:desktop-ai:update-preferences";
export const DESKTOP_AI_RUN_TASK = "islandflow:desktop-ai:run-task";

View file

@ -0,0 +1,174 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createAppServerChildEnv, IslandflowDesktopAiService, summarizeRateLimit } from "./desktop-ai.js";
const tempDirs: string[] = [];
const makeTempDir = async (): Promise<string> => {
const dir = await mkdtemp(path.join(tmpdir(), "islandflow-desktop-ai-"));
tempDirs.push(dir);
return dir;
};
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
);
});
describe("desktop ai auth environment", () => {
it("scrubs global OpenAI keys for managed ChatGPT sessions", () => {
const env = createAppServerChildEnv("managed-chatgpt", {
OPENAI_API_KEY: "openai-test",
CODEX_API_KEY: "codex-test",
HOME: "/tmp/home"
});
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.CODEX_API_KEY).toBeUndefined();
expect(env.HOME).toBe("/tmp/home");
});
it("preserves keys for api-key mode", () => {
const env = createAppServerChildEnv("api-key", {
OPENAI_API_KEY: "openai-test",
CODEX_API_KEY: "codex-test"
});
expect(env.OPENAI_API_KEY).toBe("openai-test");
expect(env.CODEX_API_KEY).toBe("codex-test");
});
});
describe("desktop ai usage and state tracking", () => {
it("records exact token usage notifications into usage rollups", async () => {
const dir = await makeTempDir();
const service = new IslandflowDesktopAiService(dir, async () => {}, () => {});
const internal = service as any;
internal.state.account.email = "analyst@example.com";
internal.state.account.planType = "plus";
internal.state.preferences.model = "gpt-5.4";
internal.state.tasks = [
{
taskId: "task-1",
kind: "smart-money-explain",
title: "Explain smart money event",
subtitle: "AAPL",
status: "running",
createdAt: Date.now(),
updatedAt: Date.now(),
threadId: "thread-1",
turnId: "turn-1",
model: "gpt-5.4",
reasoningEffort: "high",
text: "",
error: null,
compiledScreen: null
}
];
internal.activeTasksByThreadId.set("thread-1", {
taskId: "task-1",
taskKind: "smart-money-explain",
taskTitle: "Explain smart money event",
profileId: "managed-chatgpt"
});
await internal.handleNotification("thread/tokenUsage/updated", {
threadId: "thread-1",
turnId: "turn-1",
tokenUsage: {
total: {
totalTokens: 1800,
inputTokens: 1000,
cachedInputTokens: 500,
outputTokens: 250,
reasoningOutputTokens: 50
},
last: {
totalTokens: 1800,
inputTokens: 1000,
cachedInputTokens: 500,
outputTokens: 250,
reasoningOutputTokens: 50
}
}
});
expect(service.getState().usage.today.breakdown).toEqual({
totalTokens: 1800,
inputTokens: 1000,
cachedInputTokens: 500,
outputTokens: 250,
reasoningOutputTokens: 50
});
expect(service.getState().usage.today.turnCount).toBe(1);
expect(service.getState().usage.recentTurns[0]?.normalizedCostUsd).toBeCloseTo(0.007125, 6);
});
it("stores rate-limit snapshots with reset times", async () => {
const dir = await makeTempDir();
const service = new IslandflowDesktopAiService(dir, async () => {}, () => {});
const internal = service as any;
await internal.handleNotification("account/rateLimits/updated", {
rateLimits: {
limitId: "chatgpt_plus",
limitName: "ChatGPT Plus",
primary: {
usedPercent: 38.4,
windowDurationMins: 180,
resetsAt: 1_710_000_000_000
},
secondary: {
usedPercent: 12.1,
windowDurationMins: 1440,
resetsAt: 1_710_003_600_000
},
planType: "plus"
}
});
expect(service.getState().rateLimitsByLimitId.chatgpt_plus).toEqual(
summarizeRateLimit({
limitId: "chatgpt_plus",
limitName: "ChatGPT Plus",
primary: {
usedPercent: 38.4,
windowDurationMins: 180,
resetsAt: 1_710_000_000_000
},
secondary: {
usedPercent: 12.1,
windowDurationMins: 1440,
resetsAt: 1_710_003_600_000
},
planType: "plus"
})
);
});
it("clears local account state on logout", async () => {
const dir = await makeTempDir();
const service = new IslandflowDesktopAiService(dir, async () => {}, () => {});
const internal = service as any;
internal.client = {
request: async () => ({})
};
internal.state.account.loggedIn = true;
internal.state.account.email = "analyst@example.com";
internal.state.account.planType = "plus";
internal.state.account.login = { status: "browser_pending", message: "Waiting", loginId: "login-1", authUrl: "https://example.com" };
await service.logout();
expect(service.getState().account.loggedIn).toBe(false);
expect(service.getState().account.email).toBeNull();
expect(service.getState().account.planType).toBeNull();
expect(service.getState().account.login).toEqual({ status: "idle", message: "Logged out." });
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import { app, BrowserWindow, shell } from "electron";
import type { Event as ElectronEvent } from "electron";
import { app, BrowserWindow, ipcMain, shell } from "electron";
import type { Event as ElectronEvent, IpcMainInvokeEvent } from "electron";
import { fileURLToPath } from "node:url";
import {
DESKTOP_PRODUCTION_URL,
@ -7,11 +8,25 @@ import {
isTrustedAppUrl,
resolveDesktopStartUrl
} from "./security.js";
import { IslandflowDesktopAiService } from "./desktop-ai.js";
import {
DESKTOP_AI_CANCEL_LOGIN,
DESKTOP_AI_GET_STATE,
DESKTOP_AI_LOGIN_BROWSER,
DESKTOP_AI_LOGIN_DEVICE,
DESKTOP_AI_LOGOUT,
DESKTOP_AI_RUN_TASK,
DESKTOP_AI_STATE_CHANNEL,
DESKTOP_AI_UPDATE_PREFERENCES
} from "./desktop-ai-ipc.js";
const WINDOW_BACKGROUND_COLOR = "#06080b";
const WINDOW_TITLE = "Islandflow";
let mainWindow: BrowserWindow | null = null;
let desktopAiService: IslandflowDesktopAiService | null = null;
const PRELOAD_PATH = fileURLToPath(new URL("./preload.js", import.meta.url));
const canOpenExternalUrl = (sourceUrl: string, targetUrl: string): boolean => {
return isTrustedAppUrl(sourceUrl) && isSafeExternalUrl(targetUrl);
@ -61,6 +76,7 @@ const createMainWindow = (): BrowserWindow => {
title: WINDOW_TITLE,
backgroundColor: WINDOW_BACKGROUND_COLOR,
webPreferences: {
preload: PRELOAD_PATH,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
@ -92,6 +108,68 @@ const createMainWindow = (): BrowserWindow => {
return window;
};
const broadcastDesktopAiState = (): void => {
if (!desktopAiService) {
return;
}
const state = desktopAiService.getState();
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(DESKTOP_AI_STATE_CHANNEL, state);
}
};
const getTrustedSenderUrl = (event: IpcMainInvokeEvent): string => {
const senderUrl = event.senderFrame?.url || event.sender.getURL();
if (!isTrustedAppUrl(senderUrl)) {
throw new Error(`Rejected desktop AI IPC from untrusted origin: ${senderUrl || "unknown"}`);
}
return senderUrl;
};
const registerDesktopAiIpc = (service: IslandflowDesktopAiService): void => {
const guard = (event: IpcMainInvokeEvent): void => {
getTrustedSenderUrl(event);
};
ipcMain.handle(DESKTOP_AI_GET_STATE, async (event) => {
guard(event);
await service.start();
return service.getState();
});
ipcMain.handle(DESKTOP_AI_LOGIN_BROWSER, async (event) => {
guard(event);
await service.loginWithBrowser();
});
ipcMain.handle(DESKTOP_AI_LOGIN_DEVICE, async (event) => {
guard(event);
await service.loginWithDeviceCode();
});
ipcMain.handle(DESKTOP_AI_CANCEL_LOGIN, async (event) => {
guard(event);
await service.cancelLogin();
});
ipcMain.handle(DESKTOP_AI_LOGOUT, async (event) => {
guard(event);
await service.logout();
});
ipcMain.handle(DESKTOP_AI_UPDATE_PREFERENCES, async (event, next) => {
guard(event);
await service.updatePreferences(next);
});
ipcMain.handle(DESKTOP_AI_RUN_TASK, async (event, request) => {
guard(event);
return service.runTask(request);
});
};
const ensureMainWindow = (): void => {
if (mainWindow) {
return;
@ -101,6 +179,20 @@ const ensureMainWindow = (): void => {
};
app.whenReady().then(() => {
desktopAiService = new IslandflowDesktopAiService(
app.getPath("userData"),
async (url) => {
await shell.openExternal(url);
},
() => {
broadcastDesktopAiState();
}
);
registerDesktopAiIpc(desktopAiService);
void desktopAiService.start().catch((error) => {
console.error("[desktop-ai] Failed to start Codex bridge:", error);
broadcastDesktopAiState();
});
ensureMainWindow();
app.on("activate", () => {

View file

@ -0,0 +1,43 @@
import { contextBridge, ipcRenderer } from "electron";
import type {
IslandflowAiReasoningEffort,
IslandflowAiState,
IslandflowAiTaskRequest
} from "@islandflow/types";
import {
DESKTOP_AI_CANCEL_LOGIN,
DESKTOP_AI_GET_STATE,
DESKTOP_AI_LOGIN_BROWSER,
DESKTOP_AI_LOGIN_DEVICE,
DESKTOP_AI_LOGOUT,
DESKTOP_AI_RUN_TASK,
DESKTOP_AI_STATE_CHANNEL,
DESKTOP_AI_UPDATE_PREFERENCES
} from "./desktop-ai-ipc.js";
const bridge = {
ai: {
getState: (): Promise<IslandflowAiState> => ipcRenderer.invoke(DESKTOP_AI_GET_STATE),
loginWithBrowser: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGIN_BROWSER),
loginWithDeviceCode: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGIN_DEVICE),
cancelLogin: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_CANCEL_LOGIN),
logout: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGOUT),
updatePreferences: (
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_UPDATE_PREFERENCES, next),
runTask: (request: IslandflowAiTaskRequest): Promise<{ taskId: string }> =>
ipcRenderer.invoke(DESKTOP_AI_RUN_TASK, request),
subscribe: (listener: (state: IslandflowAiState) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, state: IslandflowAiState) => {
listener(state);
};
ipcRenderer.on(DESKTOP_AI_STATE_CHANNEL, handler);
return () => {
ipcRenderer.off(DESKTOP_AI_STATE_CHANNEL, handler);
};
}
}
};
contextBridge.exposeInMainWorld("islandflowDesktop", bridge);

View file

@ -2,8 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["node"],
"rootDir": "src",

View file

@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { ChartsRoute } from "../terminal";
export const dynamic = "force-dynamic";
export default function Page() {
redirect("/");
return <ChartsRoute />;
}

View file

@ -0,0 +1,924 @@
"use client";
import Link from "next/link";
import { useMemo, useState, type ReactNode } from "react";
import type {
AlertEvent,
ClassifierHitEvent,
FlowPacket,
IslandflowAiCompiledScreen,
IslandflowAiPlanType,
IslandflowAiRateLimitSnapshot,
IslandflowAiReasoningEffort,
IslandflowAiTaskKind,
OptionFlowFilters,
OptionPrint,
SmartMoneyEvent
} from "@islandflow/types";
import { useDesktopAi } from "./desktop-ai";
const numberFormatter = new Intl.NumberFormat("en-US");
const usdFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 4
});
const humanizeValue = (value: string | null | undefined): string => {
if (!value) {
return "Unknown";
}
return value
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
};
const formatTokens = (value: number): string => numberFormatter.format(value);
const formatUsd = (value: number | null): string => (value === null ? "Unavailable" : usdFormatter.format(value));
const formatTimestamp = (value: number | null): string => {
if (!value) {
return "Not reported";
}
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short"
}).format(value);
};
const formatPercent = (value: number): string => `${Math.round(value)}%`;
const getTaskStatusLabel = (value: string): string => humanizeValue(value);
const findTask = <T extends { taskId: string }>(tasks: T[], taskId: string | null): T | null => {
if (!taskId) {
return null;
}
return tasks.find((task) => task.taskId === taskId) ?? null;
};
const getCompiledScreenSummary = (compiled: IslandflowAiCompiledScreen): string[] => {
const filters = compiled.compiledFilters;
if (!filters) {
return [];
}
const parts: string[] = [];
if (filters.view) {
parts.push(`View: ${filters.view}`);
}
if (filters.securityTypes?.length) {
parts.push(`Security: ${filters.securityTypes.join(", ")}`);
}
if (filters.optionTypes?.length) {
parts.push(`Options: ${filters.optionTypes.join(", ")}`);
}
if (filters.nbboSides?.length) {
parts.push(`NBBO: ${filters.nbboSides.join(", ")}`);
}
if (typeof filters.minNotional === "number") {
parts.push(`Min notional: $${numberFormatter.format(filters.minNotional)}`);
}
return parts;
};
const CopilotPane = ({
title,
eyebrow,
actions,
wide = false,
children
}: {
title: string;
eyebrow?: string;
actions?: ReactNode;
wide?: boolean;
children: ReactNode;
}) => {
return (
<section className={`terminal-pane copilot-pane${wide ? " copilot-pane-wide" : ""}`}>
<div className="terminal-pane-head">
<div className="terminal-pane-title-row">
<div>
{eyebrow ? <div className="copilot-kicker">{eyebrow}</div> : null}
<h2 className="terminal-pane-title">{title}</h2>
</div>
</div>
{actions ? <div className="terminal-pane-actions">{actions}</div> : null}
</div>
<div className="terminal-pane-body copilot-pane-body">{children}</div>
</section>
);
};
const UsageBreakdown = ({
title,
breakdown,
normalizedCostUsd,
turnCount,
activeDays
}: {
title: string;
breakdown: {
totalTokens: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
reasoningOutputTokens: number;
};
normalizedCostUsd: number | null;
turnCount: number;
activeDays: number;
}) => {
return (
<div className="copilot-usage-block">
<div className="copilot-usage-title-row">
<h3>{title}</h3>
<span className="copilot-usage-cost">{formatUsd(normalizedCostUsd)}</span>
</div>
<div className="copilot-token-grid">
<div className="copilot-token-row">
<span>Total tokens</span>
<strong>{formatTokens(breakdown.totalTokens)}</strong>
</div>
<div className="copilot-token-row">
<span>Input</span>
<strong>{formatTokens(breakdown.inputTokens)}</strong>
</div>
<div className="copilot-token-row">
<span>Cached input</span>
<strong>{formatTokens(breakdown.cachedInputTokens)}</strong>
</div>
<div className="copilot-token-row">
<span>Output</span>
<strong>{formatTokens(breakdown.outputTokens)}</strong>
</div>
<div className="copilot-token-row">
<span>Reasoning</span>
<strong>{formatTokens(breakdown.reasoningOutputTokens)}</strong>
</div>
<div className="copilot-token-row">
<span>Turns</span>
<strong>{formatTokens(turnCount)}</strong>
</div>
<div className="copilot-token-row">
<span>Active days</span>
<strong>{formatTokens(activeDays)}</strong>
</div>
</div>
</div>
);
};
const RateLimitBoard = ({ limit }: { limit: IslandflowAiRateLimitSnapshot }) => {
return (
<div className="copilot-limit-card" key={limit.limitId ?? limit.limitName ?? "default"}>
<div className="copilot-limit-head">
<div>
<strong>{limit.limitName ?? "Default rate window"}</strong>
<p className="copilot-note">
{limit.planType ? `Plan ${humanizeValue(limit.planType)}` : "Plan not reported"}
</p>
</div>
{limit.reachedType ? <span className="copilot-badge warning">{humanizeValue(limit.reachedType)}</span> : null}
</div>
<div className="copilot-limit-grid">
{limit.primary ? (
<div className="copilot-limit-window">
<span>Primary</span>
<strong>{formatPercent(limit.primary.usedPercent)}</strong>
<p className="copilot-note">Resets {formatTimestamp(limit.primary.resetsAt)}</p>
</div>
) : null}
{limit.secondary ? (
<div className="copilot-limit-window">
<span>Secondary</span>
<strong>{formatPercent(limit.secondary.usedPercent)}</strong>
<p className="copilot-note">Resets {formatTimestamp(limit.secondary.resetsAt)}</p>
</div>
) : null}
</div>
{limit.creditsBalance || limit.unlimitedCredits !== null ? (
<p className="copilot-note">
Credits:{" "}
{limit.unlimitedCredits
? "unlimited"
: limit.creditsBalance
? limit.creditsBalance
: limit.hasCredits === false
? "none"
: "not reported"}
</p>
) : null}
</div>
);
};
const TaskOutput = ({
taskId,
emptyMessage
}: {
taskId: string | null;
emptyMessage: string;
}) => {
const { state } = useDesktopAi();
const task = findTask(state.tasks, taskId);
if (!task) {
return <p className="copilot-empty">{emptyMessage}</p>;
}
return (
<div className="copilot-task-output" aria-live="polite">
<div className="copilot-task-head">
<div>
<strong>{task.title}</strong>
<p className="copilot-note">
{task.subtitle} · {getTaskStatusLabel(task.status)}
</p>
</div>
<span className={`copilot-badge status-${task.status}`}>{getTaskStatusLabel(task.status)}</span>
</div>
{task.error ? <p className="copilot-error">{task.error}</p> : null}
{task.text ? <pre className="copilot-task-text">{task.text}</pre> : null}
{task.compiledScreen ? <CompiledScreenResult compiled={task.compiledScreen} /> : null}
</div>
);
};
const CompiledScreenResult = ({ compiled }: { compiled: IslandflowAiCompiledScreen }) => {
const summary = getCompiledScreenSummary(compiled);
return (
<div className="copilot-compiled-screen">
{summary.length > 0 ? (
<div className="copilot-chip-row">
{summary.map((item) => (
<span className="copilot-chip" key={item}>
{item}
</span>
))}
</div>
) : (
<p className="copilot-note">No filter fields were compiled from this prompt.</p>
)}
{compiled.unhandledClauses.length > 0 ? (
<div className="copilot-unhandled-list">
<div className="copilot-list-title">Unhandled clauses</div>
{compiled.unhandledClauses.map((item) => (
<div className="copilot-inline-row" key={item}>
<span>{item}</span>
</div>
))}
</div>
) : null}
</div>
);
};
const AccountSummary = ({
loggedIn,
email,
planType
}: {
loggedIn: boolean;
email: string | null;
planType: IslandflowAiPlanType | null;
}) => {
return (
<div className="copilot-hero">
<div>
<p className="copilot-kicker">Desktop-only official Codex bridge</p>
<h1 className="page-title">Analyst Copilot</h1>
<p className="copilot-hero-copy">
Managed ChatGPT login stays user-scoped, deterministic smart-money classification stays in charge, and every
AI turn is tracked with exact token telemetry from the app-server.
</p>
</div>
<div className="copilot-hero-meta">
<div className="copilot-stat">
<span>Account</span>
<strong>{loggedIn ? email ?? "Connected" : "Disconnected"}</strong>
</div>
<div className="copilot-stat">
<span>Plan</span>
<strong>{loggedIn ? humanizeValue(planType) : "Not connected"}</strong>
</div>
</div>
</div>
);
};
const LoginStatePanel = () => {
const { 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 runAction = async (label: string, action: () => Promise<void>) => {
setBusyAction(label);
setActionError(null);
try {
await action();
} catch (error) {
setActionError(error instanceof Error ? error.message : String(error));
} finally {
setBusyAction(null);
}
};
return (
<CopilotPane
title="Account and access"
eyebrow="Managed auth"
wide
actions={
<>
{state.account.loggedIn ? (
<button
className="terminal-button"
type="button"
onClick={() => void runAction("logout", logout)}
disabled={actionsDisabled}
>
{busyAction === "logout" ? "Logging out" : "Logout"}
</button>
) : (
<>
<button
className="terminal-button terminal-button-primary"
type="button"
onClick={() => void runAction("browser", loginWithBrowser)}
disabled={actionsDisabled}
>
{busyAction === "browser" ? "Opening browser" : "Browser login"}
</button>
<button
className="terminal-button"
type="button"
onClick={() => void runAction("device", loginWithDeviceCode)}
disabled={actionsDisabled}
>
{busyAction === "device" ? "Preparing code" : "Device code"}
</button>
</>
)}
{(loginState.status === "browser_pending" || loginState.status === "device_code_pending") && !state.account.loggedIn ? (
<button
className="terminal-button"
type="button"
onClick={() => void runAction("cancel", cancelLogin)}
disabled={actionsDisabled}
>
Cancel
</button>
) : null}
</>
}
>
<AccountSummary
loggedIn={state.account.loggedIn}
email={state.account.email}
planType={state.account.planType}
/>
<div className="copilot-account-grid">
<div className="copilot-account-card">
<div className="copilot-list-title">Profile slots</div>
{state.profiles.map((profile) => (
<div className="copilot-inline-row" key={profile.id}>
<div>
<strong>{profile.label}</strong>
<p className="copilot-note">{profile.description}</p>
</div>
<span className={`copilot-badge${profile.enabled ? "" : " muted"}`}>
{profile.selected ? "Selected" : profile.statusLabel}
</span>
</div>
))}
</div>
<div className="copilot-account-card">
<div className="copilot-list-title">Session status</div>
<div className="copilot-inline-row">
<span>Transport</span>
<strong>{humanizeValue(state.transportStatus)}</strong>
</div>
<div className="copilot-inline-row">
<span>Auth mode</span>
<strong>{humanizeValue(state.account.authMode)}</strong>
</div>
<div className="copilot-inline-row">
<span>OpenAI auth required</span>
<strong>{state.account.requiresOpenaiAuth ? "Yes" : "No"}</strong>
</div>
{state.transportError ? <p className="copilot-error">{state.transportError}</p> : null}
{loginState.message ? <p className="copilot-note">{loginState.message}</p> : null}
{loginState.status === "browser_pending" ? (
<div className="copilot-callout">
<strong>Browser login in progress</strong>
<p className="copilot-note">Finish the ChatGPT sign-in flow in your browser. Islandflow will update automatically.</p>
</div>
) : null}
{loginState.status === "device_code_pending" ? (
<div className="copilot-callout">
<strong>Device code</strong>
<pre className="copilot-device-code">{loginState.userCode}</pre>
<p className="copilot-note">Visit {loginState.verificationUrl} in any browser and enter the code above.</p>
</div>
) : null}
{actionError ? <p className="copilot-error">{actionError}</p> : null}
</div>
</div>
</CopilotPane>
);
};
export function DesktopAiSettingsRoute() {
const { state, updatePreferences } = useDesktopAi();
const [busyPreference, setBusyPreference] = useState<"model" | "reasoning" | null>(null);
const [preferenceError, setPreferenceError] = useState<string | null>(null);
const rateLimits = Object.values(state.rateLimitsByLimitId);
const selectedModel = state.preferences.model ?? "";
const selectedReasoning = state.preferences.reasoningEffort ?? "";
const savePreference = async (
key: "model" | "reasoning",
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
) => {
setBusyPreference(key);
setPreferenceError(null);
try {
await updatePreferences(next);
} catch (error) {
setPreferenceError(error instanceof Error ? error.message : String(error));
} finally {
setBusyPreference(null);
}
};
return (
<div className="page-shell">
{!state.desktopAvailable ? (
<CopilotPane title="Desktop required" eyebrow="Browser-only fallback" wide>
<div className="copilot-unavailable">
<p>
AI controls are intentionally read-only in the browser build. Open Islandflow Desktop to use managed ChatGPT
login, structured Copilot turns, and app-server token telemetry.
</p>
</div>
</CopilotPane>
) : null}
<LoginStatePanel />
<div className="page-grid page-grid-settings">
<CopilotPane title="Model controls" eyebrow="Execution">
<div className="copilot-field-grid">
<label className="copilot-field">
<span className="copilot-field-label">Model</span>
<select
className="copilot-select"
value={selectedModel}
onChange={(event) =>
void savePreference("model", { model: event.target.value.trim() ? event.target.value : null })
}
disabled={busyPreference !== null || state.models.length === 0 || !state.desktopAvailable}
>
<option value="">Use server default</option>
{state.models.map((model) => (
<option key={model.id} value={model.model}>
{model.displayName}
</option>
))}
</select>
</label>
<label className="copilot-field">
<span className="copilot-field-label">Reasoning</span>
<select
className="copilot-select"
value={selectedReasoning}
onChange={(event) =>
void savePreference("reasoning", {
reasoningEffort: event.target.value.trim()
? (event.target.value as IslandflowAiReasoningEffort)
: null
})
}
disabled={busyPreference !== null || !state.desktopAvailable}
>
<option value="">Use model default</option>
<option value="none">None</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">XHigh</option>
</select>
</label>
</div>
<div className="copilot-model-list">
{state.models.map((model) => (
<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>
{state.models.find((model) => model.model === state.preferences.model)?.pricing ? (
<p className="copilot-note">
Normalized estimates use current API pricing for the selected model, not your literal ChatGPT subscription bill.
</p>
) : null}
{preferenceError ? <p className="copilot-error">{preferenceError}</p> : null}
</CopilotPane>
<CopilotPane title="Rate limits" eyebrow="Live windows">
{rateLimits.length === 0 ? (
<p className="copilot-empty">No rate-limit snapshots have been reported yet.</p>
) : (
<div className="copilot-limit-list">
{rateLimits.map((limit) => (
<RateLimitBoard key={limit.limitId ?? limit.limitName ?? "default"} limit={limit} />
))}
</div>
)}
</CopilotPane>
<CopilotPane title="Usage dashboard" eyebrow="Exact app-server telemetry" wide>
<div className="copilot-usage-grid">
<UsageBreakdown
title="Today"
breakdown={state.usage.today.breakdown}
normalizedCostUsd={state.usage.today.normalizedCostUsd}
turnCount={state.usage.today.turnCount}
activeDays={state.usage.today.activeDays}
/>
<UsageBreakdown
title="Lifetime"
breakdown={state.usage.lifetime.breakdown}
normalizedCostUsd={state.usage.lifetime.normalizedCostUsd}
turnCount={state.usage.lifetime.turnCount}
activeDays={state.usage.lifetime.activeDays}
/>
</div>
</CopilotPane>
<CopilotPane title="Recent turns" eyebrow="Per-thread usage">
{state.usage.recentTurns.length === 0 ? (
<p className="copilot-empty">No tracked turns yet.</p>
) : (
<div className="copilot-turn-list">
{state.usage.recentTurns.map((turn) => (
<div className="copilot-turn-row" key={`${turn.threadId}:${turn.turnId}`}>
<div>
<strong>{turn.taskTitle ?? "Ad hoc turn"}</strong>
<p className="copilot-note">
{turn.model ?? "default"} · {formatTimestamp(turn.updatedAt)}
</p>
</div>
<div className="copilot-turn-metrics">
<span>{formatTokens(turn.breakdown.totalTokens)} tok</span>
<span>{formatUsd(turn.normalizedCostUsd)}</span>
</div>
</div>
))}
</div>
)}
</CopilotPane>
<CopilotPane title="Recent analyses" eyebrow="Task feed">
{state.tasks.length === 0 ? (
<p className="copilot-empty">No Copilot tasks have been run yet.</p>
) : (
<div className="copilot-task-list">
{state.tasks.map((task) => (
<div className="copilot-task-list-row" key={task.taskId}>
<div>
<strong>{task.title}</strong>
<p className="copilot-note">
{task.subtitle} · {humanizeValue(task.model)}
</p>
</div>
<span className={`copilot-badge status-${task.status}`}>{getTaskStatusLabel(task.status)}</span>
</div>
))}
</div>
)}
</CopilotPane>
</div>
</div>
);
}
const requireDesktopActionCopy = (desktopAvailable: boolean, loggedIn: boolean): string => {
if (!desktopAvailable) {
return "This control is desktop-only. Open Islandflow Desktop to run Copilot tasks.";
}
if (!loggedIn) {
return "Connect a ChatGPT or Codex account in Settings before running Copilot analysis.";
}
return "";
};
const SmartMoneyTaskButton = ({
label,
kind,
symbol,
disabled,
busyKind,
onRun
}: {
label: string;
kind: IslandflowAiTaskKind;
symbol: string;
disabled: boolean;
busyKind: IslandflowAiTaskKind | null;
onRun: (kind: IslandflowAiTaskKind) => void;
}) => {
return (
<button
className={`terminal-button${kind === "smart-money-explain" ? " terminal-button-primary" : ""}`}
type="button"
onClick={() => onRun(kind)}
disabled={busyKind !== null || disabled}
title={`${label} for ${symbol}`}
>
{busyKind === kind ? "Running" : label}
</button>
);
};
export function SmartMoneyCopilotPanel({
event,
flowPacket,
evidencePrints,
relatedPackets
}: {
event: SmartMoneyEvent;
flowPacket: FlowPacket | null;
evidencePrints: OptionPrint[];
relatedPackets: FlowPacket[];
}) {
const { bridgeAvailable, 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 actionsDisabled = !bridgeAvailable || !state.account.loggedIn;
const handleRun = async (kind: IslandflowAiTaskKind) => {
setBusyKind(kind);
setTaskError(null);
try {
const result = await runTask({
kind: kind as
| "smart-money-explain"
| "smart-money-skeptic"
| "smart-money-burst-summary"
| "watchlist-synthesis",
context: {
event,
flowPacket,
evidencePrints,
relatedPackets
}
});
setActiveTaskId(result.taskId);
} catch (error) {
setTaskError(error instanceof Error ? error.message : String(error));
} finally {
setBusyKind(null);
}
};
return (
<div className="copilot-inline-panel">
<div className="copilot-inline-head">
<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>
</div>
<Link className="terminal-button" href="/settings">
AI settings
</Link>
</div>
<div className="copilot-action-grid">
<SmartMoneyTaskButton
label="Explain"
kind="smart-money-explain"
symbol={event.underlying_id}
disabled={actionsDisabled}
busyKind={busyKind}
onRun={(kind) => void handleRun(kind)}
/>
<SmartMoneyTaskButton
label="Counter-thesis"
kind="smart-money-skeptic"
symbol={event.underlying_id}
disabled={actionsDisabled}
busyKind={busyKind}
onRun={(kind) => void handleRun(kind)}
/>
<SmartMoneyTaskButton
label="Burst summary"
kind="smart-money-burst-summary"
symbol={event.underlying_id}
disabled={actionsDisabled}
busyKind={busyKind}
onRun={(kind) => void handleRun(kind)}
/>
<SmartMoneyTaskButton
label="Watchlist"
kind="watchlist-synthesis"
symbol={event.underlying_id}
disabled={actionsDisabled}
busyKind={busyKind}
onRun={(kind) => void handleRun(kind)}
/>
</div>
{disabledCopy ? <p className="copilot-note">{disabledCopy}</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." />
</div>
);
}
export function ReplayCopilotPanel({
ticker,
flowFilters,
alerts,
smartMoneyEvents,
classifierHits,
flowPackets,
optionPrints
}: {
ticker: string | null;
flowFilters: OptionFlowFilters;
alerts: AlertEvent[];
smartMoneyEvents: SmartMoneyEvent[];
classifierHits: ClassifierHitEvent[];
flowPackets: FlowPacket[];
optionPrints: OptionPrint[];
}) {
const { bridgeAvailable, 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 actionsDisabled = busy || !bridgeAvailable || !state.account.loggedIn;
const handleRun = async () => {
setBusy(true);
setTaskError(null);
try {
const result = await runTask({
kind: "replay-postmortem",
context: {
ticker,
flowFilters,
alerts,
smartMoneyEvents,
classifierHits,
flowPackets,
optionPrints
}
});
setActiveTaskId(result.taskId);
} catch (error) {
setTaskError(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
};
return (
<CopilotPane
title="Replay postmortem"
eyebrow="Structured recap"
actions={
<>
<Link className="terminal-button" href="/settings">
AI settings
</Link>
<button
className="terminal-button terminal-button-primary"
type="button"
onClick={() => void handleRun()}
disabled={actionsDisabled}
>
{busy ? "Running" : "Generate postmortem"}
</button>
</>
}
>
<p className="copilot-note">
Copilot uses the current replay slice only: ticker scope, flow filters, visible alerts, classifier hits, packets, and option prints.
</p>
{disabledCopy ? <p className="copilot-note">{disabledCopy}</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." />
</CopilotPane>
);
}
export function ScreenCompilerPanel({
currentFilters,
onApplyFilters
}: {
currentFilters: OptionFlowFilters;
onApplyFilters: (next: OptionFlowFilters) => void;
}) {
const { bridgeAvailable, 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 actionsDisabled = busy || !bridgeAvailable || !state.account.loggedIn;
const handleCompile = async () => {
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt) {
setTaskError("Write a screen request first.");
return;
}
setBusy(true);
setTaskError(null);
try {
const result = await runTask({
kind: "screen-compile",
context: {
prompt: trimmedPrompt,
currentFilters
}
});
setActiveTaskId(result.taskId);
} catch (error) {
setTaskError(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
};
const compiledFilters = activeTask?.compiledScreen?.compiledFilters ?? null;
return (
<CopilotPane
title="Natural-language screens"
eyebrow="Tape workflow"
actions={
<>
<Link className="terminal-button" href="/settings">
AI settings
</Link>
<button
className="terminal-button terminal-button-primary"
type="button"
onClick={() => void handleCompile()}
disabled={actionsDisabled}
>
{busy ? "Compiling" : "Compile screen"}
</button>
</>
}
>
<div className="copilot-inline-form">
<label className="copilot-field">
<span className="copilot-field-label">Prompt</span>
<textarea
className="copilot-textarea"
rows={4}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="High-notional single-name call buying near the ask, ignore ETFs, keep it signal-only."
/>
</label>
<div className="copilot-current-filters">
<div className="copilot-list-title">Current filter baseline</div>
<pre className="copilot-json-block">{JSON.stringify(currentFilters, null, 2)}</pre>
</div>
</div>
{disabledCopy ? <p className="copilot-note">{disabledCopy}</p> : null}
{taskError ? <p className="copilot-error">{taskError}</p> : null}
{compiledFilters ? (
<div className="copilot-apply-row">
<button className="terminal-button" type="button" onClick={() => onApplyFilters(compiledFilters)}>
Apply compiled filters
</button>
</div>
) : null}
<TaskOutput taskId={activeTaskId} emptyMessage="Compile a natural-language screen to preview the translated filter set and rationale." />
</CopilotPane>
);
}

179
apps/web/app/desktop-ai.tsx Normal file
View file

@ -0,0 +1,179 @@
"use client";
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode
} from "react";
import type {
IslandflowAiReasoningEffort,
IslandflowAiState,
IslandflowAiTaskRequest
} from "@islandflow/types";
type DesktopAiBridge = {
ai: {
getState: () => Promise<IslandflowAiState>;
loginWithBrowser: () => Promise<void>;
loginWithDeviceCode: () => Promise<void>;
cancelLogin: () => Promise<void>;
logout: () => Promise<void>;
updatePreferences: (
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
) => Promise<void>;
runTask: (request: IslandflowAiTaskRequest) => Promise<{ taskId: string }>;
subscribe: (listener: (state: IslandflowAiState) => void) => () => void;
};
};
declare global {
interface Window {
islandflowDesktop?: DesktopAiBridge;
}
}
type DesktopAiContextValue = {
bridgeAvailable: boolean;
state: IslandflowAiState;
loginWithBrowser: () => Promise<void>;
loginWithDeviceCode: () => Promise<void>;
cancelLogin: () => Promise<void>;
logout: () => Promise<void>;
updatePreferences: (
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
) => Promise<void>;
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
},
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);
const rejectDesktopOnly = async (): Promise<never> => {
throw new Error("Desktop AI is only available inside the Islandflow Electron app.");
};
export function DesktopAiProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<IslandflowAiState>(() => createUnavailableState());
const [bridge, setBridge] = useState<DesktopAiBridge | null>(null);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const nextBridge = window.islandflowDesktop ?? null;
if (!nextBridge?.ai) {
setBridge(null);
setState(createUnavailableState());
return;
}
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 () => {
unsubscribe();
};
}, []);
const value = useMemo<DesktopAiContextValue>(
() => ({
bridgeAvailable: Boolean(bridge?.ai),
state,
loginWithBrowser: bridge?.ai.loginWithBrowser ?? rejectDesktopOnly,
loginWithDeviceCode: bridge?.ai.loginWithDeviceCode ?? rejectDesktopOnly,
cancelLogin: bridge?.ai.cancelLogin ?? rejectDesktopOnly,
logout: bridge?.ai.logout ?? rejectDesktopOnly,
updatePreferences: bridge?.ai.updatePreferences ?? rejectDesktopOnly,
runTask: bridge?.ai.runTask ?? rejectDesktopOnly
}),
[bridge, state]
);
return <DesktopAiContext.Provider value={value}>{children}</DesktopAiContext.Provider>;
}
export const useDesktopAi = (): DesktopAiContextValue => {
const value = useContext(DesktopAiContext);
if (!value) {
throw new Error("Desktop AI context missing");
}
return value;
};

View file

@ -276,6 +276,25 @@ input {
margin-left: auto;
}
.terminal-topbar-summary {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
margin-right: auto;
}
.terminal-topbar-summary strong {
display: block;
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.terminal-topbar-summary .copilot-note {
margin: 4px 0 0;
}
.terminal-filter {
display: flex;
flex-direction: column;
@ -712,6 +731,11 @@ h3 {
grid-template-columns: minmax(0, 1fr);
}
.page-grid-settings {
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.9fr);
align-items: start;
}
.page-grid-home > :nth-child(3),
.page-grid-home > :nth-child(4),
.page-grid-tape > :nth-child(1),
@ -719,6 +743,10 @@ h3 {
grid-column: 1 / -1;
}
.page-grid-settings > .copilot-pane-wide {
grid-column: 1 / -1;
}
.terminal-pane {
min-width: 0;
height: 100%;
@ -979,6 +1007,325 @@ h3 {
height: clamp(430px, 58vh, 760px);
}
.copilot-pane {
background:
radial-gradient(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 {
gap: 18px;
}
.copilot-kicker,
.copilot-field-label,
.copilot-list-title {
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.68rem;
}
.copilot-kicker {
margin-bottom: 8px;
}
.copilot-hero {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.7fr);
gap: 18px;
padding: 18px;
border: 1px solid var(--border);
border-radius: 14px;
background:
linear-gradient(135deg, oklch(0.2 0.017 250 / 0.92), oklch(0.16 0.012 250 / 0.96)),
var(--bg-pane-2);
}
.copilot-hero-copy {
max-width: 62ch;
margin: 10px 0 0;
color: var(--text-dim);
line-height: 1.6;
}
.copilot-hero-meta,
.copilot-account-grid,
.copilot-usage-grid,
.copilot-field-grid,
.copilot-limit-grid,
.copilot-action-grid,
.copilot-inline-form {
display: grid;
gap: 14px;
}
.copilot-hero-meta {
grid-template-columns: minmax(0, 1fr);
}
.copilot-stat,
.copilot-account-card,
.copilot-usage-block,
.copilot-limit-card,
.copilot-current-filters,
.copilot-callout {
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: oklch(0.13 0.01 250 / 0.64);
}
.copilot-stat span,
.copilot-token-row span,
.copilot-limit-window span {
display: block;
margin-bottom: 6px;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.15em;
font-size: 0.65rem;
}
.copilot-stat strong,
.copilot-token-row strong,
.copilot-limit-window strong,
.copilot-device-code,
.copilot-model-meta,
.copilot-turn-metrics {
font-family: var(--font-mono), monospace;
}
.copilot-stat strong {
font-size: 1rem;
line-height: 1.4;
}
.copilot-account-grid,
.copilot-usage-grid,
.copilot-field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.copilot-inline-row,
.copilot-turn-row,
.copilot-task-list-row,
.copilot-model-row,
.copilot-token-row,
.copilot-usage-title-row,
.copilot-limit-head,
.copilot-task-head,
.copilot-inline-head,
.copilot-apply-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.copilot-inline-row,
.copilot-turn-row,
.copilot-task-list-row,
.copilot-model-row {
padding: 10px 0;
border-top: 1px solid oklch(0.72 0.012 250 / 0.12);
}
.copilot-account-card > :first-child,
.copilot-limit-card > :first-child,
.copilot-turn-list > :first-child,
.copilot-task-list > :first-child,
.copilot-model-list > :first-child,
.copilot-unhandled-list > :first-child {
border-top: 0;
}
.copilot-note {
color: var(--text-dim);
line-height: 1.5;
}
.copilot-note,
.copilot-error,
.copilot-empty,
.copilot-device-code,
.copilot-task-text,
.copilot-json-block {
margin: 0;
}
.copilot-error {
color: oklch(0.8 0.11 28);
line-height: 1.5;
}
.copilot-badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: oklch(0.97 0.008 250 / 0.04);
color: var(--text-dim);
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.copilot-badge.muted {
opacity: 0.7;
}
.copilot-badge.warning,
.copilot-badge.status-running {
border-color: oklch(0.78 0.12 74 / 0.38);
background: oklch(0.78 0.12 74 / 0.09);
color: oklch(0.88 0.06 76);
}
.copilot-badge.status-completed {
border-color: oklch(0.74 0.13 151 / 0.34);
background: oklch(0.74 0.13 151 / 0.09);
color: oklch(0.88 0.05 151);
}
.copilot-badge.status-failed,
.copilot-badge.status-cancelled {
border-color: oklch(0.68 0.16 28 / 0.36);
background: oklch(0.68 0.16 28 / 0.1);
color: oklch(0.88 0.05 28);
}
.copilot-field {
display: grid;
gap: 8px;
}
.copilot-select,
.copilot-textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 10px;
background: oklch(0.11 0.009 250 / 0.82);
color: var(--text);
}
.copilot-select {
min-height: 40px;
padding: 0 12px;
}
.copilot-textarea {
padding: 12px;
resize: vertical;
min-height: 112px;
line-height: 1.55;
}
.copilot-model-list,
.copilot-limit-list,
.copilot-turn-list,
.copilot-task-list,
.copilot-unhandled-list {
display: grid;
gap: 0;
}
.copilot-model-meta,
.copilot-turn-metrics {
display: grid;
gap: 4px;
justify-items: end;
color: var(--text-dim);
font-size: 0.76rem;
}
.copilot-token-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 14px;
}
.copilot-token-row,
.copilot-limit-window {
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: oklch(0.97 0.008 250 / 0.03);
}
.copilot-usage-title-row h3,
.copilot-limit-head strong {
margin: 0;
}
.copilot-usage-cost {
font-family: var(--font-mono), monospace;
color: var(--accent);
}
.copilot-inline-panel {
display: grid;
gap: 14px;
}
.copilot-action-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.copilot-task-output,
.copilot-unavailable {
display: grid;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: oklch(0.12 0.01 250 / 0.72);
}
.copilot-task-text,
.copilot-json-block,
.copilot-device-code {
padding: 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: oklch(0.1 0.009 250 / 0.92);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
}
.copilot-device-code {
font-size: clamp(1.3rem, 2vw, 1.7rem);
letter-spacing: 0.18em;
text-align: center;
}
.copilot-chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.copilot-chip {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 10px;
border-radius: 999px;
background: var(--accent-soft);
border: 1px solid var(--border-strong);
font-family: var(--font-mono), monospace;
font-size: 0.72rem;
}
.copilot-compiled-screen {
display: grid;
gap: 10px;
}
.row {
display: flex;
justify-content: space-between;
@ -2030,6 +2377,7 @@ h3 {
.page-grid-signals,
.page-grid-charts,
.page-grid-replay,
.page-grid-settings,
.replay-matrix,
.shell-metrics {
grid-template-columns: minmax(0, 1fr);
@ -2060,6 +2408,13 @@ h3 {
min-height: 0;
}
.copilot-hero,
.copilot-account-grid,
.copilot-usage-grid,
.copilot-field-grid {
grid-template-columns: minmax(0, 1fr);
}
.terminal-topbar {
align-items: center;
justify-content: flex-end;
@ -2142,7 +2497,15 @@ h3 {
.terminal-pane-head,
.chart-controls,
.card-controls,
.terminal-pane-actions {
.terminal-pane-actions,
.copilot-inline-head,
.copilot-usage-title-row,
.copilot-task-head,
.copilot-turn-row,
.copilot-task-list-row,
.copilot-model-row,
.copilot-inline-row,
.copilot-apply-row {
flex-direction: column;
align-items: flex-start;
}
@ -2181,7 +2544,8 @@ h3 {
}
.terminal-topbar-actions,
.terminal-topbar-controls {
.terminal-topbar-controls,
.terminal-topbar-summary {
flex-direction: column;
align-items: stretch;
}
@ -2189,7 +2553,10 @@ h3 {
.terminal-topbar-mode .terminal-button,
.terminal-topbar-controls > .terminal-button,
.page-actions > .terminal-button,
.page-actions > .flow-filter-popover {
.page-actions > .flow-filter-popover,
.copilot-action-grid > .terminal-button,
.copilot-inline-head > .terminal-button,
.copilot-apply-row > .terminal-button {
width: 100%;
}
@ -2272,7 +2639,9 @@ h3 {
.flow-filter-checkbox-grid,
.flow-filter-checkbox-grid-wide,
.flow-filter-chip-grid {
.flow-filter-chip-grid,
.copilot-action-grid,
.copilot-token-grid {
grid-template-columns: minmax(0, 1fr);
}

View file

@ -1,6 +1,7 @@
import "./globals.css";
import type { ReactNode } from "react";
import { IBM_Plex_Mono, IBM_Plex_Sans, Quantico } from "next/font/google";
import { DesktopAiProvider } from "./desktop-ai";
import { TerminalAppShell } from "./terminal";
const display = Quantico({
@ -34,7 +35,9 @@ export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en">
<body className={`${display.variable} ${sans.variable} ${mono.variable}`}>
<TerminalAppShell>{children}</TerminalAppShell>
<DesktopAiProvider>
<TerminalAppShell>{children}</TerminalAppShell>
</DesktopAiProvider>
</body>
</html>
);

View file

@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { ReplayRoute } from "../terminal";
export const dynamic = "force-dynamic";
export default function Page() {
redirect("/");
return <ReplayRoute />;
}

View file

@ -1,31 +1,29 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
import { describe, expect, it } from "bun:test";
const redirect = mock((path: string) => {
throw new Error(`NEXT_REDIRECT:${path}`);
});
import { ChartsRoute, ReplayRoute, SettingsRoute, SignalsRoute } from "./terminal";
mock.module("next/navigation", () => ({ redirect }));
describe("legacy page redirects", () => {
beforeEach(() => {
redirect.mockClear();
});
it("redirects /signals to home", async () => {
describe("route entrypoints", () => {
it("renders the signals route directly", async () => {
const mod = await import("./signals/page");
expect(() => mod.default()).toThrow("NEXT_REDIRECT:/");
expect(redirect).toHaveBeenCalledWith("/");
expect(mod.dynamic).toBe("force-dynamic");
expect((mod.default() as any).type).toBe(SignalsRoute);
});
it("redirects /charts to home", async () => {
it("renders the charts route directly", async () => {
const mod = await import("./charts/page");
expect(() => mod.default()).toThrow("NEXT_REDIRECT:/");
expect(redirect).toHaveBeenCalledWith("/");
expect(mod.dynamic).toBe("force-dynamic");
expect((mod.default() as any).type).toBe(ChartsRoute);
});
it("redirects /replay to home", async () => {
it("renders the replay route directly", async () => {
const mod = await import("./replay/page");
expect(() => mod.default()).toThrow("NEXT_REDIRECT:/");
expect(redirect).toHaveBeenCalledWith("/");
expect(mod.dynamic).toBe("force-dynamic");
expect((mod.default() as any).type).toBe(ReplayRoute);
});
it("renders the settings route directly", async () => {
const mod = await import("./settings/page");
expect(mod.dynamic).toBe("force-dynamic");
expect((mod.default() as any).type).toBe(SettingsRoute);
});
});

View file

@ -0,0 +1,7 @@
import { SettingsRoute } from "../terminal";
export const dynamic = "force-dynamic";
export default function Page() {
return <SettingsRoute />;
}

View file

@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { SignalsRoute } from "../terminal";
export const dynamic = "force-dynamic";
export default function Page() {
redirect("/");
return <SignalsRoute />;
}

View file

@ -476,6 +476,24 @@ describe("route feature map", () => {
expect(features.showNewsPane).toBe(true);
expect(features.showAlertsPane).toBe(false);
});
it("maps /replay to replay panes and dependencies", () => {
const features = getRouteFeatures("/replay");
expect(features.showReplayConsole).toBe(true);
expect(features.showOptionsPane).toBe(true);
expect(features.showFlowPane).toBe(true);
expect(features.showAlertsPane).toBe(true);
expect(features.needsClassifierDecor).toBe(true);
});
it("maps /settings to a no-feed desktop settings surface", () => {
const features = getRouteFeatures("/settings");
expect(features.showReplayConsole).toBe(false);
expect(features.showOptionsPane).toBe(false);
expect(features.showAlertsPane).toBe(false);
expect(features.options).toBe(false);
expect(features.equities).toBe(false);
});
});
describe("fixed tape virtualization config", () => {
@ -506,11 +524,15 @@ describe("dark underlying route dependency helper", () => {
});
describe("terminal navigation", () => {
it("exposes Home, Tape, and News as top-level destinations", () => {
it("exposes the terminal routes including Copilot settings", () => {
expect(NAV_ITEMS).toEqual([
{ href: "/", label: "Home" },
{ href: "/tape", label: "Tape" },
{ href: "/news", label: "News" }
{ href: "/news", label: "News" },
{ href: "/signals", label: "Signals" },
{ href: "/charts", label: "Charts" },
{ href: "/replay", label: "Replay" },
{ href: "/settings", label: "Settings" }
]);
});
});

View file

@ -55,6 +55,13 @@ import {
matchesOptionPrintFilters
} from "@islandflow/types";
import { createChart, type IChartApi, type SeriesMarker, type UTCTimestamp } from "lightweight-charts";
import { useDesktopAi } from "./desktop-ai";
import {
DesktopAiSettingsRoute,
ReplayCopilotPanel,
ScreenCompilerPanel,
SmartMoneyCopilotPanel
} from "./desktop-ai-panels";
const parseBoundedInt = (
value: string | undefined,
@ -193,7 +200,8 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
pathname === "/news" ||
pathname === "/signals" ||
pathname === "/charts" ||
pathname === "/replay"
pathname === "/replay" ||
pathname === "/settings"
? pathname
: "/";
@ -338,6 +346,34 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
needsAlertEvidencePrefetch: true,
needsDarkUnderlying: false
};
case "/settings":
return {
options: false,
nbbo: false,
equities: false,
flow: false,
news: false,
alerts: false,
smartMoney: false,
classifierHits: false,
inferredDark: false,
equityJoins: false,
equityCandles: false,
equityOverlay: false,
showOptionsPane: false,
showEquitiesPane: false,
showFlowPane: false,
showNewsPane: false,
showAlertsPane: false,
showClassifierPane: false,
showDarkPane: false,
showChartPane: false,
showFocusPane: false,
showReplayConsole: false,
needsClassifierDecor: false,
needsAlertEvidencePrefetch: false,
needsDarkUnderlying: false
};
case "/":
default:
return {
@ -5123,10 +5159,11 @@ type SmartMoneyDrawerProps = {
event: SmartMoneyEvent;
flowPacket: FlowPacket | null;
evidence: EvidenceItem[];
relatedPackets: FlowPacket[];
onClose: () => void;
};
const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDrawerProps) => {
const SmartMoneyDrawer = ({ event, flowPacket, evidence, relatedPackets, onClose }: SmartMoneyDrawerProps) => {
const primaryScore =
event.profile_scores.find((score) => score.profile_id === event.primary_profile_id) ??
event.profile_scores[0];
@ -5155,6 +5192,15 @@ const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDr
{event.abstained ? <span className="drawer-chip">Abstained</span> : null}
</div>
<div className="drawer-section">
<SmartMoneyCopilotPanel
event={event}
flowPacket={flowPacket}
evidencePrints={evidencePrints.map((item) => item.print)}
relatedPackets={relatedPackets}
/>
</div>
<div className="drawer-section">
<h4>Profile ladder</h4>
<div className="drawer-list">
@ -6370,6 +6416,21 @@ const useTerminalState = () => {
});
}, [resolvedOptionPrintMap, selectedSmartMoneyEvent]);
const selectedSmartMoneyRelatedPackets = useMemo((): FlowPacket[] => {
if (!selectedSmartMoneyEvent) {
return [];
}
const packets: FlowPacket[] = [];
for (const packetId of selectedSmartMoneyEvent.packet_ids) {
const packet = resolvedFlowPacketMap.get(packetId);
if (packet) {
packets.push(packet);
}
}
return packets;
}, [resolvedFlowPacketMap, selectedSmartMoneyEvent]);
useEffect(() => {
if (!selectedSmartMoneyEvent || mode !== "live") {
return;
@ -7130,6 +7191,7 @@ const useTerminalState = () => {
selectedClassifierEvidence,
selectedSmartMoneyFlowPacket,
selectedSmartMoneyEvidence,
selectedSmartMoneyRelatedPackets,
filteredOptions,
filteredEquities,
optionsScopedQuiet,
@ -7171,7 +7233,11 @@ const useTerminal = (): TerminalState => {
export const NAV_ITEMS = [
{ href: "/", label: "Home" },
{ href: "/tape", label: "Tape" },
{ href: "/news", label: "News" }
{ href: "/news", label: "News" },
{ href: "/signals", label: "Signals" },
{ href: "/charts", label: "Charts" },
{ href: "/replay", label: "Replay" },
{ href: "/settings", label: "Settings" }
] as const;
type PageFrameProps = {
@ -8811,9 +8877,16 @@ function SyntheticControlDock() {
export function TerminalAppShell({ children }: { children: ReactNode }) {
const state = useTerminalState();
const ai = useDesktopAi();
const pathname = usePathname();
const tickerFieldId = useId();
const tickerHintId = useId();
const isSettingsRoute = pathname === "/settings";
const aiButtonLabel = ai.state.account.loggedIn
? `AI ${ai.state.account.planType ? ai.state.account.planType.toUpperCase() : "ON"}`
: ai.bridgeAvailable
? "AI setup"
: "AI desktop";
return (
<TerminalContext.Provider value={state}>
@ -8847,59 +8920,84 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
<div className="terminal-frame">
<header className="terminal-topbar">
<div className="terminal-topbar-actions">
<div className="terminal-topbar-controls">
{state.selectedInstrumentLabel && state.selectedInstrument?.kind !== "option-contract" ? (
<span className="instrument-focus-chip">
<span>{state.selectedInstrumentLabel}</span>
<button type="button" onClick={() => state.setSelectedInstrument(null)}>
Clear
</button>
</span>
) : null}
<label className="terminal-filter">
<span className="terminal-filter-label" id={tickerHintId}>
Ticker
</span>
<span className="terminal-filter-field">
<input
id={tickerFieldId}
aria-describedby={tickerHintId}
autoCapitalize="characters"
autoComplete="off"
autoCorrect="off"
className="terminal-input"
value={state.filterInput}
inputMode="text"
maxLength={TICKER_FILTER_INPUT_MAX_LENGTH}
name="ticker-filter"
onChange={(event) => state.setFilterInput(normalizeTickerFilterInput(event.target.value))}
placeholder="SPY, NVDA, AAPL"
spellCheck={false}
/>
</span>
</label>
<button
aria-label="Clear ticker filter"
className="terminal-button"
type="button"
onClick={() => state.setFilterInput("")}
disabled={state.filterInput.trim().length === 0}
title="Clear ticker filter"
{isSettingsRoute ? (
<div
className={`terminal-topbar-summary${ai.state.account.loggedIn ? " status-connected" : " status-disconnected"}`}
>
Clear
</button>
</div>
<span className="status-dot" />
<div>
<strong>Desktop Analyst Copilot</strong>
<p className="copilot-note">
{ai.state.account.loggedIn
? `${ai.state.account.email ?? "Connected"} · ${ai.state.account.planType ?? "plan pending"}`
: "Connect your ChatGPT subscription to unlock desktop-only AI tools."}
</p>
</div>
</div>
) : (
<div className="terminal-topbar-controls">
{state.selectedInstrumentLabel && state.selectedInstrument?.kind !== "option-contract" ? (
<span className="instrument-focus-chip">
<span>{state.selectedInstrumentLabel}</span>
<button type="button" onClick={() => state.setSelectedInstrument(null)}>
Clear
</button>
</span>
) : null}
<label className="terminal-filter">
<span className="terminal-filter-label" id={tickerHintId}>
Ticker
</span>
<span className="terminal-filter-field">
<input
id={tickerFieldId}
aria-describedby={tickerHintId}
autoCapitalize="characters"
autoComplete="off"
autoCorrect="off"
className="terminal-input"
value={state.filterInput}
inputMode="text"
maxLength={TICKER_FILTER_INPUT_MAX_LENGTH}
name="ticker-filter"
onChange={(event) => state.setFilterInput(normalizeTickerFilterInput(event.target.value))}
placeholder="SPY, NVDA, AAPL"
spellCheck={false}
/>
</span>
</label>
<button
aria-label="Clear ticker filter"
className="terminal-button"
type="button"
onClick={() => state.setFilterInput("")}
disabled={state.filterInput.trim().length === 0}
title="Clear ticker filter"
>
Clear
</button>
</div>
)}
<div className="terminal-topbar-mode">
<button
aria-label={state.mode === "live" ? "Switch to replay mode" : "Switch to live mode"}
aria-pressed={state.mode !== "live"}
className="terminal-button terminal-button-primary"
type="button"
onClick={state.toggleMode}
title={state.mode === "live" ? "Switch to replay mode" : "Switch to live mode"}
<Link
aria-current={isSettingsRoute ? "page" : undefined}
className={`terminal-button${isSettingsRoute ? " terminal-button-primary" : ""}`}
href="/settings"
>
{state.mode === "live" ? "Replay" : "Live"}
</button>
{aiButtonLabel}
</Link>
{!isSettingsRoute ? (
<button
aria-label={state.mode === "live" ? "Switch to replay mode" : "Switch to live mode"}
aria-pressed={state.mode !== "live"}
className="terminal-button terminal-button-primary"
type="button"
onClick={state.toggleMode}
title={state.mode === "live" ? "Switch to replay mode" : "Switch to live mode"}
>
{state.mode === "live" ? "Replay" : "Live"}
</button>
) : null}
</div>
</div>
</header>
@ -8939,6 +9037,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
event={state.selectedSmartMoneyEvent}
flowPacket={state.selectedSmartMoneyFlowPacket}
evidence={state.selectedSmartMoneyEvidence}
relatedPackets={state.selectedSmartMoneyRelatedPackets}
onClose={() => state.setSelectedSmartMoneyEvent(null)}
/>
) : null}
@ -9009,11 +9108,14 @@ export function TapeRoute() {
</>
}
>
<div className="page-grid page-grid-tape">
<OptionsPane state={state} />
<EquitiesPane state={state} />
<FlowPane state={state} title="Packets" />
</div>
<>
<ScreenCompilerPanel currentFilters={state.flowFilters} onApplyFilters={state.setFlowFilters} />
<div className="page-grid page-grid-tape">
<OptionsPane state={state} />
<EquitiesPane state={state} />
<FlowPane state={state} title="Packets" />
</div>
</>
</PageFrame>
);
}
@ -9045,9 +9147,39 @@ export function ChartsRoute() {
export function ReplayRoute() {
const state = useTerminal();
const replayContext = useMemo(
() => ({
ticker: state.replaySource ?? state.activeTickers[0] ?? null,
flowFilters: state.flowFilters,
alerts: state.filteredAlerts.slice(0, 12),
smartMoneyEvents: state.filteredSmartMoneyEvents.slice(0, 12),
classifierHits: state.filteredClassifierHits.slice(0, 12),
flowPackets: state.filteredFlow.slice(0, 18),
optionPrints: state.filteredOptions.slice(0, 24)
}),
[
state.replaySource,
state.activeTickers,
state.flowFilters,
state.filteredAlerts,
state.filteredSmartMoneyEvents,
state.filteredClassifierHits,
state.filteredFlow,
state.filteredOptions
]
);
return (
<PageFrame title="Replay">
<div className="page-grid page-grid-replay">
<ReplayCopilotPanel
ticker={replayContext.ticker}
flowFilters={replayContext.flowFilters}
alerts={replayContext.alerts}
smartMoneyEvents={replayContext.smartMoneyEvents}
classifierHits={replayContext.classifierHits}
flowPackets={replayContext.flowPackets}
optionPrints={replayContext.optionPrints}
/>
<ReplayConsole state={state} />
<AlertsPane state={state} limit={10} withStrip />
<FlowPane state={state} limit={12} />
@ -9056,3 +9188,11 @@ export function ReplayRoute() {
</PageFrame>
);
}
export function SettingsRoute() {
return (
<PageFrame title="Settings">
<DesktopAiSettingsRoute />
</PageFrame>
);
}

View file

@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.