add desktop codex login and analyst copilot
This commit is contained in:
parent
687a217014
commit
d9c8b53b69
23 changed files with 4142 additions and 95 deletions
|
|
@ -1,3 +1,6 @@
|
|||
{"_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}
|
||||
{"_type":"issue","id":"islandflow-jbi","title":"Hydrate alert evidence details from ClickHouse","description":"Alert detail drawers need to fetch persisted alert context from ClickHouse by trace id, including linked flow packets, option prints, preserved execution context, and explicit missing refs for UI diagnostics.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-17T14:55:43Z","created_by":"dirtydishes","updated_at":"2026-05-17T15:01:58Z","started_at":"2026-05-17T14:55:53Z","closed_at":"2026-05-17T15:01:58Z","close_reason":"Implemented ClickHouse-backed alert context hydration across storage, API, terminal drawer, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-8kj","title":"Configure persistent beads Dolt remote on deltaisland server","description":"Install the beads and Dolt CLIs on the server, configure a persistent Dolt sync remote backed by the server-hosted Forgejo repository, verify refs/dolt/data publication, and document Nginx Proxy Manager / firewall considerations.","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-05-17T10:31:31Z","created_by":"delta","updated_at":"2026-05-17T10:37:47Z","started_at":"2026-05-17T10:32:16Z","closed_at":"2026-05-17T10:37:47Z","close_reason":"Installed bd and dolt on the server, configured the Forgejo-backed Dolt remote, published refs/dolt/data, and documented the setup.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-200","title":"Implement durable options tape history","description":"Implement the plan from docs/plans/2026-05-16-1711-durable-options-tape-history.html: durable ClickHouse-backed options history, signal/all prints view selection, preserved execution context, stale semantics limited to live health, reset runbook, tests, and turn documentation.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-16T21:21:30Z","created_by":"dirtydishes","updated_at":"2026-05-16T21:26:51Z","started_at":"2026-05-16T21:21:33Z","closed_at":"2026-05-16T21:26:51Z","close_reason":"Implemented durable options tape history, signal/raw view selection, reset runbook, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
@ -13,6 +16,19 @@
|
|||
{"_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-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}
|
||||
{"_type":"issue","id":"islandflow-m83","title":"Restore options ingestion and print generation on native deployment","description":"After moving the production/VPS deployment from Docker-managed services to the native runtime, the options feed appears behind and fresh option prints are not reaching the UI. Investigate the native deployment path on the server, identify the ingestion or compute breakage, apply the required code and/or host configuration changes, validate that fresh option prints resume, and document any follow-up operational work.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T23:20:01Z","created_by":"dirtydishes","updated_at":"2026-05-19T23:27:52Z","started_at":"2026-05-19T23:20:10Z","closed_at":"2026-05-19T23:27:52Z","close_reason":"Restored native options ingest by switching the VPS back to the last known-good synthetic adapter, verified fresh option prints and compute output, and documented the native env precedence gotcha.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-o1v","title":"Add SCM provider layer with Forgejo detection","description":"Implement provider-aware source-control detection and mirror-aware guardrails for repo automation so Forgejo remotes are treated as authoritative when present.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T23:04:33Z","created_by":"dirtydishes","updated_at":"2026-05-19T23:06:55Z","started_at":"2026-05-19T23:04:35Z","closed_at":"2026-05-19T23:06:55Z","close_reason":"created by mistake during interrupted turn; no implementation was started","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-tqk","title":"publish docs/ to github pages with navigable index","description":"Set up docs deployment so repository docs are published to dirtydishes.github.io/islandflow/docs with a nicer, browsable experience than a raw file listing.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T18:56:02Z","created_by":"dirtydishes","updated_at":"2026-05-19T18:59:55Z","started_at":"2026-05-19T18:56:04Z","closed_at":"2026-05-19T18:59:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-lm6","title":"Clarify repo turn documentation scope","description":"Update AGENTS.md so repository turn documentation clearly uses repo-local docs/turns and impeccable styling, without inheriting global non-repo computer-task styling.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T12:05:07Z","created_by":"dirtydishes","updated_at":"2026-05-19T12:06:12Z","started_at":"2026-05-19T12:05:14Z","closed_at":"2026-05-19T12:06:12Z","close_reason":"Verified AGENTS.md now scopes repo turn docs to docs/turns and makes impeccable the styling authority; added turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-6iq","title":"Update README for current project state","description":"Resolve README merge conflicts and document the current project state, including the smart money classification taxonomy, Next.js update, and deployment workflow changes.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T11:37:24Z","created_by":"dirtydishes","updated_at":"2026-05-19T11:40:01Z","started_at":"2026-05-19T11:37:31Z","closed_at":"2026-05-19T11:40:01Z","close_reason":"README conflict resolved and current project state documented, including smart-money taxonomy, Next.js update, and deployment workflow.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-lib","title":"Upgrade apps/web to Next.js 16.2.6","description":"Upgrade the web app dependency stack to Next.js 16.2.6 with React 19, refresh Bun and mirrored Docker workspace lockfiles, keep runtime behavior unchanged, fix any focused web test fallout, validate the web build and targeted route tests, and document the completed work.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T11:04:51Z","created_by":"dirtydishes","updated_at":"2026-05-19T11:31:23Z","started_at":"2026-05-19T11:04:57Z","closed_at":"2026-05-19T11:31:23Z","close_reason":"Upgraded apps/web to Next.js 16.2.6 with React 19, refreshed Bun lockfiles including the Docker workspace mirror, fixed the React 19 nullable ref type issue, and validated the web build, focused tests, Docker workspace sync, and route smoke checks.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-8fn","title":"implement alpaca-backed news wire view","description":"Why this issue exists and what needs to be done:\\nAdd an Alpaca-powered live news pipeline, API, storage, and web experience, including a dedicated /news route, Home preview, live fanout, history pagination, ticker resolution, and replay-mode live-only empty states.\\n\\nAcceptance criteria:\\n- normalized NewsStory contract and live channel exist\\n- ingest-news service backfills and streams Alpaca news\\n- API persists, serves, and fans out news\\n- web app exposes /news plus Home preview and drawer\\n- tests cover types, storage, API, and key UI behaviors\\n- turn documentation is added\\n\\nDesign:\\nReuse Islandflow drawer, chips, panes, and terminal styling; keep news live-only in v1 replay mode.\\n\\nNotes:\\nImplement client-side ticker filtering in v1 and expose latest revision only per provider+story_id.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T20:37:13Z","created_by":"dirtydishes","updated_at":"2026-05-18T20:55:11Z","started_at":"2026-05-18T20:37:20Z","closed_at":"2026-05-18T20:55:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-k8i","title":"Fix duplicate alert context import in API entrypoint","description":"Recent alert-context work introduced a duplicate fetchAlertContextByTraceId import in services/api/src/index.ts, which risks breaking TypeScript compilation and API startup. Remove the duplicate import and validate the affected API/web tests.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T13:01:58Z","created_by":"dirtydishes","updated_at":"2026-05-18T13:03:40Z","started_at":"2026-05-18T13:02:02Z","closed_at":"2026-05-18T13:03:40Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-lk9","title":"Fix PR creation workflow after Forgejo migration","description":"## Why\\nCreating pull requests with fails after the repository moved primary collaboration from GitHub to Forgejo. The current workflow still assumes GitHub GraphQL PR creation semantics, which do not work against the Forgejo remote.\\n\\n## What\\nInvestigate the current PR creation path, identify remaining GitHub-specific assumptions, and update the repo workflow/scripts/docs so contributors can reliably publish branches and open PRs in the Forgejo-based setup.\\n\\n## Acceptance Criteria\\n- The repo no longer instructs contributors to use a broken GitHub-specific PR creation path for Forgejo branches\\n- There is a documented and preferably scripted way to create the equivalent review request against Forgejo\\n- Validation demonstrates the new workflow behaves correctly or clearly documents any remaining platform limitation","status":"in_progress","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T10:26:47Z","created_by":"dirtydishes","updated_at":"2026-05-18T10:26:53Z","started_at":"2026-05-18T10:26:53Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-1ei","title":"Make deploy helper remote-aware for Forgejo","description":"Why: scripts/deploy.ts hardcodes git remote name origin for fetch/pull/push and branch verification, but this repository now uses forgejo/github remotes and may not have an origin remote. What: update deploy.ts to resolve the deploy git remote robustly (Forgejo-aware), use it across local prechecks, branch publish, and remote rollout git operations, and keep behavior explicit in output.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T03:20:12Z","created_by":"dirtydishes","updated_at":"2026-05-18T03:22:39Z","started_at":"2026-05-18T03:20:16Z","closed_at":"2026-05-18T03:22:39Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-xod","title":"Add --fast mode to deploy helper","description":"Why: full main deploys rebuild all images and run full verification, which is slow for routine rollouts. What: add a --fast flag to scripts/deploy.ts with explicit behavior that short-circuits slow steps while preserving basic safety checks; update help text/docs for discoverability.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T02:50:47Z","created_by":"dirtydishes","updated_at":"2026-05-18T02:53:41Z","started_at":"2026-05-18T02:50:50Z","closed_at":"2026-05-18T02:53:41Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-cif","title":"hydrate alert evidence context from clickhouse","description":"Implement alert detail hydration from ClickHouse with a new context endpoint and frontend drawer evidence resolution. Includes storage lookup by alert trace_id/evidence refs, unresolved refs diagnostics, API route GET /flow/alerts/:trace_id/context, terminal evidence hydration + loading states/copy updates, and tests across storage/api/web.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T00:15:55Z","created_by":"dirtydishes","updated_at":"2026-05-18T00:17:38Z","started_at":"2026-05-18T00:16:00Z","closed_at":"2026-05-18T00:17:38Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
@ -46,6 +62,10 @@
|
|||
{"_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}
|
||||
{"_type":"issue","id":"islandflow-zsy","title":"Expose Forgejo SSH on a direct DNS hostname","description":"git.deltaisland.io currently resolves through Cloudflare's proxy, so SSH on port 2222 does not complete even though the Forgejo container is listening on the host. If SSH-based git/beads workflows are desired, add a DNS-only hostname (or adjust the existing record) that points directly at the server for Forgejo SSH.","status":"open","priority":3,"issue_type":"task","created_at":"2026-05-17T10:34:06Z","created_by":"delta","updated_at":"2026-05-17T10:34:06Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-38p","title":"Add native deployment unit templates and rollback helpers","description":"The deploy helper now supports --runtime native, but the repo still relies on operator-managed systemd units and manual rollback. Add checked-in native deployment templates or provisioning guidance for the expected units, and consider lightweight rollback/smoke-test helpers once the host-native path is exercised on the real VPS.","status":"open","priority":3,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-15T23:46:42Z","created_by":"dirtydishes","updated_at":"2026-05-15T23:46:42Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-575","title":"Document smart-money event calendar env","description":"Document smart-money event-calendar environment configuration in env examples and README.\n","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-05T06:57:14Z","created_by":"dirtydishes","updated_at":"2026-05-05T06:57:57Z","started_at":"2026-05-05T06:57:17Z","closed_at":"2026-05-05T06:57:57Z","close_reason":"Documented event-calendar env variables","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 @@
|
|||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type {
|
||||
IslandflowAiReasoningEffort,
|
||||
IslandflowAiState,
|
||||
IslandflowAiTaskRequest
|
||||
} from "@islandflow/types";
|
||||
import {
|
||||
DESKTOP_AI_CANCEL_LOGIN,
|
||||
DESKTOP_AI_GET_STATE,
|
||||
DESKTOP_AI_LOGIN_BROWSER,
|
||||
DESKTOP_AI_LOGIN_DEVICE,
|
||||
DESKTOP_AI_LOGOUT,
|
||||
DESKTOP_AI_RUN_TASK,
|
||||
DESKTOP_AI_STATE_CHANNEL,
|
||||
DESKTOP_AI_UPDATE_PREFERENCES
|
||||
} from "./desktop-ai-ipc.js";
|
||||
|
||||
const bridge = {
|
||||
ai: {
|
||||
getState: (): Promise<IslandflowAiState> => ipcRenderer.invoke(DESKTOP_AI_GET_STATE),
|
||||
loginWithBrowser: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGIN_BROWSER),
|
||||
loginWithDeviceCode: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGIN_DEVICE),
|
||||
cancelLogin: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_CANCEL_LOGIN),
|
||||
logout: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGOUT),
|
||||
updatePreferences: (
|
||||
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
|
||||
): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_UPDATE_PREFERENCES, next),
|
||||
runTask: (request: IslandflowAiTaskRequest): Promise<{ taskId: string }> =>
|
||||
ipcRenderer.invoke(DESKTOP_AI_RUN_TASK, request),
|
||||
subscribe: (listener: (state: IslandflowAiState) => void): (() => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, state: IslandflowAiState) => {
|
||||
listener(state);
|
||||
};
|
||||
|
||||
ipcRenderer.on(DESKTOP_AI_STATE_CHANNEL, handler);
|
||||
return () => {
|
||||
ipcRenderer.off(DESKTOP_AI_STATE_CHANNEL, handler);
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("islandflowDesktop", bridge);
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"rootDir": "src",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { ChartsRoute } from "../terminal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function Page() {
|
||||
redirect("/");
|
||||
return <ChartsRoute />;
|
||||
}
|
||||
|
|
|
|||
924
apps/web/app/desktop-ai-panels.tsx
Normal file
924
apps/web/app/desktop-ai-panels.tsx
Normal file
|
|
@ -0,0 +1,924 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import type {
|
||||
AlertEvent,
|
||||
ClassifierHitEvent,
|
||||
FlowPacket,
|
||||
IslandflowAiCompiledScreen,
|
||||
IslandflowAiPlanType,
|
||||
IslandflowAiRateLimitSnapshot,
|
||||
IslandflowAiReasoningEffort,
|
||||
IslandflowAiTaskKind,
|
||||
OptionFlowFilters,
|
||||
OptionPrint,
|
||||
SmartMoneyEvent
|
||||
} from "@islandflow/types";
|
||||
import { useDesktopAi } from "./desktop-ai";
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("en-US");
|
||||
const usdFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4
|
||||
});
|
||||
|
||||
const humanizeValue = (value: string | null | undefined): string => {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
}
|
||||
return value
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
};
|
||||
|
||||
const formatTokens = (value: number): string => numberFormatter.format(value);
|
||||
|
||||
const formatUsd = (value: number | null): string => (value === null ? "Unavailable" : usdFormatter.format(value));
|
||||
|
||||
const formatTimestamp = (value: number | null): string => {
|
||||
if (!value) {
|
||||
return "Not reported";
|
||||
}
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatPercent = (value: number): string => `${Math.round(value)}%`;
|
||||
|
||||
const getTaskStatusLabel = (value: string): string => humanizeValue(value);
|
||||
|
||||
const findTask = <T extends { taskId: string }>(tasks: T[], taskId: string | null): T | null => {
|
||||
if (!taskId) {
|
||||
return null;
|
||||
}
|
||||
return tasks.find((task) => task.taskId === taskId) ?? null;
|
||||
};
|
||||
|
||||
const getCompiledScreenSummary = (compiled: IslandflowAiCompiledScreen): string[] => {
|
||||
const filters = compiled.compiledFilters;
|
||||
if (!filters) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (filters.view) {
|
||||
parts.push(`View: ${filters.view}`);
|
||||
}
|
||||
if (filters.securityTypes?.length) {
|
||||
parts.push(`Security: ${filters.securityTypes.join(", ")}`);
|
||||
}
|
||||
if (filters.optionTypes?.length) {
|
||||
parts.push(`Options: ${filters.optionTypes.join(", ")}`);
|
||||
}
|
||||
if (filters.nbboSides?.length) {
|
||||
parts.push(`NBBO: ${filters.nbboSides.join(", ")}`);
|
||||
}
|
||||
if (typeof filters.minNotional === "number") {
|
||||
parts.push(`Min notional: $${numberFormatter.format(filters.minNotional)}`);
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
const CopilotPane = ({
|
||||
title,
|
||||
eyebrow,
|
||||
actions,
|
||||
wide = false,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
actions?: ReactNode;
|
||||
wide?: boolean;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<section className={`terminal-pane copilot-pane${wide ? " copilot-pane-wide" : ""}`}>
|
||||
<div className="terminal-pane-head">
|
||||
<div className="terminal-pane-title-row">
|
||||
<div>
|
||||
{eyebrow ? <div className="copilot-kicker">{eyebrow}</div> : null}
|
||||
<h2 className="terminal-pane-title">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{actions ? <div className="terminal-pane-actions">{actions}</div> : null}
|
||||
</div>
|
||||
<div className="terminal-pane-body copilot-pane-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const UsageBreakdown = ({
|
||||
title,
|
||||
breakdown,
|
||||
normalizedCostUsd,
|
||||
turnCount,
|
||||
activeDays
|
||||
}: {
|
||||
title: string;
|
||||
breakdown: {
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningOutputTokens: number;
|
||||
};
|
||||
normalizedCostUsd: number | null;
|
||||
turnCount: number;
|
||||
activeDays: number;
|
||||
}) => {
|
||||
return (
|
||||
<div className="copilot-usage-block">
|
||||
<div className="copilot-usage-title-row">
|
||||
<h3>{title}</h3>
|
||||
<span className="copilot-usage-cost">{formatUsd(normalizedCostUsd)}</span>
|
||||
</div>
|
||||
<div className="copilot-token-grid">
|
||||
<div className="copilot-token-row">
|
||||
<span>Total tokens</span>
|
||||
<strong>{formatTokens(breakdown.totalTokens)}</strong>
|
||||
</div>
|
||||
<div className="copilot-token-row">
|
||||
<span>Input</span>
|
||||
<strong>{formatTokens(breakdown.inputTokens)}</strong>
|
||||
</div>
|
||||
<div className="copilot-token-row">
|
||||
<span>Cached input</span>
|
||||
<strong>{formatTokens(breakdown.cachedInputTokens)}</strong>
|
||||
</div>
|
||||
<div className="copilot-token-row">
|
||||
<span>Output</span>
|
||||
<strong>{formatTokens(breakdown.outputTokens)}</strong>
|
||||
</div>
|
||||
<div className="copilot-token-row">
|
||||
<span>Reasoning</span>
|
||||
<strong>{formatTokens(breakdown.reasoningOutputTokens)}</strong>
|
||||
</div>
|
||||
<div className="copilot-token-row">
|
||||
<span>Turns</span>
|
||||
<strong>{formatTokens(turnCount)}</strong>
|
||||
</div>
|
||||
<div className="copilot-token-row">
|
||||
<span>Active days</span>
|
||||
<strong>{formatTokens(activeDays)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RateLimitBoard = ({ limit }: { limit: IslandflowAiRateLimitSnapshot }) => {
|
||||
return (
|
||||
<div className="copilot-limit-card" key={limit.limitId ?? limit.limitName ?? "default"}>
|
||||
<div className="copilot-limit-head">
|
||||
<div>
|
||||
<strong>{limit.limitName ?? "Default rate window"}</strong>
|
||||
<p className="copilot-note">
|
||||
{limit.planType ? `Plan ${humanizeValue(limit.planType)}` : "Plan not reported"}
|
||||
</p>
|
||||
</div>
|
||||
{limit.reachedType ? <span className="copilot-badge warning">{humanizeValue(limit.reachedType)}</span> : null}
|
||||
</div>
|
||||
<div className="copilot-limit-grid">
|
||||
{limit.primary ? (
|
||||
<div className="copilot-limit-window">
|
||||
<span>Primary</span>
|
||||
<strong>{formatPercent(limit.primary.usedPercent)}</strong>
|
||||
<p className="copilot-note">Resets {formatTimestamp(limit.primary.resetsAt)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{limit.secondary ? (
|
||||
<div className="copilot-limit-window">
|
||||
<span>Secondary</span>
|
||||
<strong>{formatPercent(limit.secondary.usedPercent)}</strong>
|
||||
<p className="copilot-note">Resets {formatTimestamp(limit.secondary.resetsAt)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{limit.creditsBalance || limit.unlimitedCredits !== null ? (
|
||||
<p className="copilot-note">
|
||||
Credits:{" "}
|
||||
{limit.unlimitedCredits
|
||||
? "unlimited"
|
||||
: limit.creditsBalance
|
||||
? limit.creditsBalance
|
||||
: limit.hasCredits === false
|
||||
? "none"
|
||||
: "not reported"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TaskOutput = ({
|
||||
taskId,
|
||||
emptyMessage
|
||||
}: {
|
||||
taskId: string | null;
|
||||
emptyMessage: string;
|
||||
}) => {
|
||||
const { state } = useDesktopAi();
|
||||
const task = findTask(state.tasks, taskId);
|
||||
|
||||
if (!task) {
|
||||
return <p className="copilot-empty">{emptyMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="copilot-task-output" aria-live="polite">
|
||||
<div className="copilot-task-head">
|
||||
<div>
|
||||
<strong>{task.title}</strong>
|
||||
<p className="copilot-note">
|
||||
{task.subtitle} · {getTaskStatusLabel(task.status)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`copilot-badge status-${task.status}`}>{getTaskStatusLabel(task.status)}</span>
|
||||
</div>
|
||||
{task.error ? <p className="copilot-error">{task.error}</p> : null}
|
||||
{task.text ? <pre className="copilot-task-text">{task.text}</pre> : null}
|
||||
{task.compiledScreen ? <CompiledScreenResult compiled={task.compiledScreen} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CompiledScreenResult = ({ compiled }: { compiled: IslandflowAiCompiledScreen }) => {
|
||||
const summary = getCompiledScreenSummary(compiled);
|
||||
|
||||
return (
|
||||
<div className="copilot-compiled-screen">
|
||||
{summary.length > 0 ? (
|
||||
<div className="copilot-chip-row">
|
||||
{summary.map((item) => (
|
||||
<span className="copilot-chip" key={item}>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="copilot-note">No filter fields were compiled from this prompt.</p>
|
||||
)}
|
||||
{compiled.unhandledClauses.length > 0 ? (
|
||||
<div className="copilot-unhandled-list">
|
||||
<div className="copilot-list-title">Unhandled clauses</div>
|
||||
{compiled.unhandledClauses.map((item) => (
|
||||
<div className="copilot-inline-row" key={item}>
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountSummary = ({
|
||||
loggedIn,
|
||||
email,
|
||||
planType
|
||||
}: {
|
||||
loggedIn: boolean;
|
||||
email: string | null;
|
||||
planType: IslandflowAiPlanType | null;
|
||||
}) => {
|
||||
return (
|
||||
<div className="copilot-hero">
|
||||
<div>
|
||||
<p className="copilot-kicker">Desktop-only official Codex bridge</p>
|
||||
<h1 className="page-title">Analyst Copilot</h1>
|
||||
<p className="copilot-hero-copy">
|
||||
Managed ChatGPT login stays user-scoped, deterministic smart-money classification stays in charge, and every
|
||||
AI turn is tracked with exact token telemetry from the app-server.
|
||||
</p>
|
||||
</div>
|
||||
<div className="copilot-hero-meta">
|
||||
<div className="copilot-stat">
|
||||
<span>Account</span>
|
||||
<strong>{loggedIn ? email ?? "Connected" : "Disconnected"}</strong>
|
||||
</div>
|
||||
<div className="copilot-stat">
|
||||
<span>Plan</span>
|
||||
<strong>{loggedIn ? humanizeValue(planType) : "Not connected"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LoginStatePanel = () => {
|
||||
const { state, loginWithBrowser, loginWithDeviceCode, cancelLogin, logout } = useDesktopAi();
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const loginState = state.account.login;
|
||||
const actionsDisabled = busyAction !== null || !state.desktopAvailable;
|
||||
|
||||
const runAction = async (label: string, action: () => Promise<void>) => {
|
||||
setBusyAction(label);
|
||||
setActionError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CopilotPane
|
||||
title="Account and access"
|
||||
eyebrow="Managed auth"
|
||||
wide
|
||||
actions={
|
||||
<>
|
||||
{state.account.loggedIn ? (
|
||||
<button
|
||||
className="terminal-button"
|
||||
type="button"
|
||||
onClick={() => void runAction("logout", logout)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{busyAction === "logout" ? "Logging out" : "Logout"}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="terminal-button terminal-button-primary"
|
||||
type="button"
|
||||
onClick={() => void runAction("browser", loginWithBrowser)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{busyAction === "browser" ? "Opening browser" : "Browser login"}
|
||||
</button>
|
||||
<button
|
||||
className="terminal-button"
|
||||
type="button"
|
||||
onClick={() => void runAction("device", loginWithDeviceCode)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{busyAction === "device" ? "Preparing code" : "Device code"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(loginState.status === "browser_pending" || loginState.status === "device_code_pending") && !state.account.loggedIn ? (
|
||||
<button
|
||||
className="terminal-button"
|
||||
type="button"
|
||||
onClick={() => void runAction("cancel", cancelLogin)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AccountSummary
|
||||
loggedIn={state.account.loggedIn}
|
||||
email={state.account.email}
|
||||
planType={state.account.planType}
|
||||
/>
|
||||
<div className="copilot-account-grid">
|
||||
<div className="copilot-account-card">
|
||||
<div className="copilot-list-title">Profile slots</div>
|
||||
{state.profiles.map((profile) => (
|
||||
<div className="copilot-inline-row" key={profile.id}>
|
||||
<div>
|
||||
<strong>{profile.label}</strong>
|
||||
<p className="copilot-note">{profile.description}</p>
|
||||
</div>
|
||||
<span className={`copilot-badge${profile.enabled ? "" : " muted"}`}>
|
||||
{profile.selected ? "Selected" : profile.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="copilot-account-card">
|
||||
<div className="copilot-list-title">Session status</div>
|
||||
<div className="copilot-inline-row">
|
||||
<span>Transport</span>
|
||||
<strong>{humanizeValue(state.transportStatus)}</strong>
|
||||
</div>
|
||||
<div className="copilot-inline-row">
|
||||
<span>Auth mode</span>
|
||||
<strong>{humanizeValue(state.account.authMode)}</strong>
|
||||
</div>
|
||||
<div className="copilot-inline-row">
|
||||
<span>OpenAI auth required</span>
|
||||
<strong>{state.account.requiresOpenaiAuth ? "Yes" : "No"}</strong>
|
||||
</div>
|
||||
{state.transportError ? <p className="copilot-error">{state.transportError}</p> : null}
|
||||
{loginState.message ? <p className="copilot-note">{loginState.message}</p> : null}
|
||||
{loginState.status === "browser_pending" ? (
|
||||
<div className="copilot-callout">
|
||||
<strong>Browser login in progress</strong>
|
||||
<p className="copilot-note">Finish the ChatGPT sign-in flow in your browser. Islandflow will update automatically.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{loginState.status === "device_code_pending" ? (
|
||||
<div className="copilot-callout">
|
||||
<strong>Device code</strong>
|
||||
<pre className="copilot-device-code">{loginState.userCode}</pre>
|
||||
<p className="copilot-note">Visit {loginState.verificationUrl} in any browser and enter the code above.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{actionError ? <p className="copilot-error">{actionError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</CopilotPane>
|
||||
);
|
||||
};
|
||||
|
||||
export function DesktopAiSettingsRoute() {
|
||||
const { state, updatePreferences } = useDesktopAi();
|
||||
const [busyPreference, setBusyPreference] = useState<"model" | "reasoning" | null>(null);
|
||||
const [preferenceError, setPreferenceError] = useState<string | null>(null);
|
||||
const rateLimits = Object.values(state.rateLimitsByLimitId);
|
||||
const selectedModel = state.preferences.model ?? "";
|
||||
const selectedReasoning = state.preferences.reasoningEffort ?? "";
|
||||
|
||||
const savePreference = async (
|
||||
key: "model" | "reasoning",
|
||||
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
|
||||
) => {
|
||||
setBusyPreference(key);
|
||||
setPreferenceError(null);
|
||||
try {
|
||||
await updatePreferences(next);
|
||||
} catch (error) {
|
||||
setPreferenceError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusyPreference(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-shell">
|
||||
{!state.desktopAvailable ? (
|
||||
<CopilotPane title="Desktop required" eyebrow="Browser-only fallback" wide>
|
||||
<div className="copilot-unavailable">
|
||||
<p>
|
||||
AI controls are intentionally read-only in the browser build. Open Islandflow Desktop to use managed ChatGPT
|
||||
login, structured Copilot turns, and app-server token telemetry.
|
||||
</p>
|
||||
</div>
|
||||
</CopilotPane>
|
||||
) : null}
|
||||
|
||||
<LoginStatePanel />
|
||||
|
||||
<div className="page-grid page-grid-settings">
|
||||
<CopilotPane title="Model controls" eyebrow="Execution">
|
||||
<div className="copilot-field-grid">
|
||||
<label className="copilot-field">
|
||||
<span className="copilot-field-label">Model</span>
|
||||
<select
|
||||
className="copilot-select"
|
||||
value={selectedModel}
|
||||
onChange={(event) =>
|
||||
void savePreference("model", { model: event.target.value.trim() ? event.target.value : null })
|
||||
}
|
||||
disabled={busyPreference !== null || state.models.length === 0 || !state.desktopAvailable}
|
||||
>
|
||||
<option value="">Use server default</option>
|
||||
{state.models.map((model) => (
|
||||
<option key={model.id} value={model.model}>
|
||||
{model.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="copilot-field">
|
||||
<span className="copilot-field-label">Reasoning</span>
|
||||
<select
|
||||
className="copilot-select"
|
||||
value={selectedReasoning}
|
||||
onChange={(event) =>
|
||||
void savePreference("reasoning", {
|
||||
reasoningEffort: event.target.value.trim()
|
||||
? (event.target.value as IslandflowAiReasoningEffort)
|
||||
: null
|
||||
})
|
||||
}
|
||||
disabled={busyPreference !== null || !state.desktopAvailable}
|
||||
>
|
||||
<option value="">Use model default</option>
|
||||
<option value="none">None</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="xhigh">XHigh</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="copilot-model-list">
|
||||
{state.models.map((model) => (
|
||||
<div className="copilot-model-row" key={model.id}>
|
||||
<div>
|
||||
<strong>{model.displayName}</strong>
|
||||
<p className="copilot-note">{model.description}</p>
|
||||
</div>
|
||||
<div className="copilot-model-meta">
|
||||
<span>{model.model}</span>
|
||||
{model.pricing ? <span>{formatUsd(model.pricing.inputUsdPer1MTokens)} / 1M input</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{state.models.find((model) => model.model === state.preferences.model)?.pricing ? (
|
||||
<p className="copilot-note">
|
||||
Normalized estimates use current API pricing for the selected model, not your literal ChatGPT subscription bill.
|
||||
</p>
|
||||
) : null}
|
||||
{preferenceError ? <p className="copilot-error">{preferenceError}</p> : null}
|
||||
</CopilotPane>
|
||||
|
||||
<CopilotPane title="Rate limits" eyebrow="Live windows">
|
||||
{rateLimits.length === 0 ? (
|
||||
<p className="copilot-empty">No rate-limit snapshots have been reported yet.</p>
|
||||
) : (
|
||||
<div className="copilot-limit-list">
|
||||
{rateLimits.map((limit) => (
|
||||
<RateLimitBoard key={limit.limitId ?? limit.limitName ?? "default"} limit={limit} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CopilotPane>
|
||||
|
||||
<CopilotPane title="Usage dashboard" eyebrow="Exact app-server telemetry" wide>
|
||||
<div className="copilot-usage-grid">
|
||||
<UsageBreakdown
|
||||
title="Today"
|
||||
breakdown={state.usage.today.breakdown}
|
||||
normalizedCostUsd={state.usage.today.normalizedCostUsd}
|
||||
turnCount={state.usage.today.turnCount}
|
||||
activeDays={state.usage.today.activeDays}
|
||||
/>
|
||||
<UsageBreakdown
|
||||
title="Lifetime"
|
||||
breakdown={state.usage.lifetime.breakdown}
|
||||
normalizedCostUsd={state.usage.lifetime.normalizedCostUsd}
|
||||
turnCount={state.usage.lifetime.turnCount}
|
||||
activeDays={state.usage.lifetime.activeDays}
|
||||
/>
|
||||
</div>
|
||||
</CopilotPane>
|
||||
|
||||
<CopilotPane title="Recent turns" eyebrow="Per-thread usage">
|
||||
{state.usage.recentTurns.length === 0 ? (
|
||||
<p className="copilot-empty">No tracked turns yet.</p>
|
||||
) : (
|
||||
<div className="copilot-turn-list">
|
||||
{state.usage.recentTurns.map((turn) => (
|
||||
<div className="copilot-turn-row" key={`${turn.threadId}:${turn.turnId}`}>
|
||||
<div>
|
||||
<strong>{turn.taskTitle ?? "Ad hoc turn"}</strong>
|
||||
<p className="copilot-note">
|
||||
{turn.model ?? "default"} · {formatTimestamp(turn.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="copilot-turn-metrics">
|
||||
<span>{formatTokens(turn.breakdown.totalTokens)} tok</span>
|
||||
<span>{formatUsd(turn.normalizedCostUsd)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CopilotPane>
|
||||
|
||||
<CopilotPane title="Recent analyses" eyebrow="Task feed">
|
||||
{state.tasks.length === 0 ? (
|
||||
<p className="copilot-empty">No Copilot tasks have been run yet.</p>
|
||||
) : (
|
||||
<div className="copilot-task-list">
|
||||
{state.tasks.map((task) => (
|
||||
<div className="copilot-task-list-row" key={task.taskId}>
|
||||
<div>
|
||||
<strong>{task.title}</strong>
|
||||
<p className="copilot-note">
|
||||
{task.subtitle} · {humanizeValue(task.model)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`copilot-badge status-${task.status}`}>{getTaskStatusLabel(task.status)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CopilotPane>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const requireDesktopActionCopy = (desktopAvailable: boolean, loggedIn: boolean): string => {
|
||||
if (!desktopAvailable) {
|
||||
return "This control is desktop-only. Open Islandflow Desktop to run Copilot tasks.";
|
||||
}
|
||||
if (!loggedIn) {
|
||||
return "Connect a ChatGPT or Codex account in Settings before running Copilot analysis.";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const SmartMoneyTaskButton = ({
|
||||
label,
|
||||
kind,
|
||||
symbol,
|
||||
disabled,
|
||||
busyKind,
|
||||
onRun
|
||||
}: {
|
||||
label: string;
|
||||
kind: IslandflowAiTaskKind;
|
||||
symbol: string;
|
||||
disabled: boolean;
|
||||
busyKind: IslandflowAiTaskKind | null;
|
||||
onRun: (kind: IslandflowAiTaskKind) => void;
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`terminal-button${kind === "smart-money-explain" ? " terminal-button-primary" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onRun(kind)}
|
||||
disabled={busyKind !== null || disabled}
|
||||
title={`${label} for ${symbol}`}
|
||||
>
|
||||
{busyKind === kind ? "Running" : label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export function SmartMoneyCopilotPanel({
|
||||
event,
|
||||
flowPacket,
|
||||
evidencePrints,
|
||||
relatedPackets
|
||||
}: {
|
||||
event: SmartMoneyEvent;
|
||||
flowPacket: FlowPacket | null;
|
||||
evidencePrints: OptionPrint[];
|
||||
relatedPackets: FlowPacket[];
|
||||
}) {
|
||||
const { bridgeAvailable, state, runTask } = useDesktopAi();
|
||||
const [busyKind, setBusyKind] = useState<IslandflowAiTaskKind | null>(null);
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [taskError, setTaskError] = useState<string | null>(null);
|
||||
const disabledCopy = requireDesktopActionCopy(bridgeAvailable, state.account.loggedIn);
|
||||
const actionsDisabled = !bridgeAvailable || !state.account.loggedIn;
|
||||
|
||||
const handleRun = async (kind: IslandflowAiTaskKind) => {
|
||||
setBusyKind(kind);
|
||||
setTaskError(null);
|
||||
try {
|
||||
const result = await runTask({
|
||||
kind: kind as
|
||||
| "smart-money-explain"
|
||||
| "smart-money-skeptic"
|
||||
| "smart-money-burst-summary"
|
||||
| "watchlist-synthesis",
|
||||
context: {
|
||||
event,
|
||||
flowPacket,
|
||||
evidencePrints,
|
||||
relatedPackets
|
||||
}
|
||||
});
|
||||
setActiveTaskId(result.taskId);
|
||||
} catch (error) {
|
||||
setTaskError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusyKind(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="copilot-inline-panel">
|
||||
<div className="copilot-inline-head">
|
||||
<div>
|
||||
<div className="copilot-list-title">Analyst Copilot</div>
|
||||
<p className="copilot-note">Structured interpretation only, the deterministic classifier remains the source of truth.</p>
|
||||
</div>
|
||||
<Link className="terminal-button" href="/settings">
|
||||
AI settings
|
||||
</Link>
|
||||
</div>
|
||||
<div className="copilot-action-grid">
|
||||
<SmartMoneyTaskButton
|
||||
label="Explain"
|
||||
kind="smart-money-explain"
|
||||
symbol={event.underlying_id}
|
||||
disabled={actionsDisabled}
|
||||
busyKind={busyKind}
|
||||
onRun={(kind) => void handleRun(kind)}
|
||||
/>
|
||||
<SmartMoneyTaskButton
|
||||
label="Counter-thesis"
|
||||
kind="smart-money-skeptic"
|
||||
symbol={event.underlying_id}
|
||||
disabled={actionsDisabled}
|
||||
busyKind={busyKind}
|
||||
onRun={(kind) => void handleRun(kind)}
|
||||
/>
|
||||
<SmartMoneyTaskButton
|
||||
label="Burst summary"
|
||||
kind="smart-money-burst-summary"
|
||||
symbol={event.underlying_id}
|
||||
disabled={actionsDisabled}
|
||||
busyKind={busyKind}
|
||||
onRun={(kind) => void handleRun(kind)}
|
||||
/>
|
||||
<SmartMoneyTaskButton
|
||||
label="Watchlist"
|
||||
kind="watchlist-synthesis"
|
||||
symbol={event.underlying_id}
|
||||
disabled={actionsDisabled}
|
||||
busyKind={busyKind}
|
||||
onRun={(kind) => void handleRun(kind)}
|
||||
/>
|
||||
</div>
|
||||
{disabledCopy ? <p className="copilot-note">{disabledCopy}</p> : null}
|
||||
{taskError ? <p className="copilot-error">{taskError}</p> : null}
|
||||
<TaskOutput taskId={activeTaskId} emptyMessage="Run an explanation, skepticism pass, burst summary, or watchlist synthesis to see the result here." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReplayCopilotPanel({
|
||||
ticker,
|
||||
flowFilters,
|
||||
alerts,
|
||||
smartMoneyEvents,
|
||||
classifierHits,
|
||||
flowPackets,
|
||||
optionPrints
|
||||
}: {
|
||||
ticker: string | null;
|
||||
flowFilters: OptionFlowFilters;
|
||||
alerts: AlertEvent[];
|
||||
smartMoneyEvents: SmartMoneyEvent[];
|
||||
classifierHits: ClassifierHitEvent[];
|
||||
flowPackets: FlowPacket[];
|
||||
optionPrints: OptionPrint[];
|
||||
}) {
|
||||
const { bridgeAvailable, state, runTask } = useDesktopAi();
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [taskError, setTaskError] = useState<string | null>(null);
|
||||
const disabledCopy = requireDesktopActionCopy(bridgeAvailable, state.account.loggedIn);
|
||||
const actionsDisabled = busy || !bridgeAvailable || !state.account.loggedIn;
|
||||
|
||||
const handleRun = async () => {
|
||||
setBusy(true);
|
||||
setTaskError(null);
|
||||
try {
|
||||
const result = await runTask({
|
||||
kind: "replay-postmortem",
|
||||
context: {
|
||||
ticker,
|
||||
flowFilters,
|
||||
alerts,
|
||||
smartMoneyEvents,
|
||||
classifierHits,
|
||||
flowPackets,
|
||||
optionPrints
|
||||
}
|
||||
});
|
||||
setActiveTaskId(result.taskId);
|
||||
} catch (error) {
|
||||
setTaskError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CopilotPane
|
||||
title="Replay postmortem"
|
||||
eyebrow="Structured recap"
|
||||
actions={
|
||||
<>
|
||||
<Link className="terminal-button" href="/settings">
|
||||
AI settings
|
||||
</Link>
|
||||
<button
|
||||
className="terminal-button terminal-button-primary"
|
||||
type="button"
|
||||
onClick={() => void handleRun()}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{busy ? "Running" : "Generate postmortem"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="copilot-note">
|
||||
Copilot uses the current replay slice only: ticker scope, flow filters, visible alerts, classifier hits, packets, and option prints.
|
||||
</p>
|
||||
{disabledCopy ? <p className="copilot-note">{disabledCopy}</p> : null}
|
||||
{taskError ? <p className="copilot-error">{taskError}</p> : null}
|
||||
<TaskOutput taskId={activeTaskId} emptyMessage="Generate a replay postmortem to capture the cleanest read from the current session slice." />
|
||||
</CopilotPane>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScreenCompilerPanel({
|
||||
currentFilters,
|
||||
onApplyFilters
|
||||
}: {
|
||||
currentFilters: OptionFlowFilters;
|
||||
onApplyFilters: (next: OptionFlowFilters) => void;
|
||||
}) {
|
||||
const { bridgeAvailable, state, runTask } = useDesktopAi();
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [taskError, setTaskError] = useState<string | null>(null);
|
||||
const activeTask = useMemo(() => findTask(state.tasks, activeTaskId), [state.tasks, activeTaskId]);
|
||||
const disabledCopy = requireDesktopActionCopy(bridgeAvailable, state.account.loggedIn);
|
||||
const actionsDisabled = busy || !bridgeAvailable || !state.account.loggedIn;
|
||||
|
||||
const handleCompile = async () => {
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (!trimmedPrompt) {
|
||||
setTaskError("Write a screen request first.");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setTaskError(null);
|
||||
try {
|
||||
const result = await runTask({
|
||||
kind: "screen-compile",
|
||||
context: {
|
||||
prompt: trimmedPrompt,
|
||||
currentFilters
|
||||
}
|
||||
});
|
||||
setActiveTaskId(result.taskId);
|
||||
} catch (error) {
|
||||
setTaskError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const compiledFilters = activeTask?.compiledScreen?.compiledFilters ?? null;
|
||||
|
||||
return (
|
||||
<CopilotPane
|
||||
title="Natural-language screens"
|
||||
eyebrow="Tape workflow"
|
||||
actions={
|
||||
<>
|
||||
<Link className="terminal-button" href="/settings">
|
||||
AI settings
|
||||
</Link>
|
||||
<button
|
||||
className="terminal-button terminal-button-primary"
|
||||
type="button"
|
||||
onClick={() => void handleCompile()}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{busy ? "Compiling" : "Compile screen"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="copilot-inline-form">
|
||||
<label className="copilot-field">
|
||||
<span className="copilot-field-label">Prompt</span>
|
||||
<textarea
|
||||
className="copilot-textarea"
|
||||
rows={4}
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="High-notional single-name call buying near the ask, ignore ETFs, keep it signal-only."
|
||||
/>
|
||||
</label>
|
||||
<div className="copilot-current-filters">
|
||||
<div className="copilot-list-title">Current filter baseline</div>
|
||||
<pre className="copilot-json-block">{JSON.stringify(currentFilters, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{disabledCopy ? <p className="copilot-note">{disabledCopy}</p> : null}
|
||||
{taskError ? <p className="copilot-error">{taskError}</p> : null}
|
||||
{compiledFilters ? (
|
||||
<div className="copilot-apply-row">
|
||||
<button className="terminal-button" type="button" onClick={() => onApplyFilters(compiledFilters)}>
|
||||
Apply compiled filters
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<TaskOutput taskId={activeTaskId} emptyMessage="Compile a natural-language screen to preview the translated filter set and rationale." />
|
||||
</CopilotPane>
|
||||
);
|
||||
}
|
||||
179
apps/web/app/desktop-ai.tsx
Normal file
179
apps/web/app/desktop-ai.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import type {
|
||||
IslandflowAiReasoningEffort,
|
||||
IslandflowAiState,
|
||||
IslandflowAiTaskRequest
|
||||
} from "@islandflow/types";
|
||||
|
||||
type DesktopAiBridge = {
|
||||
ai: {
|
||||
getState: () => Promise<IslandflowAiState>;
|
||||
loginWithBrowser: () => Promise<void>;
|
||||
loginWithDeviceCode: () => Promise<void>;
|
||||
cancelLogin: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
updatePreferences: (
|
||||
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
|
||||
) => Promise<void>;
|
||||
runTask: (request: IslandflowAiTaskRequest) => Promise<{ taskId: string }>;
|
||||
subscribe: (listener: (state: IslandflowAiState) => void) => () => void;
|
||||
};
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
islandflowDesktop?: DesktopAiBridge;
|
||||
}
|
||||
}
|
||||
|
||||
type DesktopAiContextValue = {
|
||||
bridgeAvailable: boolean;
|
||||
state: IslandflowAiState;
|
||||
loginWithBrowser: () => Promise<void>;
|
||||
loginWithDeviceCode: () => Promise<void>;
|
||||
cancelLogin: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
updatePreferences: (
|
||||
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
|
||||
) => Promise<void>;
|
||||
runTask: (request: IslandflowAiTaskRequest) => Promise<{ taskId: string }>;
|
||||
};
|
||||
|
||||
const createUnavailableState = (): IslandflowAiState => ({
|
||||
desktopAvailable: false,
|
||||
transportStatus: "stopped",
|
||||
transportError: "Desktop AI is only available inside the Islandflow Electron app.",
|
||||
profiles: [
|
||||
{
|
||||
id: "managed-chatgpt",
|
||||
label: "Managed ChatGPT login",
|
||||
description: "Available only in the desktop app.",
|
||||
mode: "managed-chatgpt",
|
||||
enabled: false,
|
||||
selected: true,
|
||||
statusLabel: "Desktop only"
|
||||
}
|
||||
],
|
||||
selectedProfileId: "managed-chatgpt",
|
||||
account: {
|
||||
loggedIn: false,
|
||||
email: null,
|
||||
planType: null,
|
||||
authMode: null,
|
||||
requiresOpenaiAuth: true,
|
||||
login: {
|
||||
status: "idle",
|
||||
message: "Open Islandflow Desktop to connect a ChatGPT or Codex account."
|
||||
}
|
||||
},
|
||||
preferences: {
|
||||
model: null,
|
||||
reasoningEffort: "high"
|
||||
},
|
||||
models: [],
|
||||
rateLimitsByLimitId: {},
|
||||
usage: {
|
||||
today: {
|
||||
breakdown: {
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningOutputTokens: 0
|
||||
},
|
||||
normalizedCostUsd: 0,
|
||||
turnCount: 0,
|
||||
activeDays: 0
|
||||
},
|
||||
lifetime: {
|
||||
breakdown: {
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningOutputTokens: 0
|
||||
},
|
||||
normalizedCostUsd: 0,
|
||||
turnCount: 0,
|
||||
activeDays: 0
|
||||
},
|
||||
recentTurns: []
|
||||
},
|
||||
tasks: [],
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
const DesktopAiContext = createContext<DesktopAiContextValue | null>(null);
|
||||
|
||||
const rejectDesktopOnly = async (): Promise<never> => {
|
||||
throw new Error("Desktop AI is only available inside the Islandflow Electron app.");
|
||||
};
|
||||
|
||||
export function DesktopAiProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<IslandflowAiState>(() => createUnavailableState());
|
||||
const [bridge, setBridge] = useState<DesktopAiBridge | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextBridge = window.islandflowDesktop ?? null;
|
||||
if (!nextBridge?.ai) {
|
||||
setBridge(null);
|
||||
setState(createUnavailableState());
|
||||
return;
|
||||
}
|
||||
|
||||
setBridge(nextBridge);
|
||||
let unsubscribe = () => {};
|
||||
void nextBridge.ai.getState().then(setState).catch(() => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
transportStatus: "error",
|
||||
transportError: "The desktop AI bridge could not load its initial state."
|
||||
}));
|
||||
});
|
||||
|
||||
unsubscribe = nextBridge.ai.subscribe((nextState) => {
|
||||
setState(nextState);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<DesktopAiContextValue>(
|
||||
() => ({
|
||||
bridgeAvailable: Boolean(bridge?.ai),
|
||||
state,
|
||||
loginWithBrowser: bridge?.ai.loginWithBrowser ?? rejectDesktopOnly,
|
||||
loginWithDeviceCode: bridge?.ai.loginWithDeviceCode ?? rejectDesktopOnly,
|
||||
cancelLogin: bridge?.ai.cancelLogin ?? rejectDesktopOnly,
|
||||
logout: bridge?.ai.logout ?? rejectDesktopOnly,
|
||||
updatePreferences: bridge?.ai.updatePreferences ?? rejectDesktopOnly,
|
||||
runTask: bridge?.ai.runTask ?? rejectDesktopOnly
|
||||
}),
|
||||
[bridge, state]
|
||||
);
|
||||
|
||||
return <DesktopAiContext.Provider value={value}>{children}</DesktopAiContext.Provider>;
|
||||
}
|
||||
|
||||
export const useDesktopAi = (): DesktopAiContextValue => {
|
||||
const value = useContext(DesktopAiContext);
|
||||
if (!value) {
|
||||
throw new Error("Desktop AI context missing");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
|
@ -276,6 +276,25 @@ input {
|
|||
margin-left: auto;
|
||||
}
|
||||
|
||||
.terminal-topbar-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.terminal-topbar-summary strong {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.terminal-topbar-summary .copilot-note {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.terminal-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -708,12 +727,21 @@ h3 {
|
|||
grid-template-columns: repeat(3, 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-tape > :nth-child(1),
|
||||
.page-grid-replay > :nth-child(1) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.page-grid-settings > .copilot-pane-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.terminal-pane {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
|
|
@ -973,6 +1001,325 @@ h3 {
|
|||
height: clamp(430px, 58vh, 760px);
|
||||
}
|
||||
|
||||
.copilot-pane {
|
||||
background:
|
||||
radial-gradient(circle at top right, oklch(0.8 0.12 74 / 0.07), transparent 36%),
|
||||
linear-gradient(180deg, oklch(0.18 0.013 250) 0%, oklch(0.16 0.012 250) 100%);
|
||||
}
|
||||
|
||||
.copilot-pane-body {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.copilot-kicker,
|
||||
.copilot-field-label,
|
||||
.copilot-list-title {
|
||||
color: var(--text-faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.copilot-kicker {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.copilot-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.7fr);
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(135deg, oklch(0.2 0.017 250 / 0.92), oklch(0.16 0.012 250 / 0.96)),
|
||||
var(--bg-pane-2);
|
||||
}
|
||||
|
||||
.copilot-hero-copy {
|
||||
max-width: 62ch;
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.copilot-hero-meta,
|
||||
.copilot-account-grid,
|
||||
.copilot-usage-grid,
|
||||
.copilot-field-grid,
|
||||
.copilot-limit-grid,
|
||||
.copilot-action-grid,
|
||||
.copilot-inline-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.copilot-hero-meta {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.copilot-stat,
|
||||
.copilot-account-card,
|
||||
.copilot-usage-block,
|
||||
.copilot-limit-card,
|
||||
.copilot-current-filters,
|
||||
.copilot-callout {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: oklch(0.13 0.01 250 / 0.64);
|
||||
}
|
||||
|
||||
.copilot-stat span,
|
||||
.copilot-token-row span,
|
||||
.copilot-limit-window span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.copilot-stat strong,
|
||||
.copilot-token-row strong,
|
||||
.copilot-limit-window strong,
|
||||
.copilot-device-code,
|
||||
.copilot-model-meta,
|
||||
.copilot-turn-metrics {
|
||||
font-family: var(--font-mono), monospace;
|
||||
}
|
||||
|
||||
.copilot-stat strong {
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.copilot-account-grid,
|
||||
.copilot-usage-grid,
|
||||
.copilot-field-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.copilot-inline-row,
|
||||
.copilot-turn-row,
|
||||
.copilot-task-list-row,
|
||||
.copilot-model-row,
|
||||
.copilot-token-row,
|
||||
.copilot-usage-title-row,
|
||||
.copilot-limit-head,
|
||||
.copilot-task-head,
|
||||
.copilot-inline-head,
|
||||
.copilot-apply-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.copilot-inline-row,
|
||||
.copilot-turn-row,
|
||||
.copilot-task-list-row,
|
||||
.copilot-model-row {
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid oklch(0.72 0.012 250 / 0.12);
|
||||
}
|
||||
|
||||
.copilot-account-card > :first-child,
|
||||
.copilot-limit-card > :first-child,
|
||||
.copilot-turn-list > :first-child,
|
||||
.copilot-task-list > :first-child,
|
||||
.copilot-model-list > :first-child,
|
||||
.copilot-unhandled-list > :first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.copilot-note {
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.copilot-note,
|
||||
.copilot-error,
|
||||
.copilot-empty,
|
||||
.copilot-device-code,
|
||||
.copilot-task-text,
|
||||
.copilot-json-block {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.copilot-error {
|
||||
color: oklch(0.8 0.11 28);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.copilot-badge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: oklch(0.97 0.008 250 / 0.04);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.copilot-badge.muted {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.copilot-badge.warning,
|
||||
.copilot-badge.status-running {
|
||||
border-color: oklch(0.78 0.12 74 / 0.38);
|
||||
background: oklch(0.78 0.12 74 / 0.09);
|
||||
color: oklch(0.88 0.06 76);
|
||||
}
|
||||
|
||||
.copilot-badge.status-completed {
|
||||
border-color: oklch(0.74 0.13 151 / 0.34);
|
||||
background: oklch(0.74 0.13 151 / 0.09);
|
||||
color: oklch(0.88 0.05 151);
|
||||
}
|
||||
|
||||
.copilot-badge.status-failed,
|
||||
.copilot-badge.status-cancelled {
|
||||
border-color: oklch(0.68 0.16 28 / 0.36);
|
||||
background: oklch(0.68 0.16 28 / 0.1);
|
||||
color: oklch(0.88 0.05 28);
|
||||
}
|
||||
|
||||
.copilot-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copilot-select,
|
||||
.copilot-textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: oklch(0.11 0.009 250 / 0.82);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.copilot-select {
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.copilot-textarea {
|
||||
padding: 12px;
|
||||
resize: vertical;
|
||||
min-height: 112px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.copilot-model-list,
|
||||
.copilot-limit-list,
|
||||
.copilot-turn-list,
|
||||
.copilot-task-list,
|
||||
.copilot-unhandled-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.copilot-model-meta,
|
||||
.copilot-turn-metrics {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
justify-items: end;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.copilot-token-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.copilot-token-row,
|
||||
.copilot-limit-window {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: oklch(0.97 0.008 250 / 0.03);
|
||||
}
|
||||
|
||||
.copilot-usage-title-row h3,
|
||||
.copilot-limit-head strong {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.copilot-usage-cost {
|
||||
font-family: var(--font-mono), monospace;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.copilot-inline-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.copilot-action-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.copilot-task-output,
|
||||
.copilot-unavailable {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: oklch(0.12 0.01 250 / 0.72);
|
||||
}
|
||||
|
||||
.copilot-task-text,
|
||||
.copilot-json-block,
|
||||
.copilot-device-code {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: oklch(0.1 0.009 250 / 0.92);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.copilot-device-code {
|
||||
font-size: clamp(1.3rem, 2vw, 1.7rem);
|
||||
letter-spacing: 0.18em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.copilot-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copilot-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--border-strong);
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.copilot-compiled-screen {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -1958,6 +2305,7 @@ h3 {
|
|||
.page-grid-signals,
|
||||
.page-grid-charts,
|
||||
.page-grid-replay,
|
||||
.page-grid-settings,
|
||||
.replay-matrix,
|
||||
.shell-metrics {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
|
@ -1986,6 +2334,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;
|
||||
|
|
@ -2068,7 +2423,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;
|
||||
}
|
||||
|
|
@ -2107,7 +2470,8 @@ h3 {
|
|||
}
|
||||
|
||||
.terminal-topbar-actions,
|
||||
.terminal-topbar-controls {
|
||||
.terminal-topbar-controls,
|
||||
.terminal-topbar-summary {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
|
@ -2115,7 +2479,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%;
|
||||
}
|
||||
|
||||
|
|
@ -2198,7 +2565,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}`}>
|
||||
<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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -431,6 +431,24 @@ describe("route feature map", () => {
|
|||
expect(features.equityOverlay).toBe(true);
|
||||
expect(features.alerts).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", () => {
|
||||
|
|
@ -461,10 +479,14 @@ describe("dark underlying route dependency helper", () => {
|
|||
});
|
||||
|
||||
describe("terminal navigation", () => {
|
||||
it("exposes only Home and Tape as top-level destinations", () => {
|
||||
it("exposes the terminal routes including Copilot settings", () => {
|
||||
expect(NAV_ITEMS).toEqual([
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/tape", label: "Tape" }
|
||||
{ href: "/tape", label: "Tape" },
|
||||
{ href: "/signals", label: "Signals" },
|
||||
{ href: "/charts", label: "Charts" },
|
||||
{ href: "/replay", label: "Replay" },
|
||||
{ href: "/settings", label: "Settings" }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,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,
|
||||
|
|
@ -189,7 +196,8 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
pathname === "/tape" ||
|
||||
pathname === "/signals" ||
|
||||
pathname === "/charts" ||
|
||||
pathname === "/replay"
|
||||
pathname === "/replay" ||
|
||||
pathname === "/settings"
|
||||
? pathname
|
||||
: "/";
|
||||
|
||||
|
|
@ -298,6 +306,32 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
needsAlertEvidencePrefetch: true,
|
||||
needsDarkUnderlying: false
|
||||
};
|
||||
case "/settings":
|
||||
return {
|
||||
options: false,
|
||||
nbbo: false,
|
||||
equities: false,
|
||||
flow: false,
|
||||
alerts: false,
|
||||
smartMoney: false,
|
||||
classifierHits: false,
|
||||
inferredDark: false,
|
||||
equityJoins: false,
|
||||
equityCandles: false,
|
||||
equityOverlay: false,
|
||||
showOptionsPane: false,
|
||||
showEquitiesPane: false,
|
||||
showFlowPane: false,
|
||||
showAlertsPane: false,
|
||||
showClassifierPane: false,
|
||||
showDarkPane: false,
|
||||
showChartPane: false,
|
||||
showFocusPane: false,
|
||||
showReplayConsole: false,
|
||||
needsClassifierDecor: false,
|
||||
needsAlertEvidencePrefetch: false,
|
||||
needsDarkUnderlying: false
|
||||
};
|
||||
case "/":
|
||||
default:
|
||||
return {
|
||||
|
|
@ -4934,10 +4968,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];
|
||||
|
|
@ -4966,6 +5001,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">
|
||||
|
|
@ -6172,6 +6216,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;
|
||||
|
|
@ -6914,6 +6973,7 @@ const useTerminalState = () => {
|
|||
selectedClassifierEvidence,
|
||||
selectedSmartMoneyFlowPacket,
|
||||
selectedSmartMoneyEvidence,
|
||||
selectedSmartMoneyRelatedPackets,
|
||||
filteredOptions,
|
||||
filteredEquities,
|
||||
optionsScopedQuiet,
|
||||
|
|
@ -6953,7 +7013,11 @@ const useTerminal = (): TerminalState => {
|
|||
|
||||
export const NAV_ITEMS = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/tape", label: "Tape" }
|
||||
{ href: "/tape", label: "Tape" },
|
||||
{ href: "/signals", label: "Signals" },
|
||||
{ href: "/charts", label: "Charts" },
|
||||
{ href: "/replay", label: "Replay" },
|
||||
{ href: "/settings", label: "Settings" }
|
||||
] as const;
|
||||
|
||||
type PageFrameProps = {
|
||||
|
|
@ -8514,9 +8578,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}>
|
||||
|
|
@ -8550,6 +8621,21 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
|||
<div className="terminal-frame">
|
||||
<header className="terminal-topbar">
|
||||
<div className="terminal-topbar-actions">
|
||||
{isSettingsRoute ? (
|
||||
<div
|
||||
className={`terminal-topbar-summary${ai.state.account.loggedIn ? " status-connected" : " status-disconnected"}`}
|
||||
>
|
||||
<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">
|
||||
|
|
@ -8592,7 +8678,16 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
|||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="terminal-topbar-mode">
|
||||
<Link
|
||||
aria-current={isSettingsRoute ? "page" : undefined}
|
||||
className={`terminal-button${isSettingsRoute ? " terminal-button-primary" : ""}`}
|
||||
href="/settings"
|
||||
>
|
||||
{aiButtonLabel}
|
||||
</Link>
|
||||
{!isSettingsRoute ? (
|
||||
<button
|
||||
aria-label={state.mode === "live" ? "Switch to replay mode" : "Switch to live mode"}
|
||||
aria-pressed={state.mode !== "live"}
|
||||
|
|
@ -8603,6 +8698,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
|||
>
|
||||
{state.mode === "live" ? "Replay" : "Live"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -8638,6 +8734,7 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
|||
event={state.selectedSmartMoneyEvent}
|
||||
flowPacket={state.selectedSmartMoneyFlowPacket}
|
||||
evidence={state.selectedSmartMoneyEvidence}
|
||||
relatedPackets={state.selectedSmartMoneyRelatedPackets}
|
||||
onClose={() => state.setSelectedSmartMoneyEvent(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -8696,11 +8793,14 @@ export function TapeRoute() {
|
|||
</>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -8732,9 +8832,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} />
|
||||
|
|
@ -8743,3 +8873,11 @@ export function ReplayRoute() {
|
|||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsRoute() {
|
||||
return (
|
||||
<PageFrame title="Settings">
|
||||
<DesktopAiSettingsRoute />
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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",
|
||||
|
|
|
|||
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>
|
||||
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