From d1166548bf207e471523bf643b3f7abb0a4075c8 Mon Sep 17 00:00:00 2001 From: Antonio Prebisalic Date: Tue, 31 Mar 2026 20:58:17 +0200 Subject: [PATCH] Fix dashboard bugs: Run Now, AI Insights, token chart, Design QC, project switching, cron logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — "Run Now" button silently did nothing The button called client?.send() over WebSocket which drops silently if the socket is not in OPEN state. Replaced with fetch POST /api/cron/run/:taskId. The endpoint returns 202 immediately and runs the task in the background. If the task fails, a task_error WebSocket message is broadcast so the UI shows the actual error instead of resetting silently. Bug 2 — AI Insights "Generate Now" opened the Claude desktop app The cron engine was spawning the claude CLI binary from a background daemon. On macOS that binary spins up a Linux VM via the Virtualization framework and delegates auth to the desktop app — both fail from a headless process. Fix: if ANTHROPIC_API_KEY is set, call the Anthropic API directly via fetch. If not set, fail immediately with a clear error. The dashboard panel now shows a copy-able prompt to paste into a Claude Code session — how OpenWolf is meant to work (Claude writes data, the dashboard reads it). Bug 3 — Token graph x-axis showed date only, not time s.started.slice(0, 10) only kept the date. Changed to .slice(0, 16).replace("T", " ") to show YYYY-MM-DD HH:MM. Bug 4 — Design QC only worked on localhost The capture engine only detected dev servers. Added detectDeployedUrl() which checks package.json homepage, .env.production URL vars, and vercel.json aliases. Added a manual "Run Capture" button with POST /api/designqc/run endpoint. Fixed the engine not writing designqc-report.json after capture. Bug 5 — Token comparison chart had hardcoded data and visual issues Removed fabricated comparison data. Now uses real tracked values from the token ledger. Fixed the gray tail on bars (recharts renders a background behind each bar by default — set background fill to transparent). Fixed tooltip using undefined CSS variables — now uses explicit dark colors. Bug 6 — "Healthy" and "Live" badges were meaningless The sidebar badge always showed green regardless of actual state. The header Live indicator added noise without value — if the page loaded the daemon is running. Both removed. Bug 7 — Dashboard showed wrong project after switching All projects default to port 18791. Starting a second project's daemon failed silently because the port was already held, leaving the old project's dashboard visible. daemonStart now kills whatever is on the port before starting. Added POST /api/switch which hot-reloads the project in-place: stops the cron engine and file watcher, swaps the project root, restarts both subsystems, broadcasts the new project's full state over the existing WebSocket. No process restart needed. Added a project switcher dropdown in the dashboard header. Bug 8 — Cron execution log was never written The daemon writes cron-state.json on startup with only { engine_status, last_heartbeat }. When tasks completed and tried to append to execution_log, they hit TypeError: Cannot read properties of undefined (reading 'push') — caught by the task's own catch block, making every successful run look like a failure. Fixed by merging stored state with complete defaults in readState() so execution_log is always an array. Also: openwolf binary lost execute bit on every build tsc does not preserve the +x bit. Added chmod +x to the build script and a postinstall hook so it is set automatically after every build and npm install -g. --- package.json | 3 +- pnpm-lock.yaml | 282 +++++++----------- src/cli/daemon-cmd.ts | 14 +- src/daemon/cron-engine.ts | 133 ++++----- src/daemon/file-watcher.ts | 4 +- src/daemon/wolf-daemon.ts | 118 +++++++- src/dashboard/app/App.tsx | 7 +- .../app/components/layout/Header.tsx | 71 ++++- .../app/components/layout/Sidebar.tsx | 7 +- .../app/components/panels/AISuggestions.tsx | 49 ++- .../app/components/panels/CronStatus.tsx | 27 +- .../app/components/panels/DesignQC.tsx | 79 ++++- .../app/components/panels/ProjectOverview.tsx | 7 +- .../app/components/panels/TokenUsage.tsx | 51 ++-- .../app/components/shared/LiveIndicator.tsx | 16 +- src/dashboard/app/hooks/useWolfData.ts | 9 +- src/dashboard/app/lib/wolf-client.ts | 10 + src/designqc/designqc-capture.ts | 46 +++ src/designqc/designqc-engine.ts | 53 ++-- 19 files changed, 635 insertions(+), 351 deletions(-) diff --git a/package.json b/package.json index 92ae06e..76d1f79 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ }, "scripts": { "prebuild": "node -e \"const fs=require('fs');if(fs.existsSync('dist'))fs.rmSync('dist',{recursive:true})\"", - "build": "tsc && pnpm build:hooks && pnpm build:dashboard", + "build": "tsc && pnpm build:hooks && pnpm build:dashboard && chmod +x dist/bin/openwolf.js", + "postinstall": "chmod +x dist/bin/openwolf.js 2>/dev/null || true", "build:hooks": "tsc -p tsconfig.hooks.json", "build:dashboard": "cd src/dashboard/app && npx vite build --outDir ../../../dist/dashboard", "dev": "tsc --watch", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff91407..38ec054 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,18 +20,12 @@ importers: express: specifier: ^5.0.0 version: 5.2.1 - glob: - specifier: ^11.0.0 - version: 11.1.0 node-cron: specifier: ^3.0.3 version: 3.0.3 open: specifier: ^10.0.0 version: 10.2.0 - puppeteer-core: - specifier: ^24.39.1 - version: 24.39.1 ws: specifier: ^8.18.0 version: 8.19.0 @@ -81,6 +75,10 @@ importers: vitepress: specifier: ^1.6.4 version: 1.6.4(@algolia/client-search@5.49.1)(@types/node@22.19.15)(@types/react@19.2.14)(lightningcss@1.31.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)(typescript@5.9.3) + optionalDependencies: + puppeteer-core: + specifier: ^24.39.1 + version: 24.39.1 packages: @@ -570,10 +568,6 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@isaacs/cliui@9.0.0': - resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} - engines: {node: '>=18'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1103,10 +1097,6 @@ packages: react-native-b4a: optional: true - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - bare-events@2.8.2: resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} peerDependencies: @@ -1161,10 +1151,6 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} - engines: {node: 18 || 20 || >=22} - browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1259,10 +1245,6 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1489,10 +1471,6 @@ packages: focus-trap@7.8.0: resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1533,12 +1511,6 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} - glob@11.1.0: - resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} - engines: {node: 20 || >=22} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1622,13 +1594,6 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jackspeak@4.2.3: - resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} - engines: {node: 20 || >=22} - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1727,10 +1692,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} - engines: {node: 20 || >=22} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1782,14 +1743,6 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} @@ -1849,21 +1802,10 @@ packages: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -2030,14 +1972,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} @@ -2057,10 +1991,6 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -2300,11 +2230,6 @@ packages: webdriver-bidi-protocol@0.4.1: resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2760,8 +2685,6 @@ snapshots: '@iconify/types@2.0.0': {} - '@isaacs/cliui@9.0.0': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2795,6 +2718,7 @@ snapshots: - bare-buffer - react-native-b4a - supports-color + optional: true '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -2981,7 +2905,8 @@ snapshots: tailwindcss: 4.2.1 vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) - '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': + optional: true '@types/babel__core@7.20.5': dependencies: @@ -3234,7 +3159,8 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - agent-base@7.1.4: {} + agent-base@7.1.4: + optional: true algoliasearch@5.49.1: dependencies: @@ -3253,21 +3179,24 @@ snapshots: '@algolia/requester-fetch': 5.49.1 '@algolia/requester-node-http': 5.49.1 - ansi-regex@5.0.1: {} + ansi-regex@5.0.1: + optional: true ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + optional: true ast-types@0.13.4: dependencies: tslib: 2.8.1 + optional: true - b4a@1.8.0: {} - - balanced-match@4.0.4: {} + b4a@1.8.0: + optional: true - bare-events@2.8.2: {} + bare-events@2.8.2: + optional: true bare-fs@4.5.5: dependencies: @@ -3279,12 +3208,15 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true - bare-os@3.8.0: {} + bare-os@3.8.0: + optional: true bare-path@3.0.0: dependencies: bare-os: 3.8.0 + optional: true bare-stream@2.8.1(bare-events@2.8.2): dependencies: @@ -3295,14 +3227,17 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true bare-url@2.3.2: dependencies: bare-path: 3.0.0 + optional: true baseline-browser-mapping@2.10.0: {} - basic-ftp@5.2.0: {} + basic-ftp@5.2.0: + optional: true birpc@2.9.0: {} @@ -3320,10 +3255,6 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@5.0.4: - dependencies: - balanced-match: 4.0.4 - browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.0 @@ -3332,7 +3263,8 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) - buffer-crc32@0.2.13: {} + buffer-crc32@0.2.13: + optional: true bundle-name@4.1.0: dependencies: @@ -3369,20 +3301,24 @@ snapshots: devtools-protocol: 0.0.1581282 mitt: 3.0.1 zod: 3.25.76 + optional: true cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + optional: true clsx@2.1.1: {} color-convert@2.0.1: dependencies: color-name: 1.1.4 + optional: true - color-name@1.1.4: {} + color-name@1.1.4: + optional: true comma-separated-tokens@2.0.3: {} @@ -3402,12 +3338,6 @@ snapshots: dependencies: is-what: 5.5.0 - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - csstype@3.2.3: {} d3-array@3.2.4: @@ -3448,7 +3378,8 @@ snapshots: d3-timer@3.0.1: {} - data-uri-to-buffer@6.0.2: {} + data-uri-to-buffer@6.0.2: + optional: true debug@4.4.3: dependencies: @@ -3470,6 +3401,7 @@ snapshots: ast-types: 0.13.4 escodegen: 2.1.0 esprima: 4.0.1 + optional: true depd@2.0.0: {} @@ -3481,7 +3413,8 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1581282: {} + devtools-protocol@0.0.1581282: + optional: true dom-helpers@5.2.1: dependencies: @@ -3500,13 +3433,15 @@ snapshots: emoji-regex-xs@1.0.0: {} - emoji-regex@8.0.0: {} + emoji-regex@8.0.0: + optional: true encodeurl@2.0.0: {} end-of-stream@1.4.5: dependencies: once: 1.4.0 + optional: true enhanced-resolve@5.20.0: dependencies: @@ -3589,14 +3524,18 @@ snapshots: esutils: 2.0.3 optionalDependencies: source-map: 0.6.1 + optional: true - esprima@4.0.1: {} + esprima@4.0.1: + optional: true - estraverse@5.3.0: {} + estraverse@5.3.0: + optional: true estree-walker@2.0.2: {} - esutils@2.0.3: {} + esutils@2.0.3: + optional: true etag@1.8.1: {} @@ -3607,6 +3546,7 @@ snapshots: bare-events: 2.8.2 transitivePeerDependencies: - bare-abort-controller + optional: true express@5.2.1: dependencies: @@ -3650,14 +3590,17 @@ snapshots: '@types/yauzl': 2.10.3 transitivePeerDependencies: - supports-color + optional: true fast-equals@5.4.0: {} - fast-fifo@1.3.2: {} + fast-fifo@1.3.2: + optional: true fd-slicer@1.1.0: dependencies: pend: 1.2.0 + optional: true fdir@6.5.0(picomatch@4.0.3): optionalDependencies: @@ -3678,11 +3621,6 @@ snapshots: dependencies: tabbable: 6.4.0 - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - forwarded@0.2.0: {} fresh@2.0.0: {} @@ -3694,7 +3632,8 @@ snapshots: gensync@1.0.0-beta.2: {} - get-caller-file@2.0.5: {} + get-caller-file@2.0.5: + optional: true get-intrinsic@1.3.0: dependencies: @@ -3717,6 +3656,7 @@ snapshots: get-stream@5.2.0: dependencies: pump: 3.0.4 + optional: true get-uri@6.0.5: dependencies: @@ -3725,15 +3665,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color - - glob@11.1.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 4.2.3 - minimatch: 10.2.4 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 2.0.2 + optional: true gopd@1.2.0: {} @@ -3781,6 +3713,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true https-proxy-agent@7.0.6: dependencies: @@ -3788,6 +3721,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true iconv-lite@0.7.2: dependencies: @@ -3797,13 +3731,15 @@ snapshots: internmap@2.0.3: {} - ip-address@10.1.0: {} + ip-address@10.1.0: + optional: true ipaddr.js@1.9.1: {} is-docker@3.0.0: {} - is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@3.0.0: + optional: true is-inside-container@1.0.0: dependencies: @@ -3817,12 +3753,6 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isexe@2.0.0: {} - - jackspeak@4.2.3: - dependencies: - '@isaacs/cliui': 9.0.0 - jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -3886,13 +3816,12 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@11.2.6: {} - lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lru-cache@7.18.3: {} + lru-cache@7.18.3: + optional: true magic-string@0.30.21: dependencies: @@ -3941,12 +3870,6 @@ snapshots: dependencies: mime-db: 1.54.0 - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.4 - - minipass@7.1.3: {} - minisearch@7.2.0: {} mitt@3.0.1: {} @@ -3957,7 +3880,8 @@ snapshots: negotiator@1.0.0: {} - netmask@2.0.2: {} + netmask@2.0.2: + optional: true node-cron@3.0.3: dependencies: @@ -4002,26 +3926,20 @@ snapshots: socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color + optional: true pac-resolver@7.0.1: dependencies: degenerator: 5.0.1 netmask: 2.0.2 - - package-json-from-dist@1.0.1: {} + optional: true parseurl@1.3.3: {} - path-key@3.1.1: {} - - path-scurry@2.0.2: - dependencies: - lru-cache: 11.2.6 - minipass: 7.1.3 - path-to-regexp@8.3.0: {} - pend@1.2.0: {} + pend@1.2.0: + optional: true perfect-debounce@1.0.0: {} @@ -4037,7 +3955,8 @@ snapshots: preact@10.28.4: {} - progress@2.0.3: {} + progress@2.0.3: + optional: true prop-types@15.8.1: dependencies: @@ -4064,13 +3983,16 @@ snapshots: socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color + optional: true - proxy-from-env@1.1.0: {} + proxy-from-env@1.1.0: + optional: true pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + optional: true puppeteer-core@24.39.1: dependencies: @@ -4088,6 +4010,7 @@ snapshots: - react-native-b4a - supports-color - utf-8-validate + optional: true qs@6.15.0: dependencies: @@ -4161,7 +4084,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 - require-directory@2.1.1: {} + require-directory@2.1.1: + optional: true rfdc@1.4.1: {} @@ -4216,7 +4140,8 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} + semver@7.7.4: + optional: true send@1.2.1: dependencies: @@ -4245,12 +4170,6 @@ snapshots: setprototypeof@1.2.0: {} - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - shiki@2.5.0: dependencies: '@shikijs/core': 2.5.0 @@ -4290,9 +4209,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - signal-exit@4.1.0: {} - - smart-buffer@4.2.0: {} + smart-buffer@4.2.0: + optional: true socks-proxy-agent@8.0.5: dependencies: @@ -4301,11 +4219,13 @@ snapshots: socks: 2.8.7 transitivePeerDependencies: - supports-color + optional: true socks@2.8.7: dependencies: ip-address: 10.1.0 smart-buffer: 4.2.0 + optional: true source-map-js@1.2.1: {} @@ -4326,12 +4246,14 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + optional: true stringify-entities@4.0.4: dependencies: @@ -4341,6 +4263,7 @@ snapshots: strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + optional: true superjson@2.2.6: dependencies: @@ -4363,6 +4286,7 @@ snapshots: - bare-abort-controller - bare-buffer - react-native-b4a + optional: true tar-stream@3.1.8: dependencies: @@ -4374,6 +4298,7 @@ snapshots: - bare-abort-controller - bare-buffer - react-native-b4a + optional: true teex@1.0.1: dependencies: @@ -4381,12 +4306,14 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true text-decoder@1.2.7: dependencies: b4a: 1.8.0 transitivePeerDependencies: - react-native-b4a + optional: true tiny-invariant@1.3.3: {} @@ -4399,7 +4326,8 @@ snapshots: trim-lines@3.0.1: {} - tslib@2.8.1: {} + tslib@2.8.1: + optional: true type-is@2.0.1: dependencies: @@ -4407,7 +4335,8 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typed-query-selector@2.12.1: {} + typed-query-selector@2.12.1: + optional: true typescript@5.9.3: {} @@ -4558,17 +4487,15 @@ snapshots: optionalDependencies: typescript: 5.9.3 - webdriver-bidi-protocol@0.4.1: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 + webdriver-bidi-protocol@0.4.1: + optional: true wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + optional: true wrappy@1.0.2: {} @@ -4578,11 +4505,13 @@ snapshots: dependencies: is-wsl: 3.1.1 - y18n@5.0.8: {} + y18n@5.0.8: + optional: true yallist@3.1.1: {} - yargs-parser@21.1.1: {} + yargs-parser@21.1.1: + optional: true yargs@17.7.2: dependencies: @@ -4593,12 +4522,15 @@ snapshots: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 + optional: true yauzl@2.10.0: dependencies: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + optional: true - zod@3.25.76: {} + zod@3.25.76: + optional: true zwitch@2.0.4: {} diff --git a/src/cli/daemon-cmd.ts b/src/cli/daemon-cmd.ts index 74a9d63..eb058ad 100644 --- a/src/cli/daemon-cmd.ts +++ b/src/cli/daemon-cmd.ts @@ -1,6 +1,5 @@ import { execSync } from "node:child_process"; import * as fs from "node:fs"; -import * as net from "node:net"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { findProjectRoot } from "../scanner/project-root.js"; @@ -81,12 +80,23 @@ export function daemonStart(): void { console.log("pm2 not found. Install with: pnpm add -g pm2"); return; } + + const port = getDashboardPort(); const name = getPm2Name(); + + // Stop any daemon already running on this port (from a different project) + const existingPid = findPidOnPort(port); + if (existingPid) { + killPid(existingPid); + // Also delete any stale PM2 entry for the old daemon + try { execSync(`pm2 delete /openwolf-/`, { stdio: "ignore" }); } catch {} + } + // Resolve daemon script relative to openwolf's install dir, not the target project const daemonScript = path.resolve(__dirname, "..", "daemon", "wolf-daemon.js"); try { - execSync(`pm2 start "${daemonScript}" --name ${name} --cwd "${projectRoot}" -- --env OPENWOLF_PROJECT_ROOT="${projectRoot}"`, { + execSync(`pm2 start "${daemonScript}" --name ${name} --cwd "${projectRoot}"`, { stdio: "inherit", env: { ...process.env, OPENWOLF_PROJECT_ROOT: projectRoot }, }); diff --git a/src/daemon/cron-engine.ts b/src/daemon/cron-engine.ts index 1acf5a5..07e37cf 100644 --- a/src/daemon/cron-engine.ts +++ b/src/daemon/cron-engine.ts @@ -1,8 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { execSync, spawnSync } from "node:child_process"; import cron from "node-cron"; -import { readJSON, writeJSON, readText, writeText, appendText } from "../utils/fs-safe.js"; +import { readJSON, writeJSON, readText, writeText } from "../utils/fs-safe.js"; import { scanProject } from "../scanner/anatomy-scanner.js"; import { detectWaste } from "../tracker/waste-detector.js"; import type { Logger } from "../utils/logger.js"; @@ -107,10 +106,9 @@ export class CronEngine { } private readState(): CronState { - return readJSON( - path.join(this.wolfDir, "cron-state.json"), - { last_heartbeat: null, engine_status: "running", execution_log: [], dead_letter_queue: [], upcoming: [] } - ); + const defaults: CronState = { last_heartbeat: null, engine_status: "running", execution_log: [], dead_letter_queue: [], upcoming: [] }; + const stored = readJSON>(path.join(this.wolfDir, "cron-state.json"), {}); + return { ...defaults, ...stored }; } private writeState(state: CronState): void { @@ -184,6 +182,12 @@ export class CronEngine { this.writeState(state); this.failureCounts.set(task.id, 0); + // Notify UI that this task has permanently failed + this.broadcast({ + type: "task_error", + task_id: task.id, + error: errorMsg, + }); } this.broadcast({ @@ -295,87 +299,74 @@ export class CronEngine { writeJSON(ledgerPath, ledger); } - private hasClaude(): boolean { - try { - const cmd = process.platform === "win32" ? "where claude" : "which claude"; - execSync(cmd, { stdio: "ignore" }); - return true; - } catch { - return false; - } - } - private async runAiTask(params: { prompt: string; context_files: string[] }): Promise { - if (!this.hasClaude()) { - throw new Error("Claude CLI not found. Install it from https://claude.ai/download or add it to PATH."); - } - + // Cap each context file at 20KB (tail = most recent content) + const MAX_CONTEXT_BYTES = 20 * 1024; const contextParts: string[] = []; for (const file of params.context_files) { const filePath = path.join(this.projectRoot, file); try { - contextParts.push(`--- ${file} ---\n${fs.readFileSync(filePath, "utf-8")}`); + let content = fs.readFileSync(filePath, "utf-8"); + if (Buffer.byteLength(content, "utf-8") > MAX_CONTEXT_BYTES) { + content = "...[truncated — showing most recent]\n" + content.slice(-MAX_CONTEXT_BYTES); + } + contextParts.push(`--- ${file} ---\n${content}`); } catch { contextParts.push(`--- ${file} --- (not found)`); } } const fullPrompt = `${params.prompt}\n\n---\nContext:\n${contextParts.join("\n\n")}`; + let result: string; + + if (!process.env.ANTHROPIC_API_KEY) { + throw new Error( + "ANTHROPIC_API_KEY is not set. AI tasks require a direct API key when running as a background daemon.\n" + + "Add this to your shell profile (~/.zshrc or ~/.zprofile):\n" + + " export ANTHROPIC_API_KEY=sk-ant-api03-..." + ); + } + result = await this.runViaApi(fullPrompt, process.env.ANTHROPIC_API_KEY); + + // Strip markdown code fences if present + const fenceMatch = result.match(/```[\w]*\n([\s\S]*?)\n```/s); + if (fenceMatch) result = fenceMatch[1].trim(); + // Write result to suggestions.json if it looks like JSON try { - // Use spawnSync to pipe prompt via stdin — avoids command-line length limits on Windows - // claude -p (no argument) reads prompt from stdin - // Strip ANTHROPIC_API_KEY so claude uses OAuth subscription credentials - // instead of a potentially depleted API key - const env = { ...process.env }; - delete env.ANTHROPIC_API_KEY; - - const proc = spawnSync("claude -p --output-format text", { - input: fullPrompt, - timeout: 120000, - encoding: "utf-8", - cwd: this.projectRoot, - env, - stdio: ["pipe", "pipe", "pipe"], - // shell: true needed on Windows so that claude.cmd is resolved - shell: true, - windowsHide: true, + const parsed = JSON.parse(result); + writeJSON(path.join(this.wolfDir, "suggestions.json"), { + generated_at: new Date().toISOString(), + ...parsed, }); - - if (proc.error) { - throw proc.error; - } - - if (proc.status !== 0) { - const stderr = proc.stderr?.trim(); - const stdout = proc.stdout?.trim(); - const errMsg = stderr || stdout || "Unknown error"; - throw new Error(`Exit code ${proc.status}: ${errMsg}`); - } - - let result = (proc.stdout || "").replace(/\r\n/g, "\n").trim(); - - // Strip markdown code fences if present (```markdown ... ``` or ```json ... ```) - const fenceMatch = result.match(/```[\w]*\n([\s\S]*?)\n```/); - if (fenceMatch) { - result = fenceMatch[1].trim(); + } catch { + // Cerebrum update (plain markdown) + if (result.includes("## User Preferences") || result.includes("## Key Learnings") || result.includes("# Cerebrum")) { + writeText(path.join(this.wolfDir, "cerebrum.md"), result); } + } + } - // Write result to suggestions.json if it looks like JSON - try { - const parsed = JSON.parse(result); - writeJSON(path.join(this.wolfDir, "suggestions.json"), { - generated_at: new Date().toISOString(), - ...parsed, - }); - } catch { - // Not JSON, might be a cerebrum update - if (result.includes("## User Preferences") || result.includes("## Key Learnings") || result.includes("# Cerebrum")) { - writeText(path.join(this.wolfDir, "cerebrum.md"), result); - } - } - } catch (err) { - throw new Error(`claude -p failed: ${err instanceof Error ? err.message : String(err)}`); + private async runViaApi(prompt: string, apiKey: string): Promise { + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "claude-haiku-4-5-20251001", + max_tokens: 2048, + messages: [{ role: "user", content: prompt }], + }), + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Anthropic API error ${response.status}: ${body.slice(0, 200)}`); } + const data = await response.json() as { content: Array<{ type: string; text: string }> }; + return data.content.find((b) => b.type === "text")?.text?.trim() ?? ""; } + } diff --git a/src/daemon/file-watcher.ts b/src/daemon/file-watcher.ts index 3588277..364c747 100644 --- a/src/daemon/file-watcher.ts +++ b/src/daemon/file-watcher.ts @@ -1,14 +1,13 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { watch } from "chokidar"; -import { readJSON } from "../utils/fs-safe.js"; import type { Logger } from "../utils/logger.js"; export function startFileWatcher( wolfDir: string, logger: Logger, broadcast: (msg: unknown) => void -): void { +): import("chokidar").FSWatcher { const watcher = watch(wolfDir, { ignoreInitial: true, ignored: [ @@ -62,4 +61,5 @@ export function startFileWatcher( }); logger.info("File watcher started on .wolf/"); + return watcher; } diff --git a/src/daemon/wolf-daemon.ts b/src/daemon/wolf-daemon.ts index 6a3c93f..aba14c3 100644 --- a/src/daemon/wolf-daemon.ts +++ b/src/daemon/wolf-daemon.ts @@ -4,17 +4,20 @@ import { fileURLToPath } from "node:url"; import express from "express"; import { WebSocketServer, WebSocket } from "ws"; import { findProjectRoot } from "../scanner/project-root.js"; -import { readJSON, writeJSON, readText } from "../utils/fs-safe.js"; +import { readJSON, writeJSON } from "../utils/fs-safe.js"; import { Logger } from "../utils/logger.js"; import { CronEngine } from "./cron-engine.js"; import { startFileWatcher } from "./file-watcher.js"; +import { DesignQCEngine } from "../designqc/designqc-engine.js"; +import { DEFAULT_VIEWPORTS } from "../designqc/designqc-types.js"; +import { getRegisteredProjects } from "../cli/registry.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Prefer explicit OPENWOLF_PROJECT_ROOT env (set by CLI commands) over cwd detection -const projectRoot = process.env.OPENWOLF_PROJECT_ROOT || findProjectRoot(); -const wolfDir = path.join(projectRoot, ".wolf"); +let projectRoot = process.env.OPENWOLF_PROJECT_ROOT || findProjectRoot(); +let wolfDir = path.join(projectRoot, ".wolf"); interface WolfConfig { openwolf: { @@ -102,9 +105,33 @@ function detectProjectMeta(): { name: string; description: string } { return { name, description }; } -const projectMeta = detectProjectMeta(); +let projectMeta = detectProjectMeta(); // API routes +app.get("/api/config", (_req, res) => { + res.json({ hasApiKey: !!process.env.ANTHROPIC_API_KEY }); +}); + +app.get("/api/projects", (_req, res) => { + res.json(getRegisteredProjects(true)); +}); + +app.post("/api/switch", (req, res) => { + const { root } = req.body as { root: string }; + if (!root || !fs.existsSync(path.join(root, ".wolf"))) { + res.status(400).json({ error: "Invalid project root" }); + return; + } + if (root === projectRoot) { + res.status(400).json({ error: "Already on this project" }); + return; + } + + res.json({ ok: true }); + // Hot-reload: no restart needed, switch project in-place + setImmediate(() => switchProject(root)); +}); + app.get("/api/health", (_req, res) => { const cronState = readJSON<{ engine_status: string; last_heartbeat: string | null; dead_letter_queue: unknown[] }>( path.join(wolfDir, "cron-state.json"), @@ -120,7 +147,7 @@ app.get("/api/health", (_req, res) => { uptime_seconds: Math.floor((Date.now() - startTime) / 1000), last_heartbeat: cronState.last_heartbeat, tasks: taskCount, - dead_letters: cronState.dead_letter_queue.length, + dead_letters: (cronState.dead_letter_queue ?? []).length, }); }); @@ -161,6 +188,35 @@ app.get("/api/designqc-report", (_req, res) => { res.json(report); }); +app.post("/api/designqc/run", (req, res) => { + const config = readJSON(path.join(wolfDir, "config.json"), { + openwolf: { + daemon: { port: 18790, log_level: "info" }, + dashboard: { enabled: true, port: 18791 }, + cron: { enabled: true, heartbeat_interval_minutes: 30 }, + }, + }); + const dc = (config.openwolf as any)?.designqc ?? {}; + const engine = new DesignQCEngine(wolfDir, projectRoot, { + devServerUrl: (req.body as any)?.url || undefined, + viewports: dc.viewports || DEFAULT_VIEWPORTS, + maxScreenshots: dc.max_screenshots || 16, + chromePath: dc.chrome_path ?? undefined, + quality: 70, + maxWidth: 1200, + }); + // Set a generous timeout for long captures (Chrome startup + multi-page) + res.setTimeout(120_000); + engine.capture() + .then((result) => { + res.json({ status: "ok", screenshots: result.screenshots.length, total_size_kb: result.totalSizeKB }); + }) + .catch((err) => { + logger.error(`DesignQC run failed: ${err}`); + res.status(500).json({ error: String(err) }); + }); +}); + // Trigger a cron task by ID app.post("/api/cron/run/:taskId", (req, res) => { const { taskId } = req.params; @@ -168,10 +224,11 @@ app.post("/api/cron/run/:taskId", (req, res) => { res.status(503).json({ error: "Cron engine not running" }); return; } - cronEngine.runTask(taskId).then(() => { - res.json({ status: "ok", task_id: taskId }); - }).catch((err) => { - res.status(500).json({ error: String(err) }); + // Return 202 immediately — task runs in background, result arrives via WebSocket/file-watcher + res.status(202).json({ status: "accepted", task_id: taskId }); + cronEngine.runTask(taskId).catch((err) => { + logger.error(`Manual task trigger failed for ${taskId}: ${err}`); + broadcast({ type: "task_error", task_id: taskId, error: String(err) }); }); }); @@ -285,7 +342,48 @@ if (config.openwolf.cron.enabled) { } // File watcher -startFileWatcher(wolfDir, logger, broadcast); +let fileWatcher = startFileWatcher(wolfDir, logger, broadcast); + +// Hot-switch project without restarting the process +function switchProject(newRoot: string): void { + const newWolfDir = path.join(newRoot, ".wolf"); + logger.info(`Switching project to: ${newRoot}`); + + // Stop existing subsystems + if (cronEngine) { cronEngine.stop(); cronEngine = null; } + fileWatcher.close(); + + // Update mutable state + projectRoot = newRoot; + wolfDir = newWolfDir; + projectMeta = detectProjectMeta(); + + // Restart subsystems for new project + if (config.openwolf.cron.enabled) { + cronEngine = new CronEngine(wolfDir, projectRoot, logger, broadcast); + cronEngine.start(); + } + fileWatcher = startFileWatcher(wolfDir, logger, broadcast); + + // Mark new project as running + const statePath = path.join(wolfDir, "cron-state.json"); + const state = readJSON>(statePath, {}); + state.engine_status = "running"; + state.last_heartbeat = new Date().toISOString(); + writeJSON(statePath, state); + + // Send full state to all connected dashboard clients + const wolfFiles = [ + "OPENWOLF.md", "identity.md", "cerebrum.md", "memory.md", "anatomy.md", + "config.json", "token-ledger.json", "buglog.json", + "cron-manifest.json", "cron-state.json", "designqc-report.json", "suggestions.json", + ]; + const files: Record = {}; + for (const file of wolfFiles) { + try { files[file] = fs.readFileSync(path.join(wolfDir, file), "utf-8"); } catch { files[file] = ""; } + } + broadcast({ type: "project_switched", project: { name: projectMeta.name, root: projectRoot }, files }); +} // Health heartbeat const heartbeatInterval = config.openwolf.cron.heartbeat_interval_minutes * 60 * 1000; diff --git a/src/dashboard/app/App.tsx b/src/dashboard/app/App.tsx index 2bc426e..6d7b668 100644 --- a/src/dashboard/app/App.tsx +++ b/src/dashboard/app/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, Suspense, lazy } from "react"; +import { useState, Suspense, lazy } from "react"; import { Sidebar } from "./components/layout/Sidebar.js"; import { Layout } from "./components/layout/Layout.js"; import { Header } from "./components/layout/Header.js"; @@ -64,13 +64,12 @@ export default function App() { -
+
}> {activePanel === "overview" && } {activePanel === "activity" && } diff --git a/src/dashboard/app/components/layout/Header.tsx b/src/dashboard/app/components/layout/Header.tsx index 0b055f7..38064dc 100644 --- a/src/dashboard/app/components/layout/Header.tsx +++ b/src/dashboard/app/components/layout/Header.tsx @@ -1,17 +1,79 @@ -import React from "react"; -import { LiveIndicator } from "../shared/LiveIndicator.js"; +import { useState, useEffect, useRef } from "react"; import type { Theme } from "../../hooks/useTheme.js"; +interface Project { root: string; name: string; } + interface HeaderProps { title: string; theme: Theme; onToggleTheme: () => void; + currentProject: string; } -export function Header({ title, theme, onToggleTheme }: HeaderProps) { +export function Header({ title, theme, onToggleTheme, currentProject }: HeaderProps) { + const [projects, setProjects] = useState([]); + const [open, setOpen] = useState(false); + const [switching, setSwitching] = useState(false); + const ref = useRef(null); + + useEffect(() => { + fetch("/api/projects").then(r => r.json()).then(setProjects).catch(() => {}); + }, []); + + // Reset switching state when the project actually changes + useEffect(() => { + setSwitching(false); + }, [currentProject]); + + useEffect(() => { + const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const switchProject = (root: string) => { + if (root === currentProject || switching) return; + setSwitching(true); + setOpen(false); + fetch("/api/switch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root }) }) + .catch(() => setSwitching(false)); + }; + + const otherProjects = projects.filter(p => p.root !== currentProject); + return (
-

{title}

+
+

{title}

+ {otherProjects.length > 0 && ( +
+ + {open && ( +
+
Switch project
+ {otherProjects.map(p => ( + + ))} +
+ )} +
+ )} +
-
); diff --git a/src/dashboard/app/components/layout/Sidebar.tsx b/src/dashboard/app/components/layout/Sidebar.tsx index f7eac75..b2fddda 100644 --- a/src/dashboard/app/components/layout/Sidebar.tsx +++ b/src/dashboard/app/components/layout/Sidebar.tsx @@ -1,6 +1,5 @@ import React from "react"; import { cn } from "../../lib/utils.js"; -import { StatusBadge } from "../shared/StatusBadge.js"; import type { Theme } from "../../hooks/useTheme.js"; const navItems = [ @@ -19,13 +18,12 @@ const navItems = [ interface SidebarProps { activePanel: string; onNavigate: (panel: string) => void; - daemonStatus: string; projectName: string; theme: Theme; onToggleTheme: () => void; } -export function Sidebar({ activePanel, onNavigate, daemonStatus, projectName, theme, onToggleTheme }: SidebarProps) { +export function Sidebar({ activePanel, onNavigate, projectName, theme, onToggleTheme }: SidebarProps) { return ( <> {/* Desktop sidebar */} @@ -48,9 +46,6 @@ export function Sidebar({ activePanel, onNavigate, daemonStatus, projectName, th {projectName && (

{projectName}

)} -
- -