Compare commits
6 commits
main
...
lavender/c
| Author | SHA1 | Date | |
|---|---|---|---|
| 04ce36d575 | |||
| ebdc4ab8e6 | |||
| 17b030f01f | |||
| 7b87f976a2 | |||
| 1543f419e6 | |||
| a8d183f38e |
30 changed files with 6599 additions and 130 deletions
|
|
@ -1,3 +1,5 @@
|
|||
{"_type":"issue","id":"islandflow-hj3","title":"Fix Electron preload for desktop AI bridge","description":"## Why\\nThe desktop settings page reports the native AI bridge as unavailable because Electron fails to load the preload script in local dev.\\n\\n## What\\nUpdate the desktop preload implementation/build so Electron can execute it, restore window.islandflowDesktop, and verify the Copilot settings panel detects the bridge again.\\n\\n## Acceptance Criteria\\n- Electron no longer logs a preload syntax error\\n- window.islandflowDesktop is available in the desktop renderer\\n- The settings page no longer shows bridge unavailable solely because preload failed\\n- Relevant desktop/web tests pass","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T23:16:39Z","created_by":"dirtydishes","updated_at":"2026-05-20T23:20:20Z","started_at":"2026-05-20T23:16:48Z","closed_at":"2026-05-20T23:20:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-199","title":"fix desktop copilot fallback inside electron","description":"## Why\\nThe settings page can render the browser-only fallback even when Islandflow is running inside the Electron desktop shell.\\n\\n## What\\nSeparate desktop-shell detection from desktop AI transport state, make the provider recover if the bridge appears late or initial state loading fails, and cover the regression with tests.\\n\\n## Acceptance Criteria\\n- The desktop shell no longer shows the browser-only fallback solely because initial bridge state failed or arrived late\\n- Desktop-only actions can distinguish between missing Electron bridge and transport/auth problems\\n- Automated tests cover the recovery behavior","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T22:30:16Z","created_by":"dirtydishes","updated_at":"2026-05-20T22:37:21Z","started_at":"2026-05-20T22:30:23Z","closed_at":"2026-05-20T22:37:21Z","close_reason":"Fixed desktop-shell Copilot fallback handling, added bridge recovery logic, updated desktop-vs-bridge UI messaging, and added regression tests. Follow-up tracked in islandflow-c8f for unrelated web build blocker.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-yza","title":"Persist historical flow packets for alert detail replay","description":"## Why\nAlert details can show a missing persisted flow packet when the packet is no longer present in the Redis hot cache, even though the associated historical alert and evidence were loaded from ClickHouse.\n\n## What needs to be done\nTrace the API path that resolves alert detail flow packets, compare Redis hot-cache lookups with ClickHouse historical fetches, and ensure historical flow packet payloads are treated as first-class persisted data with context preserved when replaying or loading older alerts.\n\n## Acceptance Criteria\n- Alert detail flow packets load for historical alerts even when the packet is absent from Redis hot cache\n- Historical ClickHouse-backed flow packet responses preserve the context required by the UI\n- Relevant automated tests cover the regression or the gap is explicitly documented","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T06:52:04Z","created_by":"dirtydishes","updated_at":"2026-05-20T06:59:26Z","started_at":"2026-05-20T06:52:09Z","closed_at":"2026-05-20T06:59:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-jor","title":"Support Forgejo pull request status in desktop git panel","description":"The desktop app currently reports pull request status unavailable when a repository only has a Forgejo remote. Add native Forgejo/Gitea-style remote detection and pull request status lookup so Forgejo-only repositories can show PR state in the Codex app git panel.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T20:55:15Z","created_by":"dirtydishes","updated_at":"2026-05-19T20:59:46Z","started_at":"2026-05-19T20:55:25Z","closed_at":"2026-05-19T20:59:46Z","close_reason":"Patched the installed Codex desktop app bundle with a Forgejo PR status fallback and documented the local change.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-g3a","title":"Reconcile PR merge conflicts","description":"Resolve the current pull request conflicts for the nextjs-upgrade branch, validate the result, document the turn, and push the reconciled branch.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T18:44:51Z","created_by":"dirtydishes","updated_at":"2026-05-19T18:47:35Z","started_at":"2026-05-19T18:44:56Z","closed_at":"2026-05-19T18:47:35Z","close_reason":"Merged forgejo/main into nextjs-upgrade, resolved README and Beads conflicts, updated JetStream retention tests, validated deploy help, Docker workspace sync, API/bus tests, and web build, and added turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
@ -16,6 +18,10 @@
|
|||
{"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-dy2","title":"Clarify desktop AI settings when bridge is unavailable","description":"The /settings desktop AI panel currently renders disabled ChatGPT login buttons and empty-feeling model controls when the native bridge is unavailable. Users read this as broken UI because the controls do not clearly explain that the desktop shell is missing its bridge session and therefore cannot load login or model options. Update the settings surface to explain the unavailable state, provide direct recovery guidance, and make disabled controls self-explanatory.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T22:56:03Z","created_by":"dirtydishes","updated_at":"2026-05-20T23:01:33Z","started_at":"2026-05-20T22:56:26Z","closed_at":"2026-05-20T23:01:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-c8f","title":"fix packages/types ts-extension imports for next build","description":"## Why\\nThe web production build fails during type-checking because packages/types/src/desktop-ai.ts imports sibling files with explicit .ts extensions, which Next's TypeScript config rejects without allowImportingTsExtensions.\\n\\n## What\\nNormalize the packages/types import specifiers so Next can type-check the shared package during app builds, or adjust the shared tsconfig/build strategy in a deliberate way.\\n\\n## Acceptance Criteria\\n- bun --cwd=apps/web run build no longer fails on .ts-extension import paths from packages/types\\n- The chosen import-specifier strategy is consistent across packages/types","status":"open","priority":2,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-20T22:35:30Z","created_by":"dirtydishes","updated_at":"2026-05-20T22:35:30Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-64s","title":"Fix desktop startup failure from @islandflow/types ESM imports","description":"Electron desktop startup fails with ERR_MODULE_NOT_FOUND because @islandflow/types exports TypeScript source and internal relative imports lacked .ts extensions under Node/Electron ESM resolution. Update type package internal imports and desktop tsconfig so desktop build and runtime can resolve modules consistently.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T22:26:45Z","created_by":"dirtydishes","updated_at":"2026-05-20T22:28:05Z","started_at":"2026-05-20T22:26:50Z","closed_at":"2026-05-20T22:28:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-6tn","title":"Add Codex desktop login and usage bridge","description":"Implement a desktop-only Codex integration for the Islandflow Electron app using the official codex app-server with managed ChatGPT login, native IPC, settings UI, usage tracking, and clean web degradation.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T14:01:36Z","created_by":"dirtydishes","updated_at":"2026-05-20T14:40:49Z","started_at":"2026-05-20T14:01:48Z","closed_at":"2026-05-20T14:40:49Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-laq","title":"fix native alpaca news deploy and auth","description":"Why this issue exists and what needs to be done:\\n\\nNative Islandflow rollout is incomplete because services/ingest-news is not healthy on the VPS. The checked-in native user units and helper scripts do not fully include ingest-news, and the current service uses bearer-style auth that returns 401 against Alpaca news endpoints.\\n\\nThis task should verify the current Alpaca news auth requirements against official docs, update the repo code and native deployment assets as needed, install and enable the missing VPS unit, verify news events flow end-to-end, and document the work.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T23:47:07Z","created_by":"dirtydishes","updated_at":"2026-05-20T00:05:20Z","started_at":"2026-05-19T23:47:12Z","closed_at":"2026-05-20T00:05:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-fmg","title":"Fix native deploy SSH path and verification cwd assumptions","description":"Native deploys over SSH assumed bun was already on PATH and that remote verification would run from the repository root. On the live VPS, non-login SSH shells omitted /home/delta/.bun/bin and remote native verification could not find deployment/native/check-native-infra.sh because it ran from the home directory. Update the deploy helper to prepend /Users/kell/.bun/bin when present and cd into the repo before native verification checks run.","status":"closed","priority":2,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-19T23:38:32Z","created_by":"dirtydishes","updated_at":"2026-05-19T23:40:33Z","closed_at":"2026-05-19T23:40:33Z","close_reason":"Updated native SSH deploy flow to prepend Bun's home install path when present and run native verification from the repo root before health scripts.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-wf5","title":"Harden native options provider configuration after synthetic recovery","description":"Native production recovery restored OPTIONS_INGEST_ADAPTER=synthetic because the current Alpaca setup fails authentication and crash-loops ingest-options. Follow up by deciding whether production options should remain synthetic or move to a supported live provider auth path, then add a deploy-time smoke test or config validation that catches provider auth failures before native cutover.","status":"open","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-19T23:27:51Z","created_by":"dirtydishes","updated_at":"2026-05-19T23:27:51Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
@ -61,6 +67,7 @@
|
|||
{"_type":"issue","id":"islandflow-zs0","title":"Migrate terminal UI to smart-money profiles","description":"Migrate apps/web terminal rendering to consume SmartMoneyEvent directly: primary profile, probability ladder, reason codes, and suppression/abstention state, while preserving legacy alert/classifier displays during the bridge.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-04T21:35:23Z","created_by":"dirtydishes","updated_at":"2026-05-05T05:39:58Z","closed_at":"2026-05-05T05:39:58Z","close_reason":"Completed terminal smart-money profile migration","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-igk","title":"Add plan mode","description":"Implement a user-facing plan mode in the application so users can switch into planning before taking action. Scope to be clarified from existing app patterns.","status":"closed","priority":2,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-04T04:22:37Z","created_by":"dirtydishes","updated_at":"2026-05-04T04:26:18Z","started_at":"2026-05-04T04:22:40Z","closed_at":"2026-05-04T04:26:18Z","close_reason":"Implemented as a global pi extension toggled with Shift+P","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-biq","title":"Finish raw live options delivery and filter/backpressure observability","description":"The smart-money signal path and Tape filters are in place, but the next firehose pass should finish server-side selective raw live delivery for options subscriptions and add explicit filtered-out/backpressure observability for API/web counters. This was discovered while landing islandflow-e4r.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:28:58Z","created_by":"dirtydishes","updated_at":"2026-04-29T03:54:12Z","started_at":"2026-04-29T03:54:12Z","dependencies":[{"issue_id":"islandflow-biq","depends_on_id":"islandflow-e4r","type":"discovered-from","created_at":"2026-04-28T16:28:58Z","created_by":"auto-import","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-8vr","title":"Summarize 2026-05-19 git activity for standup","description":"Create the daily git summary for 2026-05-19 in docs/general using yesterday's commits, touched files, and validation evidence only.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T13:02:41Z","created_by":"dirtydishes","updated_at":"2026-05-20T13:04:50Z","started_at":"2026-05-20T13:02:47Z","closed_at":"2026-05-20T13:04:50Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-0ty","title":"Recreate May 18 standup summary after merge","description":"Regenerate docs/daily-git/2026-05-19-standup-summary-2026-05-18.html using merged history so it reflects all commits in the May 18 window, including native deployment and merge commits.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T18:53:48Z","created_by":"dirtydishes","updated_at":"2026-05-19T18:55:33Z","started_at":"2026-05-19T18:53:52Z","closed_at":"2026-05-19T18:55:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-2df","title":"Publish 2026-05-18 git standup summary","description":"Why: the daily automation needs a grounded standup summary for May 18, 2026. What: review commits from 2026-05-18, create a scannable HTML summary in docs/daily-git, and capture only commit/file-backed statements.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T18:41:07Z","created_by":"dirtydishes","updated_at":"2026-05-19T18:42:42Z","started_at":"2026-05-19T18:41:10Z","closed_at":"2026-05-19T18:42:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-x70","title":"Create 2026-05-17 git standup summary","description":"Why this issue exists and what needs to be done:\\n- Produce the daily automation summary for 2026-05-17 git activity.\\n- Ground statements in commits, PRs, and touched files only.\\n- Create a user-readable HTML document in docs/general and update automation memory.\\n- Complete the Beads sync and git push workflow after documenting the run.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T13:01:43Z","created_by":"dirtydishes","updated_at":"2026-05-18T13:05:37Z","started_at":"2026-05-18T13:01:53Z","closed_at":"2026-05-18T13:05:37Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
const DESKTOP_AI_STATE_CHANNEL = "islandflow:desktop-ai:state";
|
||||
const DESKTOP_AI_GET_STATE = "islandflow:desktop-ai:get-state";
|
||||
const DESKTOP_AI_LOGIN_BROWSER = "islandflow:desktop-ai:login-browser";
|
||||
const DESKTOP_AI_LOGIN_DEVICE = "islandflow:desktop-ai:login-device";
|
||||
const DESKTOP_AI_CANCEL_LOGIN = "islandflow:desktop-ai:cancel-login";
|
||||
const DESKTOP_AI_LOGOUT = "islandflow:desktop-ai:logout";
|
||||
const DESKTOP_AI_UPDATE_PREFERENCES = "islandflow:desktop-ai:update-preferences";
|
||||
const DESKTOP_AI_RUN_TASK = "islandflow:desktop-ai:run-task";
|
||||
|
||||
type DesktopAiState = any;
|
||||
type DesktopAiTaskRequest = any;
|
||||
type DesktopAiPreferenceUpdate = Partial<{
|
||||
model: string | null;
|
||||
reasoningEffort: string | null;
|
||||
}>;
|
||||
|
||||
const bridge = {
|
||||
ai: {
|
||||
getState: (): Promise<DesktopAiState> => 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: DesktopAiPreferenceUpdate): Promise<void> =>
|
||||
ipcRenderer.invoke(DESKTOP_AI_UPDATE_PREFERENCES, next),
|
||||
runTask: (request: DesktopAiTaskRequest): Promise<{ taskId: string }> =>
|
||||
ipcRenderer.invoke(DESKTOP_AI_RUN_TASK, request),
|
||||
subscribe: (listener: (state: DesktopAiState) => void): (() => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, state: DesktopAiState) => {
|
||||
listener(state);
|
||||
};
|
||||
|
||||
ipcRenderer.on(DESKTOP_AI_STATE_CHANNEL, handler);
|
||||
return () => {
|
||||
ipcRenderer.off(DESKTOP_AI_STATE_CHANNEL, handler);
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("islandflowDesktop", bridge);
|
||||
|
|
@ -2,13 +2,15 @@
|
|||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
1203
apps/web/app/desktop-ai-panels.tsx
Normal file
1203
apps/web/app/desktop-ai-panels.tsx
Normal file
File diff suppressed because it is too large
Load diff
148
apps/web/app/desktop-ai.test.ts
Normal file
148
apps/web/app/desktop-ai.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
createUnavailableState,
|
||||
detectDesktopShell,
|
||||
resolveDesktopAiRuntime,
|
||||
} from "./desktop-ai";
|
||||
import {
|
||||
getDesktopAiModelListEmptyCopy,
|
||||
getDesktopAiModelSelectLabel,
|
||||
getDesktopAiProfileBadgeLabel,
|
||||
getDesktopAiSettingsBridgeNotice,
|
||||
requireDesktopActionCopy,
|
||||
} from "./desktop-ai-panels";
|
||||
|
||||
describe("desktop ai runtime detection", () => {
|
||||
it("recognizes Electron user agents before the bridge is available", () => {
|
||||
const runtime = resolveDesktopAiRuntime({
|
||||
navigator: {
|
||||
userAgent: "Mozilla/5.0 Islandflow Electron/39.0.0 Safari/537.36",
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.shellAvailable).toBe(true);
|
||||
expect(runtime.bridgeAvailable).toBe(false);
|
||||
expect(runtime.bridge).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a bridged window as desktop even without an Electron user agent", () => {
|
||||
const runtime = resolveDesktopAiRuntime({
|
||||
islandflowDesktop: {
|
||||
ai: {
|
||||
getState: async () =>
|
||||
createUnavailableState({
|
||||
shellAvailable: true,
|
||||
bridgeAvailable: true,
|
||||
}),
|
||||
loginWithBrowser: async () => {},
|
||||
loginWithDeviceCode: async () => {},
|
||||
cancelLogin: async () => {},
|
||||
logout: async () => {},
|
||||
updatePreferences: async () => {},
|
||||
runTask: async () => ({ taskId: "task-1" }),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
},
|
||||
navigator: { userAgent: "Mozilla/5.0" },
|
||||
});
|
||||
|
||||
expect(runtime.shellAvailable).toBe(true);
|
||||
expect(runtime.bridgeAvailable).toBe(true);
|
||||
expect(runtime.bridge).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop ai unavailable state", () => {
|
||||
it("keeps desktopAvailable false for real browser fallbacks", () => {
|
||||
const state = createUnavailableState();
|
||||
|
||||
expect(state.desktopAvailable).toBe(false);
|
||||
expect(state.transportStatus).toBe("stopped");
|
||||
expect(state.transportError).toContain("Electron app");
|
||||
});
|
||||
|
||||
it("reports desktop-shell bridge failures without pretending the app is a browser", () => {
|
||||
const state = createUnavailableState({ shellAvailable: true });
|
||||
|
||||
expect(state.desktopAvailable).toBe(true);
|
||||
expect(state.transportStatus).toBe("error");
|
||||
expect(state.transportError).toContain("native AI bridge");
|
||||
expect(state.account.login.message).toContain("Reload the window");
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop action copy", () => {
|
||||
it("asks for the desktop app only when the shell is genuinely absent", () => {
|
||||
expect(requireDesktopActionCopy(false, false, false)).toContain(
|
||||
"Open Islandflow Desktop",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces bridge recovery guidance inside the desktop shell", () => {
|
||||
expect(requireDesktopActionCopy(true, false, false)).toContain(
|
||||
"missing the native AI bridge",
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for login once the bridge is present", () => {
|
||||
expect(requireDesktopActionCopy(true, true, false)).toContain(
|
||||
"Connect a ChatGPT or Codex account",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears helper copy when the action is ready", () => {
|
||||
expect(requireDesktopActionCopy(true, true, true)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop shell detection", () => {
|
||||
it("matches Electron signatures", () => {
|
||||
expect(detectDesktopShell("Mozilla/5.0 Electron/39.0.0")).toBe(true);
|
||||
expect(
|
||||
detectDesktopShell("Mozilla/5.0 Chrome/136.0.0.0 Safari/537.36"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop ai settings copy", () => {
|
||||
it("explains when the desktop app itself is required", () => {
|
||||
expect(getDesktopAiSettingsBridgeNotice(false, false)).toEqual({
|
||||
title: "Desktop app required",
|
||||
body: "Open Islandflow Desktop to connect ChatGPT, load managed models, and use native Copilot controls.",
|
||||
});
|
||||
});
|
||||
|
||||
it("explains when the native bridge is missing from the desktop window", () => {
|
||||
expect(getDesktopAiSettingsBridgeNotice(true, false)?.title).toBe(
|
||||
"Bridge unavailable in this window",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the model selector explicit before login", () => {
|
||||
expect(getDesktopAiModelSelectLabel(true, true, false, 0)).toBe(
|
||||
"Connect ChatGPT to load models",
|
||||
);
|
||||
expect(getDesktopAiModelListEmptyCopy(true, true, false)).toContain(
|
||||
"Connect a ChatGPT or Codex account",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the model selector explicit while the bridge is disconnected", () => {
|
||||
expect(getDesktopAiModelSelectLabel(true, false, false, 0)).toBe(
|
||||
"Bridge unavailable",
|
||||
);
|
||||
expect(getDesktopAiModelListEmptyCopy(true, false, false)).toContain(
|
||||
"native AI bridge reconnects",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the real status label when a selected profile is unusable", () => {
|
||||
expect(
|
||||
getDesktopAiProfileBadgeLabel(true, "Bridge unavailable", false),
|
||||
).toBe("Bridge unavailable");
|
||||
expect(
|
||||
getDesktopAiProfileBadgeLabel(true, "Bridge unavailable", true),
|
||||
).toBe("Selected");
|
||||
});
|
||||
});
|
||||
278
apps/web/app/desktop-ai.tsx
Normal file
278
apps/web/app/desktop-ai.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"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;
|
||||
};
|
||||
};
|
||||
|
||||
type DesktopAiRuntime = {
|
||||
shellAvailable: boolean;
|
||||
bridgeAvailable: boolean;
|
||||
bridge: DesktopAiBridge | null;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
islandflowDesktop?: DesktopAiBridge;
|
||||
}
|
||||
}
|
||||
|
||||
type DesktopAiContextValue = {
|
||||
bridgeAvailable: boolean;
|
||||
shellAvailable: 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 BRIDGE_POLL_INTERVAL_MS = 250;
|
||||
const BRIDGE_POLL_MAX_ATTEMPTS = 20;
|
||||
const ELECTRON_USER_AGENT_PATTERN = /\bElectron\/\S+/i;
|
||||
|
||||
export const detectDesktopShell = (userAgent: string | null | undefined): boolean =>
|
||||
Boolean(userAgent && ELECTRON_USER_AGENT_PATTERN.test(userAgent));
|
||||
|
||||
export const resolveDesktopAiRuntime = (
|
||||
value:
|
||||
| {
|
||||
islandflowDesktop?: DesktopAiBridge;
|
||||
navigator?: { userAgent?: string | null };
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
): DesktopAiRuntime => {
|
||||
const bridge = value?.islandflowDesktop?.ai ? value.islandflowDesktop : null;
|
||||
const bridgeAvailable = Boolean(bridge?.ai);
|
||||
const shellAvailable = bridgeAvailable || detectDesktopShell(value?.navigator?.userAgent);
|
||||
|
||||
return {
|
||||
shellAvailable,
|
||||
bridgeAvailable,
|
||||
bridge
|
||||
};
|
||||
};
|
||||
|
||||
export const createUnavailableState = (runtime?: Partial<DesktopAiRuntime>): IslandflowAiState => {
|
||||
const shellAvailable = Boolean(runtime?.shellAvailable || runtime?.bridgeAvailable);
|
||||
const bridgeAvailable = Boolean(runtime?.bridgeAvailable);
|
||||
const transportError = !shellAvailable
|
||||
? "Desktop AI is only available inside the Islandflow Electron app."
|
||||
: bridgeAvailable
|
||||
? "The desktop AI bridge loaded, but its initial state could not be read."
|
||||
: "Islandflow Desktop is open, but the native AI bridge is unavailable in this session.";
|
||||
const loginMessage = !shellAvailable
|
||||
? "Open Islandflow Desktop to connect a ChatGPT or Codex account."
|
||||
: bridgeAvailable
|
||||
? "The desktop bridge connected, but its initial state did not load. Retry the action or restart Islandflow if this persists."
|
||||
: "This desktop window is missing its native AI bridge. Reload the window or restart Islandflow if this persists.";
|
||||
|
||||
return {
|
||||
desktopAvailable: shellAvailable,
|
||||
transportStatus: shellAvailable ? "error" : "stopped",
|
||||
transportError,
|
||||
profiles: [
|
||||
{
|
||||
id: "managed-chatgpt",
|
||||
label: "Managed ChatGPT login",
|
||||
description: shellAvailable
|
||||
? "Managed ChatGPT login belongs to the desktop shell, but this window is not connected to the native bridge yet."
|
||||
: "Available only in the desktop app.",
|
||||
mode: "managed-chatgpt",
|
||||
enabled: shellAvailable,
|
||||
selected: true,
|
||||
statusLabel: shellAvailable ? "Bridge unavailable" : "Desktop only"
|
||||
}
|
||||
],
|
||||
selectedProfileId: "managed-chatgpt",
|
||||
account: {
|
||||
loggedIn: false,
|
||||
email: null,
|
||||
planType: null,
|
||||
authMode: null,
|
||||
requiresOpenaiAuth: true,
|
||||
login: {
|
||||
status: "idle",
|
||||
message: loginMessage
|
||||
}
|
||||
},
|
||||
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);
|
||||
const [shellAvailable, setShellAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let unsubscribe = () => {};
|
||||
let pollTimer: number | null = null;
|
||||
let attempts = 0;
|
||||
|
||||
const connectBridge = (runtime: DesktopAiRuntime): boolean => {
|
||||
if (!runtime.bridge) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setShellAvailable(runtime.shellAvailable);
|
||||
setBridge(runtime.bridge);
|
||||
void runtime.bridge.ai.getState().then(
|
||||
(nextState) => {
|
||||
if (!disposed) {
|
||||
setState(nextState);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!disposed) {
|
||||
setState(createUnavailableState(runtime));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
unsubscribe = runtime.bridge.ai.subscribe((nextState) => {
|
||||
if (!disposed) {
|
||||
setState(nextState);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const syncRuntime = (): boolean => {
|
||||
const runtime = resolveDesktopAiRuntime(window);
|
||||
setShellAvailable(runtime.shellAvailable);
|
||||
if (connectBridge(runtime)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
setBridge(null);
|
||||
setState(createUnavailableState(runtime));
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!syncRuntime()) {
|
||||
const pollForBridge = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
if (syncRuntime() || attempts >= BRIDGE_POLL_MAX_ATTEMPTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (pollTimer !== null) {
|
||||
window.clearTimeout(pollTimer);
|
||||
}
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<DesktopAiContextValue>(
|
||||
() => ({
|
||||
bridgeAvailable: Boolean(bridge?.ai),
|
||||
shellAvailable,
|
||||
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, shellAvailable, 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;
|
||||
};
|
||||
|
|
@ -37,7 +37,11 @@ body {
|
|||
font-family: var(--font-sans), sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, oklch(0.78 0.12 74 / 0.08), transparent 30%),
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
oklch(0.78 0.12 74 / 0.08),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(180deg, oklch(0.15 0.012 250) 0%, oklch(0.11 0.01 250) 100%);
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +93,11 @@ input {
|
|||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: var(--rail-width) minmax(0, 1fr);
|
||||
background: linear-gradient(180deg, oklch(0.14 0.011 250) 0%, oklch(0.11 0.01 250) 100%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(0.14 0.011 250) 0%,
|
||||
oklch(0.11 0.01 250) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.terminal-rail {
|
||||
|
|
@ -100,7 +108,11 @@ input {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
background: linear-gradient(180deg, oklch(0.16 0.012 250 / 0.98), oklch(0.13 0.011 250 / 0.98));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(0.16 0.012 250 / 0.98),
|
||||
oklch(0.13 0.011 250 / 0.98)
|
||||
);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +152,10 @@ input {
|
|||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.76rem;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.terminal-nav-link:hover {
|
||||
|
|
@ -276,6 +291,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 +746,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 +758,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 +1022,342 @@ 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-callout-warning {
|
||||
border-color: oklch(0.74 0.08 68 / 0.42);
|
||||
background: oklch(0.2 0.03 68 / 0.28);
|
||||
}
|
||||
|
||||
.copilot-stat span,
|
||||
.copilot-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;
|
||||
|
|
@ -1169,19 +1548,34 @@ h3 {
|
|||
|
||||
.data-table-row-classified {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.012 + var(--classifier-intensity, 0) * 0.06)), transparent 62%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(
|
||||
var(--classifier-rgb, 192, 200, 210),
|
||||
calc(0.012 + var(--classifier-intensity, 0) * 0.06)
|
||||
),
|
||||
transparent 62%
|
||||
),
|
||||
oklch(0.98 0.008 250 / 0.008);
|
||||
}
|
||||
|
||||
.data-table-row-classified:hover,
|
||||
.data-table-row-classified:focus-visible {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.02 + var(--classifier-intensity, 0) * 0.1)), transparent 68%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(
|
||||
var(--classifier-rgb, 192, 200, 210),
|
||||
calc(0.02 + var(--classifier-intensity, 0) * 0.1)
|
||||
),
|
||||
transparent 68%
|
||||
),
|
||||
oklch(0.78 0.12 74 / 0.035);
|
||||
}
|
||||
|
||||
.data-table-row-classified.is-classified {
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--classifier-rgb), calc(0.16 + var(--classifier-intensity) * 0.12));
|
||||
box-shadow: inset 0 0 0 1px
|
||||
rgba(var(--classifier-rgb), calc(0.16 + var(--classifier-intensity) * 0.12));
|
||||
}
|
||||
|
||||
.data-table-row-warn,
|
||||
|
|
@ -1202,32 +1596,62 @@ h3 {
|
|||
|
||||
.data-table-options .data-table-head,
|
||||
.data-table-options .data-table-row {
|
||||
grid-template-columns: minmax(72px, 0.8fr) minmax(50px, 0.55fr) minmax(64px, 0.7fr) minmax(58px, 0.6fr) minmax(34px, 0.35fr) minmax(62px, 0.65fr) minmax(104px, 1fr) minmax(54px, 0.55fr) minmax(66px, 0.7fr) minmax(48px, 0.5fr) minmax(42px, 0.45fr) minmax(92px, 0.9fr);
|
||||
grid-template-columns: minmax(72px, 0.8fr) minmax(50px, 0.55fr) minmax(
|
||||
64px,
|
||||
0.7fr
|
||||
) minmax(58px, 0.6fr) minmax(34px, 0.35fr) minmax(62px, 0.65fr) minmax(
|
||||
104px,
|
||||
1fr
|
||||
) minmax(54px, 0.55fr) minmax(66px, 0.7fr) minmax(48px, 0.5fr) minmax(
|
||||
42px,
|
||||
0.45fr
|
||||
) minmax(92px, 0.9fr);
|
||||
}
|
||||
|
||||
.data-table-equities .data-table-head,
|
||||
.data-table-equities .data-table-row {
|
||||
grid-template-columns: minmax(76px, 0.9fr) minmax(70px, 0.8fr) minmax(76px, 0.8fr) minmax(70px, 0.75fr) minmax(80px, 0.8fr) minmax(76px, 0.75fr);
|
||||
grid-template-columns: minmax(76px, 0.9fr) minmax(70px, 0.8fr) minmax(
|
||||
76px,
|
||||
0.8fr
|
||||
) minmax(70px, 0.75fr) minmax(80px, 0.8fr) minmax(76px, 0.75fr);
|
||||
}
|
||||
|
||||
.data-table-flow .data-table-head,
|
||||
.data-table-flow .data-table-row {
|
||||
grid-template-columns: minmax(148px, 1.1fr) minmax(180px, 1.4fr) minmax(62px, 0.45fr) minmax(70px, 0.5fr) minmax(88px, 0.7fr) minmax(74px, 0.55fr) minmax(132px, 1fr) minmax(110px, 0.8fr) minmax(210px, 1.6fr);
|
||||
grid-template-columns: minmax(148px, 1.1fr) minmax(180px, 1.4fr) minmax(
|
||||
62px,
|
||||
0.45fr
|
||||
) minmax(70px, 0.5fr) minmax(88px, 0.7fr) minmax(74px, 0.55fr) minmax(
|
||||
132px,
|
||||
1fr
|
||||
) minmax(110px, 0.8fr) minmax(210px, 1.6fr);
|
||||
}
|
||||
|
||||
.data-table-alerts .data-table-head,
|
||||
.data-table-alerts .data-table-row {
|
||||
grid-template-columns: minmax(76px, 0.75fr) minmax(170px, 1.4fr) minmax(52px, 0.45fr) minmax(58px, 0.45fr) minmax(52px, 0.4fr) minmax(66px, 0.55fr) minmax(260px, 2fr);
|
||||
grid-template-columns: minmax(76px, 0.75fr) minmax(170px, 1.4fr) minmax(
|
||||
52px,
|
||||
0.45fr
|
||||
) minmax(58px, 0.45fr) minmax(52px, 0.4fr) minmax(66px, 0.55fr) minmax(
|
||||
260px,
|
||||
2fr
|
||||
);
|
||||
}
|
||||
|
||||
.data-table-classifier .data-table-head,
|
||||
.data-table-classifier .data-table-row {
|
||||
grid-template-columns: minmax(76px, 0.75fr) minmax(180px, 1.45fr) minmax(70px, 0.6fr) minmax(74px, 0.65fr) minmax(300px, 2.2fr);
|
||||
grid-template-columns: minmax(76px, 0.75fr) minmax(180px, 1.45fr) minmax(
|
||||
70px,
|
||||
0.6fr
|
||||
) minmax(74px, 0.65fr) minmax(300px, 2.2fr);
|
||||
}
|
||||
|
||||
.data-table-dark .data-table-head,
|
||||
.data-table-dark .data-table-row {
|
||||
grid-template-columns: minmax(76px, 0.75fr) minmax(170px, 1.35fr) minmax(76px, 0.65fr) minmax(74px, 0.65fr) minmax(74px, 0.65fr) minmax(260px, 2fr);
|
||||
grid-template-columns: minmax(76px, 0.75fr) minmax(170px, 1.35fr) minmax(
|
||||
76px,
|
||||
0.65fr
|
||||
) minmax(74px, 0.65fr) minmax(74px, 0.65fr) minmax(260px, 2fr);
|
||||
}
|
||||
|
||||
.data-table-cell {
|
||||
|
|
@ -1259,7 +1683,16 @@ h3 {
|
|||
.options-table-head,
|
||||
.options-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 0.8fr) minmax(50px, 0.55fr) minmax(64px, 0.7fr) minmax(58px, 0.6fr) minmax(34px, 0.35fr) minmax(62px, 0.65fr) minmax(104px, 1fr) minmax(54px, 0.55fr) minmax(66px, 0.7fr) minmax(48px, 0.5fr) minmax(42px, 0.45fr) minmax(92px, 0.9fr);
|
||||
grid-template-columns: minmax(72px, 0.8fr) minmax(50px, 0.55fr) minmax(
|
||||
64px,
|
||||
0.7fr
|
||||
) minmax(58px, 0.6fr) minmax(34px, 0.35fr) minmax(62px, 0.65fr) minmax(
|
||||
104px,
|
||||
1fr
|
||||
) minmax(54px, 0.55fr) minmax(66px, 0.7fr) minmax(48px, 0.5fr) minmax(
|
||||
42px,
|
||||
0.45fr
|
||||
) minmax(92px, 0.9fr);
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
}
|
||||
|
|
@ -1290,7 +1723,14 @@ h3 {
|
|||
border: 0;
|
||||
border-bottom: 1px solid oklch(0.72 0.012 250 / 0.08);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.012 + var(--classifier-intensity, 0) * 0.06)), transparent 62%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(
|
||||
var(--classifier-rgb, 192, 200, 210),
|
||||
calc(0.012 + var(--classifier-intensity, 0) * 0.06)
|
||||
),
|
||||
transparent 62%
|
||||
),
|
||||
oklch(0.98 0.008 250 / 0.012);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
|
|
@ -1301,13 +1741,21 @@ h3 {
|
|||
.options-table-row:focus-visible {
|
||||
outline: none;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.02 + var(--classifier-intensity, 0) * 0.1)), transparent 68%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(
|
||||
var(--classifier-rgb, 192, 200, 210),
|
||||
calc(0.02 + var(--classifier-intensity, 0) * 0.1)
|
||||
),
|
||||
transparent 68%
|
||||
),
|
||||
oklch(0.78 0.12 74 / 0.03);
|
||||
}
|
||||
|
||||
.options-table-row.is-classified {
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--classifier-rgb), calc(0.16 + var(--classifier-intensity) * 0.12));
|
||||
box-shadow: inset 0 0 0 1px
|
||||
rgba(var(--classifier-rgb), calc(0.16 + var(--classifier-intensity) * 0.12));
|
||||
}
|
||||
|
||||
.options-table-row > span {
|
||||
|
|
@ -1322,17 +1770,39 @@ h3 {
|
|||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.classifier-green { --classifier-rgb: 37, 193, 122; }
|
||||
.classifier-red { --classifier-rgb: 255, 107, 95; }
|
||||
.classifier-amber { --classifier-rgb: 245, 166, 35; }
|
||||
.classifier-copper { --classifier-rgb: 198, 122, 75; }
|
||||
.classifier-blue { --classifier-rgb: 77, 163, 255; }
|
||||
.classifier-teal { --classifier-rgb: 64, 210, 190; }
|
||||
.classifier-yellowgreen { --classifier-rgb: 174, 210, 78; }
|
||||
.classifier-violet { --classifier-rgb: 170, 130, 255; }
|
||||
.classifier-cyan { --classifier-rgb: 94, 214, 255; }
|
||||
.classifier-magenta { --classifier-rgb: 255, 92, 205; }
|
||||
.classifier-neutral { --classifier-rgb: 192, 200, 210; }
|
||||
.classifier-green {
|
||||
--classifier-rgb: 37, 193, 122;
|
||||
}
|
||||
.classifier-red {
|
||||
--classifier-rgb: 255, 107, 95;
|
||||
}
|
||||
.classifier-amber {
|
||||
--classifier-rgb: 245, 166, 35;
|
||||
}
|
||||
.classifier-copper {
|
||||
--classifier-rgb: 198, 122, 75;
|
||||
}
|
||||
.classifier-blue {
|
||||
--classifier-rgb: 77, 163, 255;
|
||||
}
|
||||
.classifier-teal {
|
||||
--classifier-rgb: 64, 210, 190;
|
||||
}
|
||||
.classifier-yellowgreen {
|
||||
--classifier-rgb: 174, 210, 78;
|
||||
}
|
||||
.classifier-violet {
|
||||
--classifier-rgb: 170, 130, 255;
|
||||
}
|
||||
.classifier-cyan {
|
||||
--classifier-rgb: 94, 214, 255;
|
||||
}
|
||||
.classifier-magenta {
|
||||
--classifier-rgb: 255, 92, 205;
|
||||
}
|
||||
.classifier-neutral {
|
||||
--classifier-rgb: 192, 200, 210;
|
||||
}
|
||||
|
||||
.contract,
|
||||
.drawer-row-title {
|
||||
|
|
@ -1482,7 +1952,9 @@ h3 {
|
|||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
|
|
@ -1608,7 +2080,10 @@ h3 {
|
|||
color: var(--text-dim);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28);
|
||||
z-index: 45;
|
||||
transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.synthetic-control-gear:hover,
|
||||
|
|
@ -1774,7 +2249,9 @@ h3 {
|
|||
background: oklch(0.18 0.012 250 / 0.6);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
transition: border-color 150ms ease, background 150ms ease;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background 150ms ease;
|
||||
}
|
||||
|
||||
.news-row:hover {
|
||||
|
|
@ -1898,7 +2375,12 @@ h3 {
|
|||
width: 64%;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--bg-soft), rgba(245, 166, 35, 0.14), var(--bg-soft));
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-soft),
|
||||
rgba(245, 166, 35, 0.14),
|
||||
var(--bg-soft)
|
||||
);
|
||||
background-size: 180% 100%;
|
||||
animation: drawer-skeleton 1.2s ease-out infinite;
|
||||
}
|
||||
|
|
@ -2030,6 +2512,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 +2543,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;
|
||||
|
|
@ -2083,7 +2573,11 @@ h3 {
|
|||
|
||||
@media (max-width: 720px) {
|
||||
.terminal-shell {
|
||||
background-size: 24px 24px, 24px 24px, 100% 100%, auto;
|
||||
background-size:
|
||||
24px 24px,
|
||||
24px 24px,
|
||||
100% 100%,
|
||||
auto;
|
||||
}
|
||||
|
||||
.terminal-rail {
|
||||
|
|
@ -2142,7 +2636,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 +2683,8 @@ h3 {
|
|||
}
|
||||
|
||||
.terminal-topbar-actions,
|
||||
.terminal-topbar-controls {
|
||||
.terminal-topbar-controls,
|
||||
.terminal-topbar-summary {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
|
@ -2189,7 +2692,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 +2778,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.
|
||||
|
|
|
|||
3
bun.lock
3
bun.lock
|
|
@ -11,6 +11,9 @@
|
|||
"apps/desktop": {
|
||||
"name": "@islandflow/desktop",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@islandflow/types": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.8.1",
|
||||
"@electron-forge/core": "^7.11.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,612 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>
|
||||
2026-05-20 18:59 EDT · Clarify Desktop AI Settings Bridge State
|
||||
</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: oklch(0.11 0.01 250);
|
||||
--panel: oklch(0.16 0.013 250 / 0.92);
|
||||
--panel-2: oklch(0.14 0.012 250 / 0.9);
|
||||
--text: oklch(0.93 0.014 250);
|
||||
--muted: oklch(0.74 0.018 250);
|
||||
--faint: oklch(0.6 0.016 250);
|
||||
--line: oklch(0.75 0.014 250 / 0.14);
|
||||
--accent: oklch(0.79 0.12 74);
|
||||
--accent-soft: oklch(0.79 0.12 74 / 0.12);
|
||||
--warn: oklch(0.74 0.08 68);
|
||||
--warn-soft: oklch(0.2 0.03 68 / 0.28);
|
||||
--green-soft: oklch(0.75 0.12 151 / 0.12);
|
||||
--red-soft: oklch(0.72 0.14 28 / 0.12);
|
||||
--blue-soft: oklch(0.7 0.1 247 / 0.12);
|
||||
--shadow: 0 24px 80px oklch(0.02 0.01 250 / 0.45);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
oklch(0.8 0.12 74 / 0.09),
|
||||
transparent 28%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
oklch(0.15 0.012 250) 0%,
|
||||
oklch(0.1 0.01 250) 100%
|
||||
);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1160px, calc(100vw - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 36px 0 64px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
.eyebrow,
|
||||
.meta,
|
||||
code,
|
||||
pre {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p,
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
oklch(0.2 0.017 250 / 0.96),
|
||||
oklch(0.14 0.012 250 / 0.98)
|
||||
),
|
||||
var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 10px;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 3.2vw, 3.3rem);
|
||||
line-height: 0.96;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 72ch;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.hero-grid,
|
||||
.validation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stat,
|
||||
.validation-card {
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: oklch(0.12 0.01 250 / 0.5);
|
||||
}
|
||||
|
||||
.stat span,
|
||||
.meta-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.section-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--panel-2);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
section h2 {
|
||||
margin-bottom: 14px;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li + li,
|
||||
p + p,
|
||||
p + ul,
|
||||
ul + p,
|
||||
.callout + .callout,
|
||||
.diff-title + pre,
|
||||
pre + .diff-title {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.callout {
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.callout.warn {
|
||||
border-color: oklch(0.74 0.08 68 / 0.42);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
|
||||
.callout strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--faint);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.validation-card.good {
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.validation-card.warn {
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
pre.diff {
|
||||
padding: 16px 18px;
|
||||
overflow: auto;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: oklch(0.08 0.008 250 / 0.95);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.diff-title {
|
||||
color: var(--faint);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid oklch(0.8 0.12 74 / 0.28);
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hero-grid,
|
||||
.two-col,
|
||||
.validation-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(100vw - 24px, 1160px);
|
||||
padding-top: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<article class="hero">
|
||||
<div>
|
||||
<div class="eyebrow">Turn Document · 2026-05-20 18:59 EDT</div>
|
||||
<h1>Clarify Desktop AI Settings Bridge State</h1>
|
||||
<p class="hero-copy">
|
||||
The <code>/settings</code> desktop AI surface now explains why
|
||||
ChatGPT login and model controls are unavailable when a desktop
|
||||
window loses its native bridge, instead of looking like broken
|
||||
controls.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-grid">
|
||||
<div class="stat">
|
||||
<span>Issue</span>
|
||||
<strong>islandflow-dy2</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Primary Surface</span>
|
||||
<strong><code>apps/web/app/desktop-ai-panels.tsx</code></strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>User Outcome</span>
|
||||
<strong>Actionable bridge recovery state</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="section-grid">
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
<p>
|
||||
The settings page previously showed disabled ChatGPT login buttons
|
||||
and sparse model controls whenever the Electron shell was open but
|
||||
its native AI bridge was missing. That looked indistinguishable from
|
||||
broken UI. The fix makes the unavailable state explicit, swaps the
|
||||
dead login affordance for a recovery action, and gives the model
|
||||
area state-aware empty copy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="two-col">
|
||||
<div>
|
||||
<h2>Changes Made</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Added pure helper functions that centralize settings copy for
|
||||
desktop-only, bridge-missing, and logged-out model states.
|
||||
</li>
|
||||
<li>
|
||||
Changed the account panel so a missing bridge shows a prominent
|
||||
warning callout and a <code>Reload window</code> action instead
|
||||
of inert login buttons.
|
||||
</li>
|
||||
<li>
|
||||
Changed the profile slot badge so a selected managed login
|
||||
profile reports <code>Bridge unavailable</code> when the current
|
||||
desktop window cannot use it.
|
||||
</li>
|
||||
<li>
|
||||
Added state-specific labels and empty-state copy for the model
|
||||
selector and model list so they explain whether the app is
|
||||
waiting on desktop, bridge recovery, or account login.
|
||||
</li>
|
||||
<li>
|
||||
Added a warning callout style and unit coverage for the new
|
||||
copy-selection helpers.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Context</h2>
|
||||
<p>
|
||||
This work came directly from a user report against
|
||||
<code>/settings</code>: in a desktop session missing its native
|
||||
bridge, the login controls did not function and the model dropdown
|
||||
area appeared blank or broken.
|
||||
</p>
|
||||
<div class="callout warn">
|
||||
<strong>Why this mattered</strong>
|
||||
<p>
|
||||
The underlying bridge failure was already being detected. The
|
||||
real problem was that the UI expressed that failure as disabled
|
||||
controls without enough explanation, which created the
|
||||
impression that auth itself was broken.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="two-col">
|
||||
<div>
|
||||
<h2>Important Implementation Details</h2>
|
||||
<ul>
|
||||
<li>
|
||||
The fix stays in the web renderer layer and does not alter
|
||||
Electron auth or preference persistence behavior.
|
||||
</li>
|
||||
<li>
|
||||
<code>updatePreferences</code> still remains bridge-backed and
|
||||
login-independent, so the change focuses on clearer messaging
|
||||
rather than adding new backend gating.
|
||||
</li>
|
||||
<li>
|
||||
The account panel now derives a bridge notice from
|
||||
<code>shellAvailable</code> and <code>bridgeAvailable</code>,
|
||||
which keeps the browser-only and missing-bridge states distinct.
|
||||
</li>
|
||||
<li>
|
||||
The model area uses the same availability helper family, so the
|
||||
disabled select label and the list empty-state copy stay
|
||||
aligned.
|
||||
</li>
|
||||
<li>
|
||||
The UI now prefers truthful status language over optimistic
|
||||
language. A selected profile is no longer presented as fully
|
||||
usable when the active window cannot reach the native bridge.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Expected Impact for End-Users</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Users on a broken bridge session immediately see that the
|
||||
problem is window connectivity, not a silent ChatGPT login
|
||||
failure.
|
||||
</li>
|
||||
<li>
|
||||
Users get a local recovery path from the settings page through
|
||||
the new <code>Reload window</code> action.
|
||||
</li>
|
||||
<li>
|
||||
Model controls no longer appear mysteriously empty. They explain
|
||||
whether the app is waiting for bridge recovery or for account
|
||||
login.
|
||||
</li>
|
||||
<li>
|
||||
The managed profile card now reflects actual usability in the
|
||||
current window, which reduces false confidence.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Relevant Diff Snippets</h2>
|
||||
<p class="meta">
|
||||
These snippets are formatted as unified patches so they can be
|
||||
consumed by Diffs’
|
||||
<code>parsePatchFiles</code> or <code>PatchDiff</code> flows from
|
||||
<a href="https://diffs.com/docs">diffs.com/docs</a>.
|
||||
</p>
|
||||
|
||||
<div class="diff-title">
|
||||
Settings state helpers and bridge recovery action
|
||||
</div>
|
||||
<pre
|
||||
class="diff"
|
||||
><code>diff --git a/apps/web/app/desktop-ai-panels.tsx b/apps/web/app/desktop-ai-panels.tsx
|
||||
@@
|
||||
+export const getDesktopAiSettingsBridgeNotice = (shellAvailable, bridgeAvailable) => {
|
||||
+ if (!shellAvailable) {
|
||||
+ return {
|
||||
+ title: "Desktop app required",
|
||||
+ body: "Open Islandflow Desktop to connect ChatGPT, load managed models, and use native Copilot controls."
|
||||
+ };
|
||||
+ }
|
||||
+ if (!bridgeAvailable) {
|
||||
+ return {
|
||||
+ title: "Bridge unavailable in this window",
|
||||
+ body: "This Islandflow Desktop window is missing its native AI bridge, so login actions and model controls stay disabled until the bridge reconnects. Reload the window or restart Islandflow if this keeps happening."
|
||||
+ };
|
||||
+ }
|
||||
+ return null;
|
||||
+};
|
||||
@@
|
||||
- <button className="terminal-button terminal-button-primary">Browser login</button>
|
||||
- <button className="terminal-button">Device code</button>
|
||||
+ <button
|
||||
+ className="terminal-button terminal-button-primary"
|
||||
+ type="button"
|
||||
+ onClick={() => window.location.reload()}
|
||||
+ >
|
||||
+ Reload window
|
||||
+ </button>
|
||||
@@
|
||||
+ {bridgeNotice ? (
|
||||
+ <div className="copilot-callout copilot-callout-warning">
|
||||
+ <strong>{bridgeNotice.title}</strong>
|
||||
+ <p className="copilot-note">{bridgeNotice.body}</p>
|
||||
+ </div>
|
||||
+ ) : null}</code></pre>
|
||||
|
||||
<div class="diff-title">Model controls copy and empty states</div>
|
||||
<pre
|
||||
class="diff"
|
||||
><code>diff --git a/apps/web/app/desktop-ai-panels.tsx b/apps/web/app/desktop-ai-panels.tsx
|
||||
@@
|
||||
+const modelSelectLabel = getDesktopAiModelSelectLabel(
|
||||
+ shellAvailable,
|
||||
+ bridgeAvailable,
|
||||
+ state.account.loggedIn,
|
||||
+ state.models.length
|
||||
+);
|
||||
+const modelListEmptyCopy = getDesktopAiModelListEmptyCopy(
|
||||
+ shellAvailable,
|
||||
+ bridgeAvailable,
|
||||
+ state.account.loggedIn
|
||||
+);
|
||||
@@
|
||||
- <option value="">Use server default</option>
|
||||
+ <option value="">{modelSelectLabel}</option>
|
||||
@@
|
||||
- {state.models.map((model) => (
|
||||
- <div className="copilot-model-row" key={model.id}>...</div>
|
||||
- ))}
|
||||
+ {state.models.length === 0 ? (
|
||||
+ <p className="copilot-empty">{modelListEmptyCopy}</p>
|
||||
+ ) : (
|
||||
+ state.models.map((model) => (
|
||||
+ <div className="copilot-model-row" key={model.id}>...</div>
|
||||
+ ))
|
||||
+ )}</code></pre>
|
||||
|
||||
<div class="diff-title">Unit coverage and warning presentation</div>
|
||||
<pre
|
||||
class="diff"
|
||||
><code>diff --git a/apps/web/app/desktop-ai.test.ts b/apps/web/app/desktop-ai.test.ts
|
||||
@@
|
||||
+describe("desktop ai settings copy", () => {
|
||||
+ it("explains when the native bridge is missing from the desktop window", () => {
|
||||
+ expect(getDesktopAiSettingsBridgeNotice(true, false)?.title).toBe(
|
||||
+ "Bridge unavailable in this window"
|
||||
+ );
|
||||
+ });
|
||||
+
|
||||
+ it("keeps the model selector explicit while the bridge is disconnected", () => {
|
||||
+ expect(getDesktopAiModelSelectLabel(true, false, false, 0)).toBe("Bridge unavailable");
|
||||
+ expect(getDesktopAiModelListEmptyCopy(true, false, false)).toContain(
|
||||
+ "native AI bridge reconnects"
|
||||
+ );
|
||||
+ });
|
||||
+});
|
||||
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
|
||||
@@
|
||||
+.copilot-callout-warning {
|
||||
+ border-color: oklch(0.74 0.08 68 / 0.42);
|
||||
+ background: oklch(0.2 0.03 68 / 0.28);
|
||||
+}</code></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Validation</h2>
|
||||
<div class="validation-grid">
|
||||
<div class="validation-card good">
|
||||
<span class="meta-label">Automated</span>
|
||||
<strong
|
||||
><code>bun test apps/web/app/desktop-ai.test.ts</code></strong
|
||||
>
|
||||
<p>
|
||||
Passed with 14 assertions groups green, including new coverage
|
||||
for bridge-state copy helpers.
|
||||
</p>
|
||||
</div>
|
||||
<div class="validation-card good">
|
||||
<span class="meta-label">Manual</span>
|
||||
<strong>Electron app state inspection</strong>
|
||||
<p>
|
||||
Verified the live <code>127.0.0.1:3000/settings</code> window
|
||||
now shows <code>Reload window</code>,
|
||||
<code>Bridge unavailable</code>, and the new model-controls
|
||||
explanatory copy.
|
||||
</p>
|
||||
</div>
|
||||
<div class="validation-card warn">
|
||||
<span class="meta-label">Build</span>
|
||||
<strong><code>bun --cwd=apps/web run build</code></strong>
|
||||
<p>
|
||||
Still fails on an existing repo-wide TypeScript import-extension
|
||||
problem in <code>packages/types/src/desktop-ai.ts</code>,
|
||||
unrelated to this change.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="two-col">
|
||||
<div>
|
||||
<h2>Issues, Limitations, and Mitigations</h2>
|
||||
<ul>
|
||||
<li>
|
||||
The fix does not repair the missing native bridge itself. It
|
||||
makes that failure mode explicit and recoverable from the page.
|
||||
</li>
|
||||
<li>
|
||||
<code>Reload window</code> is a best-effort recovery action. If
|
||||
the bridge is absent because of a deeper shell startup issue,
|
||||
the user may still need to restart Islandflow.
|
||||
</li>
|
||||
<li>
|
||||
The production web build could not serve as a final validation
|
||||
gate because of the pre-existing <code>.ts</code>-extension
|
||||
import issue already tracked elsewhere in Beads.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Follow-up Work</h2>
|
||||
<ul>
|
||||
<li>
|
||||
No new follow-up issue was required for this UX patch. The
|
||||
reported confusion is addressed in <code>islandflow-dy2</code>.
|
||||
</li>
|
||||
<li>
|
||||
The repo-wide Next.js build blocker remains separate work,
|
||||
already represented by <code>islandflow-c8f</code>.
|
||||
</li>
|
||||
<li>
|
||||
If bridge loss keeps happening in practice, the next useful step
|
||||
would be adding an in-app bridge diagnostics surface instead of
|
||||
relying on copy and window reload alone.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Changes at a Glance</h2>
|
||||
<div class="chip-row">
|
||||
<span class="chip">settings UX hardening</span>
|
||||
<span class="chip">desktop bridge state</span>
|
||||
<span class="chip">managed auth recovery</span>
|
||||
<span class="chip">explicit model empty states</span>
|
||||
<span class="chip">unit test coverage</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
538
docs/turns/2026-05-20-codex-desktop-login-and-copilot.html
Normal file
538
docs/turns/2026-05-20-codex-desktop-login-and-copilot.html
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>2026-05-20 · Codex Desktop Login And Copilot</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: oklch(0.11 0.01 250);
|
||||
--panel: oklch(0.16 0.013 250 / 0.92);
|
||||
--panel-2: oklch(0.14 0.012 250 / 0.9);
|
||||
--text: oklch(0.93 0.014 250);
|
||||
--muted: oklch(0.74 0.018 250);
|
||||
--faint: oklch(0.6 0.016 250);
|
||||
--line: oklch(0.75 0.014 250 / 0.14);
|
||||
--accent: oklch(0.79 0.12 74);
|
||||
--accent-soft: oklch(0.79 0.12 74 / 0.12);
|
||||
--green: oklch(0.75 0.12 151);
|
||||
--green-soft: oklch(0.75 0.12 151 / 0.12);
|
||||
--red: oklch(0.72 0.14 28);
|
||||
--red-soft: oklch(0.72 0.14 28 / 0.12);
|
||||
--blue-soft: oklch(0.7 0.1 247 / 0.12);
|
||||
--shadow: 0 24px 80px oklch(0.02 0.01 250 / 0.45);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, oklch(0.8 0.12 74 / 0.09), transparent 28%),
|
||||
linear-gradient(180deg, oklch(0.15 0.012 250) 0%, oklch(0.1 0.01 250) 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1160px, calc(100vw - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 36px 0 64px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
.eyebrow,
|
||||
.meta,
|
||||
code,
|
||||
pre {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(135deg, oklch(0.2 0.017 250 / 0.96), oklch(0.14 0.012 250 / 0.98)),
|
||||
var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 3.4vw, 3.6rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 72ch;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: oklch(0.12 0.01 250 / 0.5);
|
||||
}
|
||||
|
||||
.stat span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.section-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--panel-2);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
section h2 {
|
||||
margin-bottom: 14px;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.callout {
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.callout strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--faint);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.validation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.validation-card {
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: oklch(0.11 0.01 250 / 0.62);
|
||||
}
|
||||
|
||||
.validation-card.good {
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.validation-card.warn {
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
pre.diff {
|
||||
margin: 0;
|
||||
padding: 16px 18px;
|
||||
overflow: auto;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: oklch(0.08 0.008 250 / 0.95);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.diff-title {
|
||||
margin-bottom: 10px;
|
||||
color: var(--faint);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.diff-note {
|
||||
margin-top: 10px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid oklch(0.8 0.12 74 / 0.28);
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hero-grid,
|
||||
.two-col,
|
||||
.validation-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(100vw - 24px, 1160px);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
section,
|
||||
.hero {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<article class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Turn Document · Repository Implementation</p>
|
||||
<h1>Codex Desktop Login And Analyst Copilot</h1>
|
||||
<p class="hero-copy">
|
||||
Islandflow Desktop now boots an official <code>codex app-server</code> bridge, lets each desktop user log
|
||||
into a ChatGPT-backed Codex account, exposes a narrow Electron IPC surface to the renderer, and adds an AI
|
||||
settings and copilot experience without turning AI into the live first-pass classifier.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-grid">
|
||||
<div class="stat">
|
||||
<span>Scope</span>
|
||||
<strong>Electron bridge, auth, usage telemetry, renderer UI, tests</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Beads</span>
|
||||
<strong><code>islandflow-6tn</code></strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Validation</span>
|
||||
<strong>Desktop tests, web tests, shared-type check, desktop typecheck, production web build</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="section-grid">
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
<p>
|
||||
This turn added a desktop-only Codex capability that respects the repo plan: managed ChatGPT login comes
|
||||
first, the existing deterministic smart-money classifier remains the source of truth, and AI is used as a
|
||||
structured analyst copilot on top of existing Islandflow artifacts.
|
||||
</p>
|
||||
<div class="chip-row">
|
||||
<span class="chip">Managed ChatGPT browser login</span>
|
||||
<span class="chip">Device-code fallback</span>
|
||||
<span class="chip">Electron preload + IPC bridge</span>
|
||||
<span class="chip">Usage and rate-limit dashboard</span>
|
||||
<span class="chip">Smart-money and replay copilot actions</span>
|
||||
<span class="chip">Browser-safe degradation</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Changes Made</h2>
|
||||
<ul>
|
||||
<li>Added a native desktop AI service in <code>apps/desktop/src/desktop-ai.ts</code> that starts <code>codex app-server</code>, handles account state, usage notifications, rate limits, task execution, and preference persistence.</li>
|
||||
<li>Added preload and IPC plumbing in <code>apps/desktop/src/preload.ts</code>, <code>apps/desktop/src/desktop-ai-ipc.ts</code>, and <code>apps/desktop/src/main.ts</code> with trusted-origin enforcement.</li>
|
||||
<li>Introduced shared AI contracts in <code>packages/types/src/desktop-ai.ts</code> for auth state, model controls, token usage, rate limits, task payloads, and compiled screen responses.</li>
|
||||
<li>Added a renderer-side desktop AI provider in <code>apps/web/app/desktop-ai.tsx</code> and richer UI surfaces in <code>apps/web/app/desktop-ai-panels.tsx</code>.</li>
|
||||
<li>Enabled previously latent routes for <code>/signals</code>, <code>/charts</code>, and <code>/replay</code>, plus a new <code>/settings</code> route.</li>
|
||||
<li>Extended the terminal shell and styles so users can reach AI Settings, compile natural-language screens on Tape, run replay postmortems, and investigate smart-money events inline.</li>
|
||||
<li>Added desktop tests for env scrubbing, token usage accounting, rate-limit snapshots, and logout state reset, plus updated web route and navigation tests.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Context</h2>
|
||||
<p>
|
||||
The desktop app was previously a secure Electron wrapper around the web terminal. That meant there was no
|
||||
authenticated native bridge, no preload API for AI state, and no desktop-only place to manage account
|
||||
status, model controls, or token telemetry. The goal of this turn was to add those capabilities without
|
||||
weakening the shell security model and without letting AI replace the deterministic classification pipeline.
|
||||
</p>
|
||||
<div class="callout">
|
||||
<strong>Deliberate product boundary:</strong>
|
||||
<p>
|
||||
The Copilot works only from structured Islandflow payloads such as <code>SmartMoneyEvent</code>,
|
||||
<code>ClassifierHitEvent</code>, <code>FlowPacket</code>, and current replay slices. It does not become a
|
||||
freeform live classifier in v1.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="two-col">
|
||||
<div>
|
||||
<h2>Important Implementation Details</h2>
|
||||
<ul>
|
||||
<li>The child <code>codex app-server</code> environment now explicitly clears <code>OPENAI_API_KEY</code> and <code>CODEX_API_KEY</code> unless the selected profile mode is API-key-based, which prevents accidental auth-mode drift during ChatGPT subscription sessions.</li>
|
||||
<li>All desktop AI access goes through a narrow preload bridge instead of exposing Node or Electron primitives to the renderer.</li>
|
||||
<li>IPC handlers validate the sender URL with the existing trusted-origin policy before serving account or task requests.</li>
|
||||
<li>Usage is persisted by <code>threadId</code> and <code>turnId</code>, then rolled up into today and lifetime dashboards using exact token notifications from the app-server.</li>
|
||||
<li>Normalized cost is clearly labeled as an API-price estimate, not literal ChatGPT subscription billing.</li>
|
||||
<li>The screen compiler returns a structured filter payload plus rationale and unhandled clauses, then lets the user apply the compiled filters instead of silently mutating the terminal state.</li>
|
||||
<li>The browser build stays safe by exposing an unavailable state through the provider and disabling desktop-only actions outside Electron.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Expected Impact for End-Users</h2>
|
||||
<ul>
|
||||
<li>Desktop users can log into their own ChatGPT-backed Codex account from Islandflow Settings without sharing a subscription across users.</li>
|
||||
<li>Users can see plan type, model defaults, reasoning controls, rate-limit windows, recent AI turns, and token usage from one place.</li>
|
||||
<li>Selected smart-money events now have one-click explain, counter-thesis, burst summary, and watchlist synthesis actions directly inside the investigation drawer.</li>
|
||||
<li>Replay sessions can produce a structured postmortem from the exact slice currently on screen.</li>
|
||||
<li>Tape users can write a natural-language screen and translate it into the app’s existing filter model where possible.</li>
|
||||
<li>Web-only sessions degrade cleanly instead of exposing broken or misleading AI controls.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Relevant Diff Snippets</h2>
|
||||
<p class="meta">
|
||||
These snippets are formatted as unified patch strings so they can be consumed by Diffs’
|
||||
<code>parsePatchFiles</code> or <code>PatchDiff</code> flow from the official docs:
|
||||
<a href="https://diffs.com/docs">https://diffs.com/docs</a>.
|
||||
</p>
|
||||
|
||||
<div class="diff-title">Electron main process: preload, desktop AI service, and guarded IPC</div>
|
||||
<pre class="diff"><code>diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
|
||||
@@
|
||||
-import { app, BrowserWindow, shell } from "electron";
|
||||
+import { app, BrowserWindow, ipcMain, shell } from "electron";
|
||||
+import type { Event as ElectronEvent, IpcMainInvokeEvent } from "electron";
|
||||
+import { fileURLToPath } from "node:url";
|
||||
+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 PRELOAD_PATH = fileURLToPath(new URL("./preload.js", import.meta.url));
|
||||
@@
|
||||
+const registerDesktopAiIpc = (service: IslandflowDesktopAiService): void => {
|
||||
+ const guard = (event: IpcMainInvokeEvent): void => {
|
||||
+ const senderUrl = event.senderFrame?.url || event.sender.getURL();
|
||||
+ if (!isTrustedAppUrl(senderUrl)) {
|
||||
+ throw new Error(`Rejected desktop AI IPC from untrusted origin: ${senderUrl || "unknown"}`);
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
+ ipcMain.handle(DESKTOP_AI_GET_STATE, async (event) => {
|
||||
+ guard(event);
|
||||
+ await service.start();
|
||||
+ return service.getState();
|
||||
+ });
|
||||
+ // login, logout, preference, and task handlers follow the same guard
|
||||
+};</code></pre>
|
||||
|
||||
<div class="diff-title" style="margin-top: 18px">Renderer shell: settings route, topbar entrypoint, and in-context copilot surfaces</div>
|
||||
<pre class="diff"><code>diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx
|
||||
@@
|
||||
+import { useDesktopAi } from "./desktop-ai";
|
||||
+import {
|
||||
+ DesktopAiSettingsRoute,
|
||||
+ ReplayCopilotPanel,
|
||||
+ ScreenCompilerPanel,
|
||||
+ SmartMoneyCopilotPanel
|
||||
+} from "./desktop-ai-panels";
|
||||
@@
|
||||
+export const NAV_ITEMS = [
|
||||
+ { href: "/", label: "Home" },
|
||||
+ { href: "/tape", label: "Tape" },
|
||||
+ { href: "/signals", label: "Signals" },
|
||||
+ { href: "/charts", label: "Charts" },
|
||||
+ { href: "/replay", label: "Replay" },
|
||||
+ { href: "/settings", label: "Settings" }
|
||||
+];
|
||||
@@
|
||||
+<ScreenCompilerPanel currentFilters={state.flowFilters} onApplyFilters={state.setFlowFilters} />
|
||||
@@
|
||||
+<ReplayCopilotPanel
|
||||
+ ticker={replayContext.ticker}
|
||||
+ flowFilters={replayContext.flowFilters}
|
||||
+ alerts={replayContext.alerts}
|
||||
+ smartMoneyEvents={replayContext.smartMoneyEvents}
|
||||
+ classifierHits={replayContext.classifierHits}
|
||||
+ flowPackets={replayContext.flowPackets}
|
||||
+ optionPrints={replayContext.optionPrints}
|
||||
+/>
|
||||
@@
|
||||
+<SmartMoneyCopilotPanel
|
||||
+ event={event}
|
||||
+ flowPacket={flowPacket}
|
||||
+ evidencePrints={evidencePrints.map((item) => item.print)}
|
||||
+ relatedPackets={relatedPackets}
|
||||
+/></code></pre>
|
||||
|
||||
<div class="diff-title" style="margin-top: 18px">Shared desktop AI contract: auth state, rate limits, usage, and structured tasks</div>
|
||||
<pre class="diff"><code>diff --git a/packages/types/src/desktop-ai.ts b/packages/types/src/desktop-ai.ts
|
||||
+export const IslandflowAiTaskRequestSchema = z.discriminatedUnion("kind", [
|
||||
+ z.object({ kind: z.literal("smart-money-explain"), context: IslandflowAiSmartMoneyContextSchema }),
|
||||
+ z.object({ kind: z.literal("smart-money-skeptic"), context: IslandflowAiSmartMoneyContextSchema }),
|
||||
+ z.object({ kind: z.literal("smart-money-burst-summary"), context: IslandflowAiSmartMoneyContextSchema }),
|
||||
+ z.object({ kind: z.literal("watchlist-synthesis"), context: IslandflowAiSmartMoneyContextSchema }),
|
||||
+ z.object({ kind: z.literal("replay-postmortem"), context: IslandflowAiReplayContextSchema }),
|
||||
+ z.object({ kind: z.literal("screen-compile"), context: IslandflowAiScreenCompileContextSchema })
|
||||
+]);
|
||||
+
|
||||
+export type IslandflowAiState = {
|
||||
+ desktopAvailable: boolean;
|
||||
+ transportStatus: IslandflowAiTransportStatus;
|
||||
+ transportError: string | null;
|
||||
+ profiles: IslandflowAiProfileSlot[];
|
||||
+ account: IslandflowAiAccountState;
|
||||
+ preferences: IslandflowAiPreferences;
|
||||
+ models: IslandflowAiModelSummary[];
|
||||
+ rateLimitsByLimitId: Record<string, IslandflowAiRateLimitSnapshot>;
|
||||
+ usage: IslandflowAiUsageDashboard;
|
||||
+ tasks: IslandflowAiTaskSnapshot[];
|
||||
+ updatedAt: number;
|
||||
+};</code></pre>
|
||||
|
||||
<p class="diff-note">
|
||||
The document does not embed the Diffs runtime directly, but the snippets above are already prepared in the
|
||||
patch-string format that Diffs documents for <code>PatchDiff</code>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Validation</h2>
|
||||
<div class="validation-grid">
|
||||
<div class="validation-card good">
|
||||
<h3>Desktop typecheck</h3>
|
||||
<p><code>bun --cwd=apps/desktop run typecheck</code></p>
|
||||
</div>
|
||||
<div class="validation-card good">
|
||||
<h3>Desktop tests</h3>
|
||||
<p><code>bun --cwd=apps/desktop run test</code></p>
|
||||
</div>
|
||||
<div class="validation-card good">
|
||||
<h3>Shared types</h3>
|
||||
<p><code>bun x tsc -p packages/types/tsconfig.json --noEmit</code></p>
|
||||
</div>
|
||||
<div class="validation-card good">
|
||||
<h3>Web tests</h3>
|
||||
<p><code>bun test apps/web/app/terminal.test.ts apps/web/app/routes.test.ts</code></p>
|
||||
</div>
|
||||
<div class="validation-card good">
|
||||
<h3>Web production build</h3>
|
||||
<p><code>bun --cwd=apps/web run build</code></p>
|
||||
</div>
|
||||
<div class="validation-card warn">
|
||||
<h3>Manual desktop runtime</h3>
|
||||
<p>No end-to-end interactive Electron sign-in was executed in this turn. The bridge, auth flows, and renderer integration were validated through type checks, unit tests, and the production web build.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Issues, Limitations, and Mitigations</h2>
|
||||
<ul>
|
||||
<li>Workspace provider and API-key profile support are intentionally left as reserved slots behind the same abstraction, not shipped as active shared-subscription behavior.</li>
|
||||
<li>The desktop bridge launches an ephemeral Codex thread per analysis task, which is safer for v1 but means there is not yet a long-lived conversational analyst thread.</li>
|
||||
<li>The screen compiler only applies filters that map onto today’s <code>OptionFlowFilters</code> model, and it explicitly returns unhandled clauses rather than pretending to support unsupported logic.</li>
|
||||
<li>The recent task and usage dashboards depend on app-server notifications. When those notifications do not fire, the UI stays safe and honest rather than synthesizing made-up counters.</li>
|
||||
<li>Renderer interactions were validated in build and unit test contexts, but not with a live packaged desktop binary in this turn.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Follow-up Work</h2>
|
||||
<ul>
|
||||
<li>Add an automated Electron integration test harness that exercises browser login completion, device-code completion, logout, and recovery after app-server restart.</li>
|
||||
<li>Promote the reserved workspace-provider slot into a real enterprise or API-key-backed profile once the product decision is ready.</li>
|
||||
<li>Persist richer per-task provenance so replay postmortems and smart-money analyses can be reopened with their original structured context, not only their output text.</li>
|
||||
<li>Consider a dedicated Copilot activity log or side rail once users accumulate enough analyses that the compact recent-task list becomes too shallow.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
463
docs/turns/2026-05-20-fix-desktop-copilot-shell-detection.html
Normal file
463
docs/turns/2026-05-20-fix-desktop-copilot-shell-detection.html
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>2026-05-20 · Fix Desktop Copilot Shell Detection</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: oklch(0.11 0.01 250);
|
||||
--panel: oklch(0.16 0.013 250 / 0.92);
|
||||
--panel-2: oklch(0.14 0.012 250 / 0.9);
|
||||
--text: oklch(0.93 0.014 250);
|
||||
--muted: oklch(0.74 0.018 250);
|
||||
--faint: oklch(0.6 0.016 250);
|
||||
--line: oklch(0.75 0.014 250 / 0.14);
|
||||
--accent: oklch(0.79 0.12 74);
|
||||
--accent-soft: oklch(0.79 0.12 74 / 0.12);
|
||||
--green-soft: oklch(0.75 0.12 151 / 0.12);
|
||||
--red-soft: oklch(0.72 0.14 28 / 0.12);
|
||||
--blue-soft: oklch(0.7 0.1 247 / 0.12);
|
||||
--shadow: 0 24px 80px oklch(0.02 0.01 250 / 0.45);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, oklch(0.8 0.12 74 / 0.09), transparent 28%),
|
||||
linear-gradient(180deg, oklch(0.15 0.012 250) 0%, oklch(0.1 0.01 250) 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1160px, calc(100vw - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 36px 0 64px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
.eyebrow,
|
||||
.meta,
|
||||
code,
|
||||
pre {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
main > * + * {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(135deg, oklch(0.2 0.017 250 / 0.96), oklch(0.14 0.012 250 / 0.98)),
|
||||
var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 3.4vw, 3.6rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 72ch;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.hero-grid,
|
||||
.section-grid,
|
||||
.validation-grid,
|
||||
.two-col {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.two-col {
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
|
||||
}
|
||||
|
||||
.stat,
|
||||
.validation-card,
|
||||
.callout {
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: oklch(0.12 0.01 250 / 0.5);
|
||||
}
|
||||
|
||||
.stat span,
|
||||
.validation-card span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.stat strong,
|
||||
.validation-card strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.validation-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.validation-card.good {
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.validation-card.warn {
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.callout {
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--panel-2);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
section h2 {
|
||||
margin-bottom: 14px;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid oklch(0.8 0.12 74 / 0.28);
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--faint);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
pre.diff {
|
||||
margin: 0;
|
||||
padding: 16px 18px;
|
||||
overflow: auto;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: oklch(0.08 0.008 250 / 0.95);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.diff-title {
|
||||
margin-bottom: 10px;
|
||||
color: var(--faint);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
main {
|
||||
width: min(100vw - 24px, 1160px);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.hero-grid,
|
||||
.two-col,
|
||||
.validation-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Turn Record · 2026-05-20 18:34 EDT</p>
|
||||
<h1>Fix Desktop Copilot Shell Detection</h1>
|
||||
<p class="hero-copy">
|
||||
This turn fixed the case where Islandflow could be running inside the Electron desktop shell but still show
|
||||
the browser-only Copilot fallback. The renderer now distinguishes between “not in desktop”, “desktop shell
|
||||
detected but native bridge missing”, and “desktop bridge present but initial state failed”.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-grid">
|
||||
<div class="stat">
|
||||
<span>Main Issue</span>
|
||||
<strong><code>islandflow-199</code></strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Files Changed</span>
|
||||
<strong>3</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Validation</span>
|
||||
<strong>Targeted tests passed, web build blocked by existing repo issue</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
<p>
|
||||
The Copilot settings surface no longer assumes that any failure to load desktop AI state means the app is
|
||||
running in a browser. Instead, it recovers late bridge injection for a short window, exposes better shell-vs-
|
||||
bridge state to the UI, and gives the user actionable recovery copy when the native bridge is missing.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Changes Made</h2>
|
||||
<ul>
|
||||
<li>Added desktop runtime helpers in <code>apps/web/app/desktop-ai.tsx</code> to detect Electron shell presence separately from bridge presence.</li>
|
||||
<li>Changed the desktop AI provider to poll briefly for late bridge availability instead of giving up after the first missing-read.</li>
|
||||
<li>Reworked the provider’s unavailable/error state so desktop sessions show bridge-focused recovery guidance instead of browser-only copy.</li>
|
||||
<li>Updated settings and Copilot action panels in <code>apps/web/app/desktop-ai-panels.tsx</code> to gate actions on bridge availability while only showing the browser fallback when the shell is genuinely absent.</li>
|
||||
<li>Added regression coverage in <code>apps/web/app/desktop-ai.test.ts</code> for Electron detection, bridge-missing fallback state, and action helper copy.</li>
|
||||
<li>Filed follow-up issue <code>islandflow-c8f</code> for the unrelated shared-types import-path problem currently blocking <code>bun --cwd=apps/web run build</code>.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Context</h2>
|
||||
<div class="two-col">
|
||||
<div>
|
||||
<p>
|
||||
The reported symptom was a Settings screen that still rendered “Browser-only fallback” even though the
|
||||
user was visibly inside the Islandflow desktop app. The pre-fix renderer had a single blunt interpretation:
|
||||
if the preload bridge was not immediately readable, the state fell back to the same model used for a real
|
||||
browser session.
|
||||
</p>
|
||||
<p>
|
||||
That collapsed three different states into one misleading message:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Regular browser session, where desktop AI truly is unavailable.</li>
|
||||
<li>Electron shell present, but the bridge appeared slightly later than the first effect pass.</li>
|
||||
<li>Electron shell and bridge both present, but the initial <code>getState()</code> call failed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<aside class="callout">
|
||||
<strong>Why this matters</strong>
|
||||
<p>
|
||||
A desktop shell claiming “open the desktop app” is not just awkward copy. It also disables the login
|
||||
actions that might still be recoverable, which turns a bridge problem into a dead-end user experience.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Important Implementation Details</h2>
|
||||
<ul>
|
||||
<li><strong>Shell detection:</strong> the renderer now treats an Electron user-agent or a working preload bridge as evidence that the desktop shell is present.</li>
|
||||
<li><strong>Bridge recovery:</strong> the provider polls for up to 20 attempts at 250ms intervals so late preload exposure does not immediately collapse to browser fallback.</li>
|
||||
<li><strong>Better state semantics:</strong> unavailable-state generation now accepts runtime context and can mark <code>desktopAvailable</code> true while still reporting a bridge error.</li>
|
||||
<li><strong>UI gating:</strong> Settings and Copilot actions key off <code>bridgeAvailable</code> for actionable controls, while the “Desktop required” banner keys off <code>shellAvailable</code>.</li>
|
||||
<li><strong>Recovery copy:</strong> action helper text now distinguishes between “open the desktop app”, “reload or restart because the native bridge is missing”, and “connect a ChatGPT or Codex account”.</li>
|
||||
</ul>
|
||||
<div class="chip-row" style="margin-top: 14px;">
|
||||
<span class="chip">Electron shell detection</span>
|
||||
<span class="chip">Late bridge retry</span>
|
||||
<span class="chip">Accurate fallback messaging</span>
|
||||
<span class="chip">Regression tests</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Relevant Diff Snippets</h2>
|
||||
<p class="meta">
|
||||
Diff snippets are rendered as plain patch strings compatible with the format documented by
|
||||
<a href="https://diffs.com/docs">diffs.com</a>.
|
||||
</p>
|
||||
<div class="section-grid">
|
||||
<div>
|
||||
<div class="diff-title">Renderer runtime detection and bridge polling</div>
|
||||
<pre class="diff"><code>diff --git a/apps/web/app/desktop-ai.tsx b/apps/web/app/desktop-ai.tsx
|
||||
@@
|
||||
+const BRIDGE_POLL_INTERVAL_MS = 250;
|
||||
+const BRIDGE_POLL_MAX_ATTEMPTS = 20;
|
||||
+const ELECTRON_USER_AGENT_PATTERN = /\bElectron\/\S+/i;
|
||||
+
|
||||
+export const detectDesktopShell = (userAgent: string | null | undefined): boolean =>
|
||||
+ Boolean(userAgent && ELECTRON_USER_AGENT_PATTERN.test(userAgent));
|
||||
+
|
||||
+export const resolveDesktopAiRuntime = (...) => {
|
||||
+ const bridge = value?.islandflowDesktop?.ai ? value.islandflowDesktop : null;
|
||||
+ const bridgeAvailable = Boolean(bridge?.ai);
|
||||
+ const shellAvailable = bridgeAvailable || detectDesktopShell(value?.navigator?.userAgent);
|
||||
+ return { shellAvailable, bridgeAvailable, bridge };
|
||||
+};
|
||||
@@
|
||||
+ if (!syncRuntime()) {
|
||||
+ const pollForBridge = () => {
|
||||
+ attempts += 1;
|
||||
+ if (syncRuntime() || attempts >= BRIDGE_POLL_MAX_ATTEMPTS) {
|
||||
+ return;
|
||||
+ }
|
||||
+ pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
|
||||
+ };
|
||||
+ pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
|
||||
+ }</code></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="diff-title">Settings and task panels stop calling the desktop shell a browser</div>
|
||||
<pre class="diff"><code>diff --git a/apps/web/app/desktop-ai-panels.tsx b/apps/web/app/desktop-ai-panels.tsx
|
||||
@@
|
||||
- const actionsDisabled = busyAction !== null || !state.desktopAvailable;
|
||||
+ const actionsDisabled = busyAction !== null || !bridgeAvailable;
|
||||
@@
|
||||
- {!state.desktopAvailable ? (
|
||||
+ {!shellAvailable ? (
|
||||
<CopilotPane title="Desktop required" eyebrow="Browser-only fallback" wide>
|
||||
@@
|
||||
-const requireDesktopActionCopy = (desktopAvailable: boolean, loggedIn: boolean): string => {
|
||||
- if (!desktopAvailable) {
|
||||
+export const requireDesktopActionCopy = (shellAvailable: boolean, bridgeAvailable: boolean, loggedIn: boolean): string => {
|
||||
+ if (!shellAvailable) {
|
||||
return "This control is desktop-only. Open Islandflow Desktop to run Copilot tasks.";
|
||||
}
|
||||
+ if (!bridgeAvailable) {
|
||||
+ return "Islandflow Desktop is open, but this window is missing the native AI bridge. Reload the window or restart the app.";
|
||||
+ }</code></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="diff-title">Regression coverage for shell-vs-bridge behavior</div>
|
||||
<pre class="diff"><code>diff --git a/apps/web/app/desktop-ai.test.ts b/apps/web/app/desktop-ai.test.ts
|
||||
new file mode 100644
|
||||
@@
|
||||
+it("recognizes Electron user agents before the bridge is available", () => {
|
||||
+ const runtime = resolveDesktopAiRuntime({ navigator: { userAgent: "... Electron/39.0.0 ..." } });
|
||||
+ expect(runtime.shellAvailable).toBe(true);
|
||||
+ expect(runtime.bridgeAvailable).toBe(false);
|
||||
+});
|
||||
+
|
||||
+it("reports desktop-shell bridge failures without pretending the app is a browser", () => {
|
||||
+ const state = createUnavailableState({ shellAvailable: true });
|
||||
+ expect(state.desktopAvailable).toBe(true);
|
||||
+ expect(state.transportError).toContain("native AI bridge");
|
||||
+});</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Expected Impact for End-Users</h2>
|
||||
<ul>
|
||||
<li>Desktop users no longer get told to “open Islandflow Desktop” when they are already in it.</li>
|
||||
<li>If the preload bridge is delayed, the renderer now has a short recovery window before giving up.</li>
|
||||
<li>If the bridge is missing or broken, the UI explains that exact condition and suggests a sensible recovery path.</li>
|
||||
<li>Copilot action text now distinguishes between missing desktop shell, missing bridge, and missing account login.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Validation</h2>
|
||||
<div class="validation-grid">
|
||||
<div class="validation-card good">
|
||||
<span>Passed</span>
|
||||
<strong><code>bun test apps/web/app/desktop-ai.test.ts apps/web/app/routes.test.ts apps/web/app/terminal.test.ts</code></strong>
|
||||
<p>All targeted renderer tests passed, including the new desktop shell and bridge regression coverage.</p>
|
||||
</div>
|
||||
<div class="validation-card warn">
|
||||
<span>Blocked By Existing Repo Issue</span>
|
||||
<strong><code>bun --cwd=apps/web run build</code></strong>
|
||||
<p>
|
||||
The build still fails during shared package type-checking because <code>packages/types/src/desktop-ai.ts</code>
|
||||
imports sibling files with explicit <code>.ts</code> extensions. Follow-up issue: <code>islandflow-c8f</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Issues, Limitations, and Mitigations</h2>
|
||||
<ul>
|
||||
<li>The provider’s retry window is intentionally short. It helps with delayed bridge injection, but it does not try to hide a truly broken preload indefinitely.</li>
|
||||
<li>The first client paint can still momentarily start from the generic unavailable state before the effect resolves runtime details. The mitigation is that the resolved UI now lands on an accurate desktop-shell state instead of a misleading browser fallback.</li>
|
||||
<li>The production web build remains red for an unrelated shared-types import-path issue. That blocker is explicitly tracked in <code>islandflow-c8f</code> so it does not disappear into session history.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Follow-up Work</h2>
|
||||
<ul>
|
||||
<li><code>islandflow-c8f</code>: remove or normalize explicit <code>.ts</code> import specifiers in <code>packages/types</code> so the Next.js production build can complete.</li>
|
||||
<li>Optionally add desktop-shell telemetry to the topbar summary so bridge-missing sessions are visible outside Settings too.</li>
|
||||
<li>If bridge timing remains flaky in practice, consider emitting a preload-ready event instead of relying only on a short polling window.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
148
docs/turns/2026-05-20-fix-desktop-types-esm-imports.html
Normal file
148
docs/turns/2026-05-20-fix-desktop-types-esm-imports.html
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Turn Doc - Fix desktop types ESM imports</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f7f8fc;
|
||||
--panel: #ffffff;
|
||||
--ink: #1c2333;
|
||||
--muted: #5b6781;
|
||||
--accent: #2556d8;
|
||||
--line: #dbe2f1;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at top left, #eef2ff 0%, var(--bg) 45%);
|
||||
color: var(--ink);
|
||||
line-height: 1.55;
|
||||
}
|
||||
main {
|
||||
max-width: 920px;
|
||||
margin: 32px auto;
|
||||
padding: 0 20px 48px;
|
||||
}
|
||||
article {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 14px 36px rgba(28, 35, 51, 0.08);
|
||||
}
|
||||
h1 { margin: 0 0 8px; font-size: 1.6rem; }
|
||||
.meta { color: var(--muted); margin-bottom: 18px; }
|
||||
h2 {
|
||||
margin-top: 26px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.15rem;
|
||||
color: #172952;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
p, li { margin: 0 0 10px; }
|
||||
ul { margin: 0; padding-left: 20px; }
|
||||
code {
|
||||
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: #edf2ff;
|
||||
padding: 1px 5px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
pre {
|
||||
margin: 10px 0 14px;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d6def3;
|
||||
background: #0f1525;
|
||||
color: #d9e2ff;
|
||||
overflow-x: auto;
|
||||
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.callout {
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #eef3ff;
|
||||
color: #22335f;
|
||||
margin: 10px 0;
|
||||
}
|
||||
a { color: #1747be; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<article>
|
||||
<h1>Desktop ESM Import Resolution Fix</h1>
|
||||
<p class="meta">Created: 2026-05-20 18:30 EDT · Branch: <code>lavender/codex-login-management-electron</code></p>
|
||||
|
||||
<h2>Summary</h2>
|
||||
<p>Fixed a desktop startup crash where Electron failed to load <code>@islandflow/types</code> with <code>ERR_MODULE_NOT_FOUND</code> for <code>packages/types/src/events</code>. The types package now uses explicit <code>.ts</code> relative imports, and desktop TypeScript build settings were updated to permit and rewrite those imports during emit.</p>
|
||||
|
||||
<h2>Changes Made</h2>
|
||||
<ul>
|
||||
<li>Updated all internal re-exports/imports in <code>packages/types/src</code> that pointed to sibling modules without file extensions to use explicit <code>.ts</code> extensions.</li>
|
||||
<li>Updated desktop TS compiler options in <code>apps/desktop/tsconfig.json</code>:
|
||||
<code>allowImportingTsExtensions: true</code> and <code>rewriteRelativeImportExtensions: true</code>.</li>
|
||||
<li>Created and tracked the bug in Beads as <code>islandflow-64s</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Context</h2>
|
||||
<p>The desktop app launches via Electron (Node ESM behavior), while much of this monorepo is typically run through Bun. Bun accepts extensionless TypeScript relative imports that Node ESM does not. Since <code>@islandflow/types</code> exports source TS (<code>./src/index.ts</code>), Electron loaded those sources directly and failed on extensionless sibling imports.</p>
|
||||
|
||||
<h2>Important Implementation Details</h2>
|
||||
<ul>
|
||||
<li>The fix stays within current package structure; no new build pipeline was introduced for <code>@islandflow/types</code>.</li>
|
||||
<li>By enabling <code>rewriteRelativeImportExtensions</code>, desktop emit remains compatible even while accepting <code>.ts</code> specifiers during type-checking.</li>
|
||||
<li>This avoids adding runtime Node flags and keeps behavior explicit in source and compiler config.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Relevant Diff Snippets</h2>
|
||||
<p>Unified patch snippets below are diffs.com-compatible patch text format.</p>
|
||||
<pre><code>diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
|
||||
-export * from "./events";
|
||||
+export * from "./events.ts";
|
||||
-export * from "./live";
|
||||
+export * from "./live.ts";
|
||||
|
||||
...
|
||||
|
||||
diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json
|
||||
"noEmit": false,
|
||||
+"allowImportingTsExtensions": true,
|
||||
+"rewriteRelativeImportExtensions": true,
|
||||
"sourceMap": true,</code></pre>
|
||||
|
||||
<h2>Expected Impact for End-Users</h2>
|
||||
<p>Desktop developers can run <code>bun run dev:desktop</code> without the startup crash. This unblocks local desktop testing and feature work without requiring manual runtime flags or ad hoc environment tweaks.</p>
|
||||
|
||||
<h2>Validation</h2>
|
||||
<ul>
|
||||
<li>Ran <code>bun --cwd=apps/desktop run build</code> successfully.</li>
|
||||
<li>Ran <code>node -e "import('./packages/types/src/index.ts')"</code> to confirm direct source import resolution works after the import updates.</li>
|
||||
<li>Ran <code>bun run dev:desktop</code> and confirmed Electron launched successfully past the previous module-resolution failure (manually interrupted afterward).</li>
|
||||
</ul>
|
||||
|
||||
<h2>Issues, Limitations, and Mitigations</h2>
|
||||
<ul>
|
||||
<li>This fix assumes Node/Electron TypeScript type-stripping support remains available for direct TS source loading.</li>
|
||||
<li>The plugin catalog 403 warnings observed during launch are unrelated to this module-resolution fix and remain unchanged.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Follow-up Work</h2>
|
||||
<ul>
|
||||
<li>Evaluate whether <code>@islandflow/types</code> should eventually ship a dedicated built JS output for stricter runtime portability beyond Bun/Electron workflows.</li>
|
||||
<li>Add a lightweight desktop smoke check in CI for startup-path regressions around workspace package resolution.</li>
|
||||
</ul>
|
||||
|
||||
<p class="callout">Beads issue: <code>islandflow-64s</code>.</p>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
310
docs/turns/2026-05-20-fix-electron-preload-bridge.html
Normal file
310
docs/turns/2026-05-20-fix-electron-preload-bridge.html
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>2026-05-20 · Fix Electron Preload Bridge</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: oklch(0.11 0.01 250);
|
||||
--panel: oklch(0.16 0.013 250 / 0.92);
|
||||
--panel-2: oklch(0.14 0.012 250 / 0.9);
|
||||
--text: oklch(0.93 0.014 250);
|
||||
--muted: oklch(0.74 0.018 250);
|
||||
--faint: oklch(0.6 0.016 250);
|
||||
--line: oklch(0.75 0.014 250 / 0.14);
|
||||
--accent: oklch(0.79 0.12 74);
|
||||
--accent-soft: oklch(0.79 0.12 74 / 0.12);
|
||||
--green-soft: oklch(0.75 0.12 151 / 0.12);
|
||||
--red-soft: oklch(0.72 0.14 28 / 0.12);
|
||||
--shadow: 0 24px 80px oklch(0.02 0.01 250 / 0.45);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, oklch(0.8 0.12 74 / 0.09), transparent 28%),
|
||||
linear-gradient(180deg, oklch(0.15 0.012 250) 0%, oklch(0.1 0.01 250) 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
main {
|
||||
width: min(1160px, calc(100vw - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 36px 0 64px;
|
||||
}
|
||||
h1, h2, h3, .eyebrow, .meta, code, pre {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
}
|
||||
h1, h2, h3 { margin: 0; }
|
||||
p, li { color: var(--muted); line-height: 1.7; }
|
||||
a { color: inherit; }
|
||||
main > * + * { margin-top: 22px; }
|
||||
.hero, section {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 28px;
|
||||
background:
|
||||
linear-gradient(135deg, oklch(0.2 0.017 250 / 0.96), oklch(0.14 0.012 250 / 0.98)),
|
||||
var(--panel);
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 3.4vw, 3.5rem);
|
||||
line-height: 0.96;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hero-copy { max-width: 72ch; margin: 14px 0 0; }
|
||||
.hero-grid, .two-col, .validation-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.hero-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.two-col { grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr); }
|
||||
.validation-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.stat, .validation-card, .callout {
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: oklch(0.12 0.01 250 / 0.5);
|
||||
}
|
||||
.stat span, .validation-card span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.validation-card.good { background: var(--green-soft); }
|
||||
.validation-card.warn { background: var(--red-soft); }
|
||||
.callout { background: var(--accent-soft); }
|
||||
section {
|
||||
padding: 24px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
section h2 {
|
||||
margin-bottom: 14px;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
ul { margin: 0; padding-left: 20px; }
|
||||
li + li { margin-top: 10px; }
|
||||
.chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid oklch(0.8 0.12 74 / 0.28);
|
||||
background: var(--accent-soft);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
.meta { color: var(--faint); font-size: 0.82rem; }
|
||||
pre.diff {
|
||||
margin: 0;
|
||||
padding: 16px 18px;
|
||||
overflow: auto;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: oklch(0.08 0.008 250 / 0.95);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.diff-title {
|
||||
margin-bottom: 10px;
|
||||
color: var(--faint);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
main {
|
||||
width: min(100vw - 24px, 1160px);
|
||||
padding-top: 18px;
|
||||
}
|
||||
.hero-grid, .two-col, .validation-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<article class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Repository Turn Document</p>
|
||||
<h1>Fix Electron Preload Bridge</h1>
|
||||
<p class="hero-copy">
|
||||
Repaired the desktop ChatGPT login bridge by making the Electron preload
|
||||
script emit a classic runnable script instead of ESM. Local desktop
|
||||
settings now reconnect to the native bridge and show the managed
|
||||
account, transport readiness, and model controls again.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-grid">
|
||||
<div class="stat">
|
||||
<span>Area</span>
|
||||
<strong>Electron desktop shell</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Issue</span>
|
||||
<strong>`islandflow-hj3`</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Validated</span>
|
||||
<strong>Build, tests, live desktop restart</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
<p>
|
||||
The desktop Copilot settings page was not failing because login was broken.
|
||||
Electron was failing before the bridge could even load: the preload bundle
|
||||
contained runtime `import` syntax, so the renderer never got
|
||||
`window.islandflowDesktop`. After switching the preload implementation to
|
||||
CommonJS-compatible runtime code, the local Electron shell resumed exposing
|
||||
the bridge and the settings page recovered immediately after restart.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Changes Made</h2>
|
||||
<ul>
|
||||
<li>Rewrote the desktop preload runtime imports to use `require("electron")`.</li>
|
||||
<li>Inlined the IPC channel names inside the preload file so no runtime module imports remain.</li>
|
||||
<li>Removed the type-only module usage that caused TypeScript to emit a trailing `export {}`.</li>
|
||||
<li>Verified the generated `apps/desktop/dist/preload.js` is now a plain script.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Context</h2>
|
||||
<p>
|
||||
The UI state on `localhost:3000/settings` was already designed to distinguish
|
||||
between “not in desktop” and “desktop shell detected but native bridge
|
||||
missing.” In this case the latter message was accurate but incomplete: Electron
|
||||
was present, yet the preload never executed because the emitted file was not
|
||||
valid for the preload runtime. That left the renderer with an Electron user
|
||||
agent but no bridge object.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="two-col">
|
||||
<div>
|
||||
<h2>Important Implementation Details</h2>
|
||||
<ul>
|
||||
<li>Electron logged `Unable to load preload script` followed by `Cannot use import statement outside a module` during local startup.</li>
|
||||
<li>The desktop main process configuration was already pointing at the correct preload path, so the failure was in emitted file format, not window wiring.</li>
|
||||
<li>Keeping the preload free of runtime imports ensures Electron can execute it even though the desktop workspace is otherwise ESM-oriented.</li>
|
||||
<li>The bridge API shape exposed to the web app did not change, so no renderer updates were required.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<h2>Relevant Runtime Signal</h2>
|
||||
<p class="meta">Observed in Electron startup logs during reproduction</p>
|
||||
<p>
|
||||
`Unable to load preload script: .../dist/preload.js` and
|
||||
`SyntaxError: Cannot use import statement outside a module`
|
||||
were the decisive clues that isolated the problem to preload emission.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Relevant Diff Snippets</h2>
|
||||
<div class="diff-title">apps/desktop/src/preload.ts</div>
|
||||
<pre class="diff"><code class="language-diff">--- a/apps/desktop/src/preload.ts
|
||||
+++ b/apps/desktop/src/preload.ts
|
||||
@@
|
||||
-import { contextBridge, ipcRenderer } from "electron";
|
||||
-import { ... } from "./desktop-ai-ipc.js";
|
||||
+const { contextBridge, ipcRenderer } = require("electron");
|
||||
+const DESKTOP_AI_STATE_CHANNEL = "islandflow:desktop-ai:state";
|
||||
+const DESKTOP_AI_GET_STATE = "islandflow:desktop-ai:get-state";
|
||||
+const DESKTOP_AI_LOGIN_BROWSER = "islandflow:desktop-ai:login-browser";
|
||||
+const DESKTOP_AI_LOGIN_DEVICE = "islandflow:desktop-ai:login-device";
|
||||
+const DESKTOP_AI_CANCEL_LOGIN = "islandflow:desktop-ai:cancel-login";
|
||||
+const DESKTOP_AI_LOGOUT = "islandflow:desktop-ai:logout";
|
||||
+const DESKTOP_AI_UPDATE_PREFERENCES = "islandflow:desktop-ai:update-preferences";
|
||||
+const DESKTOP_AI_RUN_TASK = "islandflow:desktop-ai:run-task";
|
||||
@@
|
||||
- updatePreferences: (
|
||||
- next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
|
||||
- ): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_UPDATE_PREFERENCES, next),
|
||||
+ updatePreferences: (next: DesktopAiPreferenceUpdate): Promise<void> =>
|
||||
+ ipcRenderer.invoke(DESKTOP_AI_UPDATE_PREFERENCES, next),
|
||||
</code></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Expected Impact for End-Users</h2>
|
||||
<p>
|
||||
Local Islandflow Desktop sessions no longer get stuck in the misleading
|
||||
“Bridge unavailable in this window” state when the actual problem is preload
|
||||
execution. Users should see their managed ChatGPT session reconnect, transport
|
||||
status move to <strong>Ready</strong>, and model controls become interactive
|
||||
after launching or restarting the desktop shell.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Validation</h2>
|
||||
<div class="validation-grid">
|
||||
<div class="validation-card good">
|
||||
<span>Desktop Build</span>
|
||||
<strong>`bun --cwd=apps/desktop run build` succeeded and emitted a plain preload script.</strong>
|
||||
</div>
|
||||
<div class="validation-card good">
|
||||
<span>Desktop Tests</span>
|
||||
<strong>`bun --cwd=apps/desktop test` passed.</strong>
|
||||
</div>
|
||||
<div class="validation-card good">
|
||||
<span>Web Tests</span>
|
||||
<strong>`bun test apps/web/app/desktop-ai.test.ts` passed.</strong>
|
||||
</div>
|
||||
<div class="validation-card good">
|
||||
<span>Manual Verification</span>
|
||||
<strong>Restarted `bun run dev:desktop` and confirmed settings showed the connected account, ready transport, and populated model controls.</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Issues, Limitations, and Mitigations</h2>
|
||||
<ul>
|
||||
<li>The preload now duplicates the IPC channel strings instead of importing them. That is intentional to keep the emitted file import-free.</li>
|
||||
<li>The desktop startup logs still show unrelated remote plugin 403 warnings from the managed app-server environment. They did not block bridge recovery in this turn.</li>
|
||||
<li>The preload uses loose local types to avoid turning the file back into a runtime module. If preload complexity grows, a dedicated CommonJS-safe shared typing strategy may be worth adding later.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Follow-up Work</h2>
|
||||
<div class="chip-row">
|
||||
<span class="chip">No immediate follow-up required</span>
|
||||
<span class="chip">Possible hardening: build-time preload output guard</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
234
docs/turns/2026-05-21-fix-types-imports-next-build.html
Normal file
234
docs/turns/2026-05-21-fix-types-imports-next-build.html
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Fix packages/types imports for Next build</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b1020;
|
||||
--panel: #131b34;
|
||||
--panel-2: #1c2748;
|
||||
--text: #eef3ff;
|
||||
--muted: #b8c4e6;
|
||||
--line: #31416e;
|
||||
--accent: #9fb7ff;
|
||||
--accent-2: #7ee0c3;
|
||||
--code: #0a1227;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", Inter, system-ui, sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(159, 183, 255, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, #0a0f1e 0%, var(--bg) 100%);
|
||||
color: var(--text);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(980px, calc(100% - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 40px 0 64px;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h1, h2, code, pre {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 70ch;
|
||||
color: var(--muted);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(19, 27, 52, 0.75);
|
||||
color: var(--accent-2);
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: 16px;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(19, 27, 52, 0.96), rgba(28, 39, 72, 0.9));
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.98rem;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0.1rem 0.32rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(10, 18, 39, 0.95);
|
||||
border: 1px solid rgba(91, 109, 163, 0.7);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 12px 0 0;
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
background: var(--code);
|
||||
border: 1px solid rgba(91, 109, 163, 0.7);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #c6d4ff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div class="meta">Turn Document · 2026-05-21 · islandflow-c8f</div>
|
||||
<h1>Fix shared type imports for Next build</h1>
|
||||
<p class="lede">
|
||||
Resolved the web production build failure on the Electron/Codex branch by normalizing
|
||||
sibling imports in <code>packages/types/src</code>. The fix keeps the change surface
|
||||
narrow: only TypeScript module specifiers changed, with no runtime contract updates.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
<p>
|
||||
The shared type package was exporting and importing local files with explicit
|
||||
<code>.ts</code> suffixes. Next 16 type-checking rejected that layout during
|
||||
<code>bun --cwd=apps/web run build</code>. This turn removed those suffixes across the
|
||||
affected files and verified that the web production build now completes successfully.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Changes Made</h2>
|
||||
<ul>
|
||||
<li>Updated <code>packages/types/src/index.ts</code> to re-export sibling modules without explicit <code>.ts</code> suffixes.</li>
|
||||
<li>Updated sibling imports in <code>desktop-ai.ts</code>, <code>events.ts</code>, <code>live.ts</code>, <code>options-flow.ts</code>, and <code>synthetic-market.ts</code>.</li>
|
||||
<li>Left all schemas, exported names, and runtime behavior unchanged.</li>
|
||||
<li>Added this turn document under <code>docs/turns/</code>.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Context</h2>
|
||||
<p>
|
||||
Recent Electron/Codex work introduced a new <code>packages/types/src/desktop-ai.ts</code>
|
||||
surface and switched the package to source-based workspace exports. That was compatible
|
||||
with the desktop runtime path, but the web production build still runs under Next's
|
||||
stricter TypeScript pipeline, which flagged the explicit <code>.ts</code> sibling imports.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Important Implementation Details</h2>
|
||||
<ul>
|
||||
<li>The fix intentionally stops at module specifiers; it does not alter package exports, tsconfig flags, or build tooling.</li>
|
||||
<li>This keeps the desktop branch behavior stable while making the source package consumable by Next's type-checking rules.</li>
|
||||
<li>The check for remaining explicit local <code>.ts</code> imports in <code>packages/types/src</code> now returns no matches.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Relevant Diff Snippets</h2>
|
||||
<p>
|
||||
Snippets below use unified diff formatting compatible with tools documented by
|
||||
<a href="https://diffs.com/docs">diffs.com</a>.
|
||||
</p>
|
||||
<pre><code class="language-diff">diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
|
||||
-export * from "./events.ts";
|
||||
-export * from "./live.ts";
|
||||
-export * from "./options-flow.ts";
|
||||
+export * from "./events";
|
||||
+export * from "./live";
|
||||
+export * from "./options-flow";</code></pre>
|
||||
<pre><code class="language-diff">diff --git a/packages/types/src/desktop-ai.ts b/packages/types/src/desktop-ai.ts
|
||||
-} from "./events.ts";
|
||||
-import { OptionFlowFiltersSchema } from "./options-flow.ts";
|
||||
+} from "./events";
|
||||
+import { OptionFlowFiltersSchema } from "./options-flow";</code></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Expected Impact for End-Users</h2>
|
||||
<p>
|
||||
The Electron/Codex branch can now produce a successful web build again, which unblocks
|
||||
shipping and testing of the desktop AI settings and Copilot surfaces without a separate
|
||||
manual tsconfig workaround.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Validation</h2>
|
||||
<ul>
|
||||
<li><code>rg -n "from "\\./.*\\.ts"|from '\\./.*\\.ts'|export \\* from "\\./.*\\.ts"|export \\* from '\\./.*\\.ts'" packages/types/src</code> returned no matches after the patch.</li>
|
||||
<li><code>bun test packages/types/tests</code> passed: 12 tests, 0 failures.</li>
|
||||
<li><code>bun install</code> completed successfully in this worktree so the web build could run.</li>
|
||||
<li><code>bun --cwd=apps/web run build</code> completed successfully on Next 16.2.6.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Issues, Limitations, and Mitigations</h2>
|
||||
<ul>
|
||||
<li>This fix is scoped to the current source import strategy. If the package later moves to compiled output exports, the import policy should be revisited deliberately.</li>
|
||||
<li>The original failure was branch-specific, so validation was performed on <code>lavender/codex-login-management-electron</code> rather than <code>main</code>.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Follow-up Work</h2>
|
||||
<ul>
|
||||
<li>Consider adding a lightweight lint or script check that rejects new explicit sibling <code>.ts</code> imports in <code>packages/types/src</code>.</li>
|
||||
<li>No additional product follow-up is required for <code>islandflow-c8f</code> once this branch is reviewed and merged.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
303
packages/types/src/desktop-ai.ts
Normal file
303
packages/types/src/desktop-ai.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
AlertEventSchema,
|
||||
ClassifierHitEventSchema,
|
||||
FlowPacketSchema,
|
||||
OptionPrintSchema,
|
||||
SmartMoneyEventSchema
|
||||
} from "./events";
|
||||
import { OptionFlowFiltersSchema } from "./options-flow";
|
||||
|
||||
export const IslandflowAiReasoningEffortSchema = z.enum([
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]);
|
||||
|
||||
export type IslandflowAiReasoningEffort = z.infer<typeof IslandflowAiReasoningEffortSchema>;
|
||||
|
||||
export const IslandflowAiPlanTypeSchema = z.enum([
|
||||
"free",
|
||||
"go",
|
||||
"plus",
|
||||
"pro",
|
||||
"prolite",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
]);
|
||||
|
||||
export type IslandflowAiPlanType = z.infer<typeof IslandflowAiPlanTypeSchema>;
|
||||
|
||||
export const IslandflowAiAuthModeSchema = z.enum([
|
||||
"apikey",
|
||||
"chatgpt",
|
||||
"chatgptAuthTokens",
|
||||
"agentIdentity"
|
||||
]);
|
||||
|
||||
export type IslandflowAiAuthMode = z.infer<typeof IslandflowAiAuthModeSchema>;
|
||||
|
||||
export const IslandflowAiProfileModeSchema = z.enum([
|
||||
"managed-chatgpt",
|
||||
"api-key",
|
||||
"workspace-provider"
|
||||
]);
|
||||
|
||||
export type IslandflowAiProfileMode = z.infer<typeof IslandflowAiProfileModeSchema>;
|
||||
|
||||
export const IslandflowAiTransportStatusSchema = z.enum([
|
||||
"starting",
|
||||
"ready",
|
||||
"error",
|
||||
"stopped",
|
||||
"restarting"
|
||||
]);
|
||||
|
||||
export type IslandflowAiTransportStatus = z.infer<typeof IslandflowAiTransportStatusSchema>;
|
||||
|
||||
export const IslandflowAiTaskKindSchema = z.enum([
|
||||
"smart-money-explain",
|
||||
"smart-money-skeptic",
|
||||
"smart-money-burst-summary",
|
||||
"watchlist-synthesis",
|
||||
"replay-postmortem",
|
||||
"screen-compile"
|
||||
]);
|
||||
|
||||
export type IslandflowAiTaskKind = z.infer<typeof IslandflowAiTaskKindSchema>;
|
||||
|
||||
export const IslandflowAiTaskStatusSchema = z.enum([
|
||||
"queued",
|
||||
"running",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled"
|
||||
]);
|
||||
|
||||
export type IslandflowAiTaskStatus = z.infer<typeof IslandflowAiTaskStatusSchema>;
|
||||
|
||||
export const IslandflowAiTokenBreakdownSchema = z.object({
|
||||
totalTokens: z.number().int().nonnegative(),
|
||||
inputTokens: z.number().int().nonnegative(),
|
||||
cachedInputTokens: z.number().int().nonnegative(),
|
||||
outputTokens: z.number().int().nonnegative(),
|
||||
reasoningOutputTokens: z.number().int().nonnegative()
|
||||
});
|
||||
|
||||
export type IslandflowAiTokenBreakdown = z.infer<typeof IslandflowAiTokenBreakdownSchema>;
|
||||
|
||||
export const IslandflowAiPricingSchema = z.object({
|
||||
inputUsdPer1MTokens: z.number().nonnegative(),
|
||||
cachedInputUsdPer1MTokens: z.number().nonnegative(),
|
||||
outputUsdPer1MTokens: z.number().nonnegative(),
|
||||
sourceLabel: z.string().min(1),
|
||||
sourceUrl: z.string().url()
|
||||
});
|
||||
|
||||
export type IslandflowAiPricing = z.infer<typeof IslandflowAiPricingSchema>;
|
||||
|
||||
export const IslandflowAiModelSummarySchema = z.object({
|
||||
id: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
isDefault: z.boolean(),
|
||||
supportedReasoningEfforts: z.array(IslandflowAiReasoningEffortSchema),
|
||||
defaultReasoningEffort: IslandflowAiReasoningEffortSchema.nullable(),
|
||||
pricing: IslandflowAiPricingSchema.nullable()
|
||||
});
|
||||
|
||||
export type IslandflowAiModelSummary = z.infer<typeof IslandflowAiModelSummarySchema>;
|
||||
|
||||
export const IslandflowAiRateLimitWindowSchema = z.object({
|
||||
usedPercent: z.number().min(0).max(100),
|
||||
windowDurationMins: z.number().int().positive().nullable(),
|
||||
resetsAt: z.number().int().nullable()
|
||||
});
|
||||
|
||||
export type IslandflowAiRateLimitWindow = z.infer<typeof IslandflowAiRateLimitWindowSchema>;
|
||||
|
||||
export const IslandflowAiRateLimitSnapshotSchema = z.object({
|
||||
limitId: z.string().nullable(),
|
||||
limitName: z.string().nullable(),
|
||||
primary: IslandflowAiRateLimitWindowSchema.nullable(),
|
||||
secondary: IslandflowAiRateLimitWindowSchema.nullable(),
|
||||
planType: IslandflowAiPlanTypeSchema.nullable(),
|
||||
reachedType: z.string().nullable(),
|
||||
hasCredits: z.boolean().nullable(),
|
||||
unlimitedCredits: z.boolean().nullable(),
|
||||
creditsBalance: z.string().nullable()
|
||||
});
|
||||
|
||||
export type IslandflowAiRateLimitSnapshot = z.infer<typeof IslandflowAiRateLimitSnapshotSchema>;
|
||||
|
||||
export const IslandflowAiSmartMoneyContextSchema = z.object({
|
||||
event: SmartMoneyEventSchema,
|
||||
flowPacket: FlowPacketSchema.nullable(),
|
||||
evidencePrints: z.array(OptionPrintSchema),
|
||||
relatedPackets: z.array(FlowPacketSchema).default([])
|
||||
});
|
||||
|
||||
export type IslandflowAiSmartMoneyContext = z.infer<typeof IslandflowAiSmartMoneyContextSchema>;
|
||||
|
||||
export const IslandflowAiReplayContextSchema = z.object({
|
||||
ticker: z.string().min(1).nullable(),
|
||||
flowFilters: OptionFlowFiltersSchema,
|
||||
alerts: z.array(AlertEventSchema),
|
||||
smartMoneyEvents: z.array(SmartMoneyEventSchema),
|
||||
classifierHits: z.array(ClassifierHitEventSchema),
|
||||
flowPackets: z.array(FlowPacketSchema),
|
||||
optionPrints: z.array(OptionPrintSchema)
|
||||
});
|
||||
|
||||
export type IslandflowAiReplayContext = z.infer<typeof IslandflowAiReplayContextSchema>;
|
||||
|
||||
export const IslandflowAiScreenCompileContextSchema = z.object({
|
||||
prompt: z.string().min(1).max(4_000),
|
||||
currentFilters: OptionFlowFiltersSchema
|
||||
});
|
||||
|
||||
export type IslandflowAiScreenCompileContext = z.infer<typeof IslandflowAiScreenCompileContextSchema>;
|
||||
|
||||
export const IslandflowAiTaskRequestSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("smart-money-explain"),
|
||||
context: IslandflowAiSmartMoneyContextSchema
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("smart-money-skeptic"),
|
||||
context: IslandflowAiSmartMoneyContextSchema
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("smart-money-burst-summary"),
|
||||
context: IslandflowAiSmartMoneyContextSchema
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("watchlist-synthesis"),
|
||||
context: IslandflowAiSmartMoneyContextSchema
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replay-postmortem"),
|
||||
context: IslandflowAiReplayContextSchema
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("screen-compile"),
|
||||
context: IslandflowAiScreenCompileContextSchema
|
||||
})
|
||||
]);
|
||||
|
||||
export type IslandflowAiTaskRequest = z.infer<typeof IslandflowAiTaskRequestSchema>;
|
||||
|
||||
export const IslandflowAiCompiledScreenSchema = z.object({
|
||||
compiledFilters: OptionFlowFiltersSchema.nullable(),
|
||||
rationale: z.string().min(1),
|
||||
unhandledClauses: z.array(z.string()),
|
||||
sanitizedPrompt: z.string().min(1)
|
||||
});
|
||||
|
||||
export type IslandflowAiCompiledScreen = z.infer<typeof IslandflowAiCompiledScreenSchema>;
|
||||
|
||||
export type IslandflowAiProfileSlot = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
mode: IslandflowAiProfileMode;
|
||||
enabled: boolean;
|
||||
selected: boolean;
|
||||
statusLabel: string;
|
||||
};
|
||||
|
||||
export type IslandflowAiLoginState =
|
||||
| { status: "idle"; message: string | null }
|
||||
| { status: "browser_pending"; message: string | null; loginId: string; authUrl: string }
|
||||
| {
|
||||
status: "device_code_pending";
|
||||
message: string | null;
|
||||
loginId: string;
|
||||
verificationUrl: string;
|
||||
userCode: string;
|
||||
}
|
||||
| { status: "error"; message: string; loginId: string | null };
|
||||
|
||||
export type IslandflowAiPreferences = {
|
||||
model: string | null;
|
||||
reasoningEffort: IslandflowAiReasoningEffort | null;
|
||||
};
|
||||
|
||||
export type IslandflowAiUsageTurnRecord = {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
taskId: string | null;
|
||||
taskKind: IslandflowAiTaskKind | null;
|
||||
taskTitle: string | null;
|
||||
dayKey: string;
|
||||
profileId: string;
|
||||
accountEmail: string | null;
|
||||
planType: IslandflowAiPlanType | null;
|
||||
model: string | null;
|
||||
breakdown: IslandflowAiTokenBreakdown;
|
||||
normalizedCostUsd: number | null;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type IslandflowAiUsageRollup = {
|
||||
breakdown: IslandflowAiTokenBreakdown;
|
||||
normalizedCostUsd: number | null;
|
||||
turnCount: number;
|
||||
activeDays: number;
|
||||
};
|
||||
|
||||
export type IslandflowAiUsageDashboard = {
|
||||
today: IslandflowAiUsageRollup;
|
||||
lifetime: IslandflowAiUsageRollup;
|
||||
recentTurns: IslandflowAiUsageTurnRecord[];
|
||||
};
|
||||
|
||||
export type IslandflowAiTaskSnapshot = {
|
||||
taskId: string;
|
||||
kind: IslandflowAiTaskKind;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: IslandflowAiTaskStatus;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
threadId: string | null;
|
||||
turnId: string | null;
|
||||
model: string | null;
|
||||
reasoningEffort: IslandflowAiReasoningEffort | null;
|
||||
text: string;
|
||||
error: string | null;
|
||||
compiledScreen: IslandflowAiCompiledScreen | null;
|
||||
};
|
||||
|
||||
export type IslandflowAiAccountState = {
|
||||
loggedIn: boolean;
|
||||
email: string | null;
|
||||
planType: IslandflowAiPlanType | null;
|
||||
authMode: IslandflowAiAuthMode | null;
|
||||
requiresOpenaiAuth: boolean;
|
||||
login: IslandflowAiLoginState;
|
||||
};
|
||||
|
||||
export type IslandflowAiState = {
|
||||
desktopAvailable: boolean;
|
||||
transportStatus: IslandflowAiTransportStatus;
|
||||
transportError: string | null;
|
||||
profiles: IslandflowAiProfileSlot[];
|
||||
selectedProfileId: string;
|
||||
account: IslandflowAiAccountState;
|
||||
preferences: IslandflowAiPreferences;
|
||||
models: IslandflowAiModelSummary[];
|
||||
rateLimitsByLimitId: Record<string, IslandflowAiRateLimitSnapshot>;
|
||||
usage: IslandflowAiUsageDashboard;
|
||||
tasks: IslandflowAiTaskSnapshot[];
|
||||
updatedAt: number;
|
||||
};
|
||||
|
|
@ -3,3 +3,4 @@ export * from "./live";
|
|||
export * from "./options-flow";
|
||||
export * from "./sp500";
|
||||
export * from "./synthetic-market";
|
||||
export * from "./desktop-ai";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue