add desktop codex login and analyst copilot
This commit is contained in:
parent
fb25b5ac97
commit
a8d183f38e
24 changed files with 4127 additions and 97 deletions
|
|
@ -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",
|
||||
|
|
|
|||
8
apps/desktop/src/desktop-ai-ipc.ts
Normal file
8
apps/desktop/src/desktop-ai-ipc.ts
Normal 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";
|
||||
174
apps/desktop/src/desktop-ai.test.ts
Normal file
174
apps/desktop/src/desktop-ai.test.ts
Normal 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." });
|
||||
});
|
||||
});
|
||||
1222
apps/desktop/src/desktop-ai.ts
Normal file
1222
apps/desktop/src/desktop-ai.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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", () => {
|
||||
|
|
|
|||
43
apps/desktop/src/preload.ts
Normal file
43
apps/desktop/src/preload.ts
Normal 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);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
924
apps/web/app/desktop-ai-panels.tsx
Normal file
924
apps/web/app/desktop-ai-panels.tsx
Normal 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
179
apps/web/app/desktop-ai.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
7
apps/web/app/settings/page.tsx
Normal file
7
apps/web/app/settings/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { SettingsRoute } from "../terminal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function Page() {
|
||||
return <SettingsRoute />;
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
3
apps/web/next-env.d.ts
vendored
3
apps/web/next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue