From f241ac9e10d577b1fc79f1282c7a1402c5202025 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 29 Mar 2026 22:23:39 -0400 Subject: [PATCH 1/8] Add Vision Pro spatial tracker with nginx config Port the Conductor Tracker 3D visualization for Apple Vision Pro. Renders the ThreeJS scene as a surround environment with repos on a sphere shell around the viewer. Runs in a Safari window (shared space) so other apps stay visible, with opt-in immersive mode available. Includes nginx config for local HTTPS serving (required by WebXR). --- .gitignore | 3 + .nginx/nginx.conf | 45 ++ tracker3d-visionpro.html | 978 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 1026 insertions(+) create mode 100644 .nginx/nginx.conf create mode 100644 tracker3d-visionpro.html diff --git a/.gitignore b/.gitignore index c18dd8d..53f4390 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ __pycache__/ +.nginx/*.pem +.nginx/*.log +.nginx/*.pid diff --git a/.nginx/nginx.conf b/.nginx/nginx.conf new file mode 100644 index 0000000..82ce170 --- /dev/null +++ b/.nginx/nginx.conf @@ -0,0 +1,45 @@ +worker_processes 1; +error_log /Users/wk/conductor/conductor-cli/.conductor/florence-v1/.nginx/error.log; +pid /Users/wk/conductor/conductor-cli/.conductor/florence-v1/.nginx/nginx.pid; + +events { + worker_connections 64; +} + +http { + include /opt/homebrew/etc/nginx/mime.types; + default_type application/octet-stream; + + access_log /Users/wk/conductor/conductor-cli/.conductor/florence-v1/.nginx/access.log; + + # HTTPS server (port 8443) — needed for WebXR + server { + listen 8443 ssl; + server_name localhost; + + ssl_certificate /Users/wk/conductor/conductor-cli/.conductor/florence-v1/.nginx/cert.pem; + ssl_certificate_key /Users/wk/conductor/conductor-cli/.conductor/florence-v1/.nginx/key.pem; + + root /Users/wk/conductor/conductor-cli/.conductor/florence-v1; + index tracker3d-visionpro.html; + + location / { + try_files $uri $uri/ =404; + add_header Access-Control-Allow-Origin *; + } + } + + # HTTP server (port 8080) — for quick testing without SSL warnings + server { + listen 8080; + server_name localhost; + + root /Users/wk/conductor/conductor-cli/.conductor/florence-v1; + index tracker3d-visionpro.html; + + location / { + try_files $uri $uri/ =404; + add_header Access-Control-Allow-Origin *; + } + } +} diff --git a/tracker3d-visionpro.html b/tracker3d-visionpro.html new file mode 100644 index 0000000..4e271fe --- /dev/null +++ b/tracker3d-visionpro.html @@ -0,0 +1,978 @@ + + + + + + Conductor Tracker – Vision Pro + + + +
+ +
+ +
+

Conductor Tracker

+
Spatial Environment
+
Loading data...
+
+ +
+
Fresh
+
Recent
+
Aging
+
Stale
+
+ +
+ + + +
+ +
+

+
+
+ + + + + + + From c3afc1d3f502b8e7e45ca6510f8e99ebb6f018d3 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Mon, 30 Mar 2026 11:24:17 -0400 Subject: [PATCH 2/8] Add surround environment layout and visionOS native app spec Reworked the 3D tracker to place the camera inside the node graph looking outward (surround mode), with repos on a Fibonacci sphere shell. Added auto-orbit, layout toggle, and made immersive VR opt-in so Vision Pro users can keep other windows open. Wrote a full spec for a native visionOS RealityKit app that uses ImmersiveSpace with mixed immersion to render the tracker as a true spatial environment. --- .nginx/nginx.conf | 16 +- SPEC-visionos-tracker.md | 400 +++++++++++++++++++++++++++++++++++++++ tracker3d-visionpro.html | 13 +- 3 files changed, 415 insertions(+), 14 deletions(-) create mode 100644 SPEC-visionos-tracker.md diff --git a/.nginx/nginx.conf b/.nginx/nginx.conf index 82ce170..fc54961 100644 --- a/.nginx/nginx.conf +++ b/.nginx/nginx.conf @@ -1,26 +1,26 @@ worker_processes 1; -error_log /Users/wk/conductor/conductor-cli/.conductor/florence-v1/.nginx/error.log; -pid /Users/wk/conductor/conductor-cli/.conductor/florence-v1/.nginx/nginx.pid; +error_log NGINX_DIR/error.log; +pid NGINX_DIR/nginx.pid; events { worker_connections 64; } http { - include /opt/homebrew/etc/nginx/mime.types; + include MIME_TYPES_PATH; default_type application/octet-stream; - access_log /Users/wk/conductor/conductor-cli/.conductor/florence-v1/.nginx/access.log; + access_log NGINX_DIR/access.log; # HTTPS server (port 8443) — needed for WebXR server { listen 8443 ssl; server_name localhost; - ssl_certificate /Users/wk/conductor/conductor-cli/.conductor/florence-v1/.nginx/cert.pem; - ssl_certificate_key /Users/wk/conductor/conductor-cli/.conductor/florence-v1/.nginx/key.pem; + ssl_certificate NGINX_DIR/cert.pem; + ssl_certificate_key NGINX_DIR/key.pem; - root /Users/wk/conductor/conductor-cli/.conductor/florence-v1; + root PROJECT_ROOT; index tracker3d-visionpro.html; location / { @@ -34,7 +34,7 @@ http { listen 8080; server_name localhost; - root /Users/wk/conductor/conductor-cli/.conductor/florence-v1; + root PROJECT_ROOT; index tracker3d-visionpro.html; location / { diff --git a/SPEC-visionos-tracker.md b/SPEC-visionos-tracker.md new file mode 100644 index 0000000..814cc09 --- /dev/null +++ b/SPEC-visionos-tracker.md @@ -0,0 +1,400 @@ +# Conductor Tracker — visionOS Native App Spec + +## Overview + +A native visionOS app that renders the Conductor Tracker visualization as a **spatial environment** using RealityKit. The user is surrounded by their GitHub organization's repos and PRs as glowing nodes in space, while still being able to use other apps and windows (mixed immersion). + +This is a port of the existing Three.js web tracker (`tracker3d-visionpro.html`) to native Swift/RealityKit, specifically to unlock the visionOS **ImmersiveSpace** API — which allows rendering 3D content around the user without hiding other windows. + +--- + +## Target Platform + +- visionOS 2.0+ +- Swift 6 / SwiftUI +- RealityKit (not SceneKit, not Unity) +- No Conductor app dependency — standalone app that fetches data over HTTP or reads a static JSON file + +--- + +## Data Model + +The app consumes the same JSON schema as the web tracker. Data is fetched from one of: + +1. **HTTP API**: `http://:8765/api/tracker` (live Conductor server) +2. **Static JSON file**: bundled or user-provided URL via `?data=` equivalent +3. **Local file**: loaded from app documents directory + +### Root JSON Structure + +```json +{ + "organization": "string", + "generated_at": "ISO 8601", + "stale_minutes": 30, + "stats": { + "total_repos": 39, + "repos_with_prs": 20, + "total_open_prs": 80, + "total_open_issues": 181 + }, + "trees": { + "repo-name": { + "name": "repo-name", + "branch": "main", + "repo_name": "repo-name", + "github_url": "https://github.com/org/repo-name", + "last_updated": "ISO 8601", + "children": [ /* PR nodes */ ] + } + } +} +``` + +### PR Node Structure + +```json +{ + "name": "Display Name", + "branch": "author/feature-branch", + "repo_name": "repo-name", + "pr_number": 95, + "pr_title": "Fix the thing", + "pr_url": "https://github.com/org/repo/pull/95", + "pr_author": "username", + "last_updated": "ISO 8601", + "ci_status": "pass" | "fail" | "pending" | "", + "is_draft": false, + "labels": [], + "parent_branch_name": "main", + "children": [ /* stacked PRs */ ] +} +``` + +### Swift Types + +```swift +struct TrackerData: Codable { + let organization: String? + let generatedAt: String? + let staleMinutes: Int? + let stats: Stats? + let trees: [String: RepoTree] + + struct Stats: Codable { + let totalRepos: Int? + let reposWithPrs: Int? + let totalOpenPrs: Int? + let totalOpenIssues: Int? + } +} + +struct RepoTree: Codable { + let name: String + let branch: String? + let repoName: String? + let githubUrl: String? + let lastUpdated: String? + let children: [PRNode]? +} + +struct PRNode: Codable, Identifiable { + var id: String { workspaceId ?? "org-\(repoName ?? "")-\(prNumber ?? 0)" } + let workspaceId: String? + let name: String? + let branch: String? + let repoName: String? + let prNumber: Int? + let prTitle: String? + let prUrl: String? + let prAuthor: String? + let lastUpdated: String? + let ciStatus: String? + let isDraft: Bool? + let labels: [String]? + let parentBranchName: String? + let children: [PRNode]? +} +``` + +Use `CodingKeys` with `snake_case` conversion or `JSONDecoder.keyDecodingStrategy = .convertFromSnakeCase`. + +--- + +## Architecture + +``` +ConductorTracker/ +├── ConductorTrackerApp.swift // App entry, WindowGroup + ImmersiveSpace +├── Models/ +│ ├── TrackerData.swift // Codable types above +│ └── TrackerViewModel.swift // ObservableObject, data fetching, scoring +├── Views/ +│ ├── ContentView.swift // 2D window: settings, status, data URL config +│ ├── ImmersiveView.swift // RealityKit immersive space +│ └── NodeDetailView.swift // Attachment: PR detail popover +├── Entities/ +│ ├── RepoNodeEntity.swift // RealityKit entity for repo spheres +│ ├── PRNodeEntity.swift // RealityKit entity for PR spheres +│ ├── ConnectionLineEntity.swift // Lines between nodes +│ └── TextBillboard.swift // 3D text labels (MeshResource.generateText) +├── Utilities/ +│ ├── FreshnessColor.swift // Score → color mapping +│ └── SphereLayout.swift // Fibonacci sphere distribution +└── Resources/ + └── sample_data.json // Bundled fallback data for demo +``` + +--- + +## App Entry Point + +```swift +@main +struct ConductorTrackerApp: App { + @State private var immersionStyle: ImmersionStyle = .mixed + + var body: some Scene { + // 2D window for settings/status + WindowGroup { + ContentView() + } + + // Spatial environment + ImmersiveSpace(id: "tracker-environment") { + ImmersiveView() + } + .immersionStyle(selection: $immersionStyle, in: .mixed) + } +} +``` + +Key: `.mixed` immersion style means the 3D content renders in the user's space alongside their other windows. This is the critical difference from the web version. + +--- + +## Scene Layout + +### Surround Mode (Primary) + +Repos are placed on a **Fibonacci sphere shell** around the user's head position. + +```swift +func fibonacciSpherePosition(index: Int, total: Int, radius: Float) -> SIMD3 { + let golden = (1.0 + sqrt(5.0)) / 2.0 + let theta = 2.0 * .pi * Float(index) / Float(golden) + let phi = acos(1.0 - 2.0 * (Float(index) + 0.5) / Float(total)) + + // Clamp to a band ±40° from horizon (don't put repos above/below head) + let clampedPhi = 0.5 + (phi / .pi) * (.pi - 1.0) + + return SIMD3( + radius * sin(clampedPhi) * cos(theta), + radius * cos(clampedPhi), + radius * sin(clampedPhi) * sin(theta) + ) +} +``` + +**Parameters:** +- Sphere radius: **2.5 meters** (comfortable arm's-length viewing in spatial) +- Repo sphere diameter: **8 cm** +- PR node diameter: **5 cm** +- Connection line thickness: **2 mm** + +PR nodes branch **outward** from their repo, away from center. Stacked PRs extend further out. + +--- + +## Visual Design + +### Freshness Color Gradient + +Same 4-stop gradient as web version: + +| Score Range | Color | Hex | +|-------------|-------|-----| +| 0.0–0.33 | Fresh green | `#39FF14` | +| 0.33–0.66 | Recent yellow → aging orange | `#EAB308` → `#F97316` | +| 0.66–1.0 | Stale red | `#EF4444` | + +Score = `min(1, minutesSinceUpdate / staleMinutes)` + +Linear interpolation between stops. + +### Materials + +Use **PhysicallyBasedMaterial** with emissive for glow: + +```swift +var material = PhysicallyBasedMaterial() +material.baseColor = .init(tint: freshnessColor) +material.emissiveColor = .init(color: freshnessColor) +material.emissiveIntensity = 0.5 +``` + +For extra glow, add a slightly larger, transparent sphere behind each node (bloom effect). + +### Labels + +Use `MeshResource.generateText()` for repo names. For PR status badges, use small billboard attachments or text meshes. + +Labels should **face the user** (billboard behavior). RealityKit supports this via `BillboardComponent`. + +### CI Status Indicators + +Small colored ring or badge near each PR node: +- **Pass**: green ring + checkmark +- **Fail**: red ring + X +- **Pending**: yellow ring + dots +- **Unknown**: gray, no indicator + +### Connection Lines + +Thin cylinders or `MeshResource` lines from each PR node back to its parent repo/PR. Semi-transparent, colored to match the repo's average freshness. + +### Ambient Effects + +- Subtle particle system around the space (RealityKit `ParticleEmitterComponent`) +- Very slow rotation of the entire constellation (~0.3°/sec) for liveliness +- Optional: faint starfield on a large inverted sphere behind everything + +--- + +## Interaction + +### Gaze + Tap (Primary on Vision Pro) + +- **Look at a node**: Highlight (scale up slightly, increase emissive glow). Show a floating detail card as a SwiftUI **attachment** anchored to the entity. +- **Tap a node**: Open the PR URL in Safari via `openURL` environment action. +- **Pinch + drag**: Rotate the entire constellation around the user. +- **Zoom pinch**: Scale the constellation closer/further. + +### Detail Card (SwiftUI Attachment) + +When gazing at a PR node, show a small floating card: + +``` +┌──────────────────────────┐ +│ Fix the thing #95│ +│ author/feature-branch │ +│ Author: username │ +│ CI: ✓ pass Updated: 1d│ +│ [Open in GitHub →] │ +└──────────────────────────┘ +``` + +Use RealityKit's attachment API to anchor SwiftUI views to entities. + +### System Window (2D) + +The `WindowGroup` provides a small settings/control panel: +- Data source URL input +- Refresh button / auto-refresh toggle (interval: 60s) +- Organization name display +- Stats summary (repos, PRs, issues) +- "Enter Environment" button to open the ImmersiveSpace + +--- + +## Data Flow + +``` +┌──────────────────┐ +│ ContentView │ ← User sets data URL +│ (2D Window) │ +└────────┬─────────┘ + │ opens + ▼ +┌──────────────────┐ ┌─────────────────────┐ +│ ImmersiveView │────▶│ TrackerViewModel │ +│ (RealityKit) │ │ @Observable │ +└──────────────────┘ │ - fetchData() │ + │ - trackerData │ + │ - autoRefreshTimer │ + └──────────┬──────────┘ + │ HTTP GET + ▼ + ┌─────────────────────┐ + │ JSON endpoint │ + │ localhost:8765 or │ + │ bundled file │ + └─────────────────────┘ +``` + +Use `URLSession` for fetching. Decode with `JSONDecoder` (snake_case strategy). Auto-refresh every 60 seconds. On data update, diff the tree and animate node position/color changes rather than rebuilding the whole scene. + +--- + +## Settings Persistence + +Use `@AppStorage` or `UserDefaults` for: +- `dataSourceURL: String` — last used API/data URL +- `autoRefresh: Bool` — whether to poll +- `refreshInterval: TimeInterval` — polling interval (default 60s) +- `surroundRadius: Float` — user-preferred constellation size +- `rotationSpeed: Float` — ambient rotation speed (0 = off) + +--- + +## Networking + +The app does NOT require Conductor to be running. It just needs access to the JSON data. Options: + +1. **Local network**: Conductor server on Mac at `http://:8765/api/tracker` +2. **Static file**: Host `org_data.json` anywhere (GitHub Pages, S3, local nginx) +3. **Bundled demo**: Include `sample_data.json` in app bundle for offline demo +4. **Paste/share**: Accept a shared JSON file via the visionOS share sheet + +For local network access, the app needs the **Local Network** entitlement and `NSLocalNetworkUsageDescription` in Info.plist. For HTTP (non-HTTPS), add an App Transport Security exception for the local IP. + +--- + +## Performance Considerations + +- Target: 90 fps (visionOS requirement for comfort) +- Current dataset: ~80 PRs + 39 repos = ~120 entities — well within budget +- Use instanced rendering (`ModelComponent` with shared `MeshResource`) for node spheres +- Text labels: generate once, update only on data refresh +- Connection lines: use a single mesh with all line segments batched +- Particle effects: use built-in `ParticleEmitterComponent` (GPU-accelerated) + +--- + +## Build & Test + +### Requirements +- Xcode 16+ +- visionOS 2.0 SDK +- Apple Developer account (for device deployment) +- visionOS Simulator works for layout testing + +### Quick Start +```bash +# Clone the repo +git clone +cd ConductorTracker + +# Open in Xcode +open ConductorTracker.xcodeproj + +# Build for visionOS Simulator +# Product → Destination → Apple Vision Pro (Designed for visionOS) +# Cmd+R to run + +# For real device: connect via Developer Strap or wireless deployment +``` + +### Testing Without Live Data +The app bundles `sample_data.json` (snapshot of real org data). On first launch with no configured URL, it loads the bundled data so you can see the visualization immediately. + +--- + +## Future Extensions (Out of Scope for V1) + +- **SharePlay**: Multiple people viewing the same tracker in a shared space +- **Spatial audio**: Subtle chime when a PR's CI status changes +- **Hand gestures**: Grab and rearrange nodes, pin repos closer +- **Widget**: visionOS ornament showing stale PR count +- **Deep link**: `conductortracker://open?pr=org/repo/123` to highlight a specific PR +- **SQLite direct read**: Mount Conductor's local DB instead of HTTP (requires shared app group or file provider) diff --git a/tracker3d-visionpro.html b/tracker3d-visionpro.html index 4e271fe..51ba8d3 100644 --- a/tracker3d-visionpro.html +++ b/tracker3d-visionpro.html @@ -844,13 +844,14 @@

const panel = document.getElementById('detail-panel'); document.getElementById('detail-title').textContent = node.pr_title || node.name || node.branch; + const esc = s => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }; let body = ''; - if (node.pr_number) body += `
PR: #${node.pr_number}
`; - if (node.branch) body += `
Branch: ${node.branch}
`; - if (node.pr_author) body += `
Author: ${node.pr_author}
`; - if (node.ci_status) body += `
CI: ${node.ci_status}
`; - if (node.last_updated) body += `
Updated: ${getTimeText(node)} ago
`; - if (node.repo_name) body += `
Repo: ${node.repo_name}
`; + if (node.pr_number) body += `
PR: #${esc(String(node.pr_number))}
`; + if (node.branch) body += `
Branch: ${esc(node.branch)}
`; + if (node.pr_author) body += `
Author: ${esc(node.pr_author)}
`; + if (node.ci_status) body += `
CI: ${esc(node.ci_status)}
`; + if (node.last_updated) body += `
Updated: ${esc(getTimeText(node))} ago
`; + if (node.repo_name) body += `
Repo: ${esc(node.repo_name)}
`; document.getElementById('detail-body').innerHTML = body; panel.style.display = 'block'; From b1b72611bd345092d9e54814f6d9344e0cfb4897 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Mon, 30 Mar 2026 12:13:30 -0400 Subject: [PATCH 3/8] Add full HUD, nginx API proxy, and fix session status - Port full HUD from original tracker: status panel (working/idle/error from session_statuses), legend with gradient and CI badges, toolbar buttons (zen, fullscreen, orbit, layout, fit), footer with worktree count and session timer, cursor-following tooltip - Add nginx reverse proxy for /api/ -> localhost:8765 so Vision Pro only needs one HTTPS port - Fix status counts to use session_statuses instead of CI status - Add /api/tracker as first data source URL (same-origin via proxy) --- .nginx/nginx.conf | 19 +++ .nginx/setup.sh | 39 +++++ tracker3d-visionpro.html | 354 +++++++++++++++++++++++++++++++++------ 3 files changed, 359 insertions(+), 53 deletions(-) create mode 100755 .nginx/setup.sh diff --git a/.nginx/nginx.conf b/.nginx/nginx.conf index fc54961..608acf5 100644 --- a/.nginx/nginx.conf +++ b/.nginx/nginx.conf @@ -23,6 +23,16 @@ http { root PROJECT_ROOT; index tracker3d-visionpro.html; + # Reverse proxy the tracker API so Vision Pro only needs one HTTPS port + location /api/ { + proxy_pass http://127.0.0.1:8765/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_connect_timeout 3s; + proxy_read_timeout 10s; + add_header Access-Control-Allow-Origin *; + } + location / { try_files $uri $uri/ =404; add_header Access-Control-Allow-Origin *; @@ -37,6 +47,15 @@ http { root PROJECT_ROOT; index tracker3d-visionpro.html; + location /api/ { + proxy_pass http://127.0.0.1:8765/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_connect_timeout 3s; + proxy_read_timeout 10s; + add_header Access-Control-Allow-Origin *; + } + location / { try_files $uri $uri/ =404; add_header Access-Control-Allow-Origin *; diff --git a/.nginx/setup.sh b/.nginx/setup.sh new file mode 100755 index 0000000..58f8d0f --- /dev/null +++ b/.nginx/setup.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Detect mime.types location +if [ -f /opt/homebrew/etc/nginx/mime.types ]; then + MIME_TYPES_PATH="/opt/homebrew/etc/nginx/mime.types" +elif [ -f /usr/local/etc/nginx/mime.types ]; then + MIME_TYPES_PATH="/usr/local/etc/nginx/mime.types" +elif [ -f /etc/nginx/mime.types ]; then + MIME_TYPES_PATH="/etc/nginx/mime.types" +else + echo "Error: Could not find nginx mime.types. Install nginx or set MIME_TYPES_PATH manually." + exit 1 +fi + +# Generate self-signed cert if missing +if [ ! -f "$SCRIPT_DIR/cert.pem" ] || [ ! -f "$SCRIPT_DIR/key.pem" ]; then + echo "Generating self-signed SSL certificate..." + openssl req -x509 -newkey rsa:2048 -keyout "$SCRIPT_DIR/key.pem" \ + -out "$SCRIPT_DIR/cert.pem" -days 365 -nodes \ + -subj "/CN=localhost" 2>/dev/null + echo "Certificate created at $SCRIPT_DIR/cert.pem" +fi + +# Generate nginx.conf from template +sed -e "s|NGINX_DIR|$SCRIPT_DIR|g" \ + -e "s|PROJECT_ROOT|$PROJECT_ROOT|g" \ + -e "s|MIME_TYPES_PATH|$MIME_TYPES_PATH|g" \ + "$SCRIPT_DIR/nginx.conf" > "$SCRIPT_DIR/nginx.generated.conf" + +echo "Generated: $SCRIPT_DIR/nginx.generated.conf" +echo "" +echo "Start nginx with:" +echo " nginx -c $SCRIPT_DIR/nginx.generated.conf" +echo "" +echo "Then open: https://localhost:8443/tracker3d-visionpro.html" diff --git a/tracker3d-visionpro.html b/tracker3d-visionpro.html index 51ba8d3..98bae45 100644 --- a/tracker3d-visionpro.html +++ b/tracker3d-visionpro.html @@ -92,40 +92,144 @@ #vr-button:hover { background: rgba(57, 255, 20, 0.2); } #vr-button:disabled { border-color: #555; color: #555; background: transparent; cursor: default; } - #detail-panel { + /* Status panel (top-right) */ + #status-panel { position: absolute; - bottom: 90px; + top: 20px; right: 20px; z-index: 100; - background: rgba(10, 10, 15, 0.88); + background: rgba(10, 10, 15, 0.85); padding: 15px 20px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); - min-width: 260px; - max-width: 340px; - display: none; + min-width: 180px; } - #detail-panel h3 { font-size: 14px; color: #fff; margin-bottom: 8px; } - #detail-panel .detail-row { font-size: 12px; color: #aaa; margin-bottom: 4px; } - #detail-panel .detail-row span { color: #ddd; } + #status-panel h3 { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; + color: #888; + margin-bottom: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 8px; + } - #legend { + .status-item { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 14px; } + .status-icon { width: 12px; height: 12px; border-radius: 50%; } + .status-working { background: #39ff14; box-shadow: 0 0 10px #39ff14; } + .status-idle { background: #666; } + .status-error { background: #ef4444; } + + /* Legend panel (right side) */ + #legend-panel { position: absolute; - top: 20px; + bottom: 80px; right: 20px; z-index: 100; - background: rgba(10, 10, 15, 0.75); - padding: 12px 16px; + background: rgba(10, 10, 15, 0.85); + padding: 15px 20px; border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); + min-width: 200px; + } + + #legend-panel h3 { font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; + color: #888; + margin-bottom: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 8px; } - .legend-item { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } + .legend-section { margin-bottom: 12px; } + .legend-section:last-child { margin-bottom: 0; } + .legend-section h4 { font-size: 11px; color: #666; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; } + .legend-item { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; font-size: 13px; } .legend-dot { width: 10px; height: 10px; border-radius: 50%; } + + .legend-gradient { + width: 100%; + height: 12px; + border-radius: 6px; + background: linear-gradient(to right, #39ff14, #eab308, #f97316, #ef4444); + margin-bottom: 4px; + } + .legend-gradient-labels { display: flex; justify-content: space-between; font-size: 10px; color: #888; } + + .pr-badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; color: white; flex-shrink: 0; } + .pr-pass { background: rgba(34, 197, 94, 0.9); } + .pr-fail { background: rgba(239, 68, 68, 0.9); } + .pr-pending { background: rgba(234, 179, 8, 0.9); } + .pr-none { background: rgba(80, 80, 220, 0.9); } + + .time-badge { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 500; background: rgba(0,0,0,0.7); flex-shrink: 0; } + .time-fresh { color: #88ff88; } + .time-aging { color: #ffcc44; } + .time-stale { color: #ff6666; } + + /* Tooltip */ + #tooltip { + position: absolute; + background: rgba(20, 20, 30, 0.95); + padding: 12px 16px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + pointer-events: none; + display: none; + z-index: 1000; + max-width: 300px; + backdrop-filter: blur(10px); + } + + #tooltip .tooltip-title { font-weight: 600; font-size: 14px; color: #fff; margin-bottom: 6px; } + #tooltip .tooltip-repo { font-size: 11px; color: #888; margin-bottom: 8px; } + #tooltip .tooltip-time { font-size: 12px; color: #aaa; } + #tooltip .tooltip-status { display: flex; align-items: center; gap: 6px; margin-top: 6px; font-size: 12px; } + + /* Footer */ + #footer { + position: absolute; + bottom: 20px; + left: 20px; + right: 20px; + z-index: 100; + background: rgba(10, 10, 15, 0.85); + padding: 12px 20px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + display: flex; + justify-content: space-between; + align-items: center; + } + + #footer .tracking-info { font-size: 13px; color: #aaa; } + #footer .session-time { font-size: 13px; color: #888; } + + /* Toolbar buttons */ + .toolbar-btn { + position: absolute; + left: 20px; + z-index: 100; + background: rgba(10, 10, 15, 0.85); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #e0e0e0; + padding: 8px 14px; + border-radius: 10px; + cursor: pointer; + font-size: 13px; + display: flex; + gap: 6px; + align-items: center; + backdrop-filter: blur(10px); + } + .toolbar-btn.active { border-color: #39ff14; } + .toolbar-btn:hover { background: rgba(20, 20, 30, 0.95); } @@ -133,28 +237,93 @@ +
-

Conductor Tracker

+

CONDUCTOR WORKTREE TRACKER

Spatial Environment
Loading data...
-
-
Fresh
-
Recent
-
Aging
-
Stale
+ + + + + + + + +
+

Status

+
+
+
+ 0 working +
+
+
+ 0 idle +
+
+
+ 0 error +
+
+ +
+

Legend

+
+

Worktree Freshness

+
+
FreshStale
+
+
+

PR Label (above ball)

+
#123 ✓
CI Passing
+
#123 ✗
CI Failing
+
#123 ⋯
CI Pending
+
#123 ◌
CI Unknown
+
+
+

Time Indicator

+
5m
Recently active
+
2h
Getting stale
+
1d
Stale
+
+
+ +
- -
-
-

-
+ + + + +
+
+
+
+
@@ -217,7 +386,7 @@

const customUrl = getDataUrl(); const urls = customUrl ? [customUrl] - : ['http://localhost:8765/api/tracker', 'data/org_data.json']; + : ['/api/tracker', 'http://localhost:8765/api/tracker', 'data/org_data.json']; for (const url of urls) { try { @@ -730,7 +899,7 @@

scene.background = null; controls.autoRotate = false; - document.querySelectorAll('#info-panel, #legend, #controls-bar, #detail-panel') + document.querySelectorAll('#info-panel, #status-panel, #legend-panel, #controls-bar, #footer, #tooltip, .toolbar-btn') .forEach(el => el.style.display = 'none'); setupControllers(); @@ -744,9 +913,8 @@

scene.background = new THREE.Color(0x050510); controls.autoRotate = autoRotate; - document.getElementById('info-panel').style.display = ''; - document.getElementById('legend').style.display = ''; - document.getElementById('controls-bar').style.display = ''; + document.querySelectorAll('#info-panel, #status-panel, #legend-panel, #controls-bar, #footer, .toolbar-btn') + .forEach(el => el.style.display = ''); } function setupControllers() { @@ -813,6 +981,7 @@

if (intersects.length > 0) { highlightNode(intersects[0].object); + updateTooltip(e); renderer.domElement.style.cursor = 'pointer'; } else { clearHighlight(); @@ -838,24 +1007,6 @@

mesh.material.emissiveIntensity = 1.0; } mesh.scale.set(1.4, 1.4, 1.4); - - if (!isImmersive && mesh.userData.data) { - const node = mesh.userData.data; - const panel = document.getElementById('detail-panel'); - document.getElementById('detail-title').textContent = node.pr_title || node.name || node.branch; - - const esc = s => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }; - let body = ''; - if (node.pr_number) body += `
PR: #${esc(String(node.pr_number))}
`; - if (node.branch) body += `
Branch: ${esc(node.branch)}
`; - if (node.pr_author) body += `
Author: ${esc(node.pr_author)}
`; - if (node.ci_status) body += `
CI: ${esc(node.ci_status)}
`; - if (node.last_updated) body += `
Updated: ${esc(getTimeText(node))} ago
`; - if (node.repo_name) body += `
Repo: ${esc(node.repo_name)}
`; - - document.getElementById('detail-body').innerHTML = body; - panel.style.display = 'block'; - } } function clearHighlight() { @@ -865,10 +1016,38 @@

} hoveredNode.scale.set(1, 1, 1); hoveredNode = null; - if (!isImmersive) document.getElementById('detail-panel').style.display = 'none'; + document.getElementById('tooltip').style.display = 'none'; } } + function updateTooltip(e) { + if (!hoveredNode || isImmersive) return; + const tooltip = document.getElementById('tooltip'); + const node = hoveredNode.userData.data || hoveredNode.userData; + + const esc = s => { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }; + tooltip.querySelector('.tooltip-title').textContent = node.pr_title || node.name || node.branch || hoveredNode.userData.name || ''; + tooltip.querySelector('.tooltip-repo').textContent = node.repo_name || ''; + tooltip.querySelector('.tooltip-time').textContent = node.last_updated ? `Updated ${getTimeText(node)} ago` : ''; + + let statusHtml = ''; + if (node.ci_status === 'pass') statusHtml = '✓ CI Passing'; + else if (node.ci_status === 'fail') statusHtml = '✗ CI Failing'; + else if (node.ci_status === 'pending') statusHtml = '⋯ CI Pending'; + if (node.pr_number) statusHtml = `PR #${esc(String(node.pr_number))} ${statusHtml}`; + if (node.pr_author) statusHtml += statusHtml ? ` · ${esc(node.pr_author)}` : esc(node.pr_author); + tooltip.querySelector('.tooltip-status').innerHTML = statusHtml; + + tooltip.style.display = 'block'; + tooltip.style.left = (e.clientX + 16) + 'px'; + tooltip.style.top = (e.clientY + 16) + 'px'; + + // Keep tooltip on screen + const rect = tooltip.getBoundingClientRect(); + if (rect.right > window.innerWidth) tooltip.style.left = (e.clientX - rect.width - 8) + 'px'; + if (rect.bottom > window.innerHeight) tooltip.style.top = (e.clientY - rect.height - 8) + 'px'; + } + function onResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); @@ -918,9 +1097,15 @@

// UI controls // ======================================================================== + let zenMode = false; + const sessionStart = Date.now(); + function setupControls() { const btnRotate = document.getElementById('btn-rotate'); const btnLayout = document.getElementById('btn-layout'); + const btnZen = document.getElementById('btn-zen'); + const btnFull = document.getElementById('btn-fullscreen'); + const btnFit = document.getElementById('btn-fit'); btnRotate.addEventListener('click', () => { autoRotate = !autoRotate; @@ -930,10 +1115,9 @@

btnLayout.addEventListener('click', () => { surroundMode = !surroundMode; - btnLayout.textContent = surroundMode ? 'Layout: Surround' : 'Layout: Flat'; + btnLayout.querySelector('span:last-child').textContent = surroundMode ? 'Surround' : 'Flat'; btnLayout.classList.toggle('active', !surroundMode); - // Reset camera for new layout if (surroundMode) { camera.position.set(0, 1.0, 0.1); controls.target.set(0, 0, 0); @@ -941,9 +1125,72 @@

camera.position.set(0, 8, 15); controls.target.set(0, 0, 0); } - buildScene(); }); + + btnZen.addEventListener('click', () => { + zenMode = !zenMode; + btnZen.classList.toggle('active', zenMode); + const els = ['#info-panel', '#status-panel', '#legend-panel', '#footer', '#controls-bar', + '#btn-zen', '#btn-fullscreen', '#btn-rotate', '#btn-layout', '#btn-fit']; + if (zenMode) { + els.forEach(s => { const el = document.querySelector(s); if (el && el !== btnZen) el.style.display = 'none'; }); + btnZen.style.opacity = '0.3'; + } else { + els.forEach(s => { const el = document.querySelector(s); if (el) el.style.display = ''; }); + btnZen.style.opacity = ''; + } + }); + + btnFull.addEventListener('click', () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + btnFull.classList.add('active'); + } else { + document.exitFullscreen(); + btnFull.classList.remove('active'); + } + }); + + btnFit.addEventListener('click', () => { + // Compute bounding box of all nodes and fit camera + const box = new THREE.Box3(); + nodes.forEach(mesh => box.expandByObject(mesh)); + if (box.isEmpty()) return; + + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()).length(); + const dist = size / (2 * Math.tan(camera.fov * Math.PI / 360)); + + controls.target.copy(center); + camera.position.copy(center).add(new THREE.Vector3(0, dist * 0.3, dist)); + controls.update(); + }); + + // Session timer + setInterval(() => { + const min = Math.floor((Date.now() - sessionStart) / 60000); + if (min < 60) document.getElementById('session-duration').textContent = `${min}m`; + else document.getElementById('session-duration').textContent = `${Math.floor(min/60)}h ${min%60}m`; + }, 10000); + } + + function updateStatusCounts() { + if (!trackerData) return; + + const statuses = trackerData.session_statuses || {}; + let working = 0, idle = 0, error = 0; + + Object.values(statuses).forEach(s => { + if (s.status === 'working') working++; + else if (s.status === 'error') error++; + else idle++; + }); + + document.getElementById('working-count').textContent = working; + document.getElementById('idle-count').textContent = idle; + document.getElementById('error-count').textContent = error; + document.getElementById('worktree-count').textContent = trackerData.worktree_count || nodes.size; } // ======================================================================== @@ -959,6 +1206,7 @@

if (data) { trackerData = data; buildScene(); + updateStatusCounts(); const treeCount = Object.keys(data.trees || {}).length; let nodeCount = 0; From 44ec0759d8c0d4302461fdbbd204fc875a29bec8 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Mon, 30 Mar 2026 12:14:44 -0400 Subject: [PATCH 4/8] Gitignore generated nginx config --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 53f4390..4bc1e17 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ .nginx/*.pem .nginx/*.log .nginx/*.pid +.nginx/nginx.generated.conf From c2ce5dd87d9f504349969f7910920bf5ff7de80a Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Mon, 30 Mar 2026 12:20:52 -0400 Subject: [PATCH 5/8] Add commit avatars, auto-refresh, and clickable commit nodes - Render commit avatars along connection lines between repos and PRs using circular avatar sprites with texture caching - Add 5-second auto-refresh polling to keep data in sync with Conductor (only rebuilds scene when data changes) - Commits are hoverable (shows message, author, sha) and clickable (opens commit on GitHub) --- tracker3d-visionpro.html | 240 ++++++++++++++++++++++++++++++++++----- 1 file changed, 211 insertions(+), 29 deletions(-) diff --git a/tracker3d-visionpro.html b/tracker3d-visionpro.html index 98bae45..0941ebb 100644 --- a/tracker3d-visionpro.html +++ b/tracker3d-visionpro.html @@ -493,6 +493,134 @@

Time Indicator

return sprite; } + // ======================================================================== + // Avatar sprites for commits + // ======================================================================== + + const avatarTextureCache = new Map(); + const pendingAvatarMaterials = new Map(); + let commitNodes = []; + + function createCircularPlaceholder() { + const canvas = document.createElement('canvas'); + const size = 64; + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + ctx.beginPath(); + ctx.arc(size/2, size/2, size/2 - 2, 0, Math.PI * 2); + ctx.fillStyle = '#4488ff'; + ctx.fill(); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.stroke(); + return canvas; + } + + function createCircularAvatarTexture(img) { + const canvas = document.createElement('canvas'); + const size = 64; + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + ctx.beginPath(); + ctx.arc(size/2, size/2, size/2 - 2, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + ctx.drawImage(img, 0, 0, size, size); + ctx.beginPath(); + ctx.arc(size/2, size/2, size/2 - 2, 0, Math.PI * 2); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.stroke(); + const texture = new THREE.CanvasTexture(canvas); + texture.needsUpdate = true; + return texture; + } + + function createAvatarSprite(avatarUrl) { + const placeholderCanvas = createCircularPlaceholder(); + const placeholderTexture = new THREE.CanvasTexture(placeholderCanvas); + const material = new THREE.SpriteMaterial({ map: placeholderTexture, transparent: true }); + const sprite = new THREE.Sprite(material); + + if (avatarUrl && !avatarTextureCache.has(avatarUrl)) { + avatarTextureCache.set(avatarUrl, 'loading'); + pendingAvatarMaterials.set(avatarUrl, [material]); + + const sizedUrl = avatarUrl.includes('?') ? `${avatarUrl}&s=64` : `${avatarUrl}?s=64`; + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = function() { + const texture = createCircularAvatarTexture(img); + avatarTextureCache.set(avatarUrl, texture); + const pending = pendingAvatarMaterials.get(avatarUrl) || []; + pending.forEach(mat => { mat.map = texture; mat.needsUpdate = true; }); + pendingAvatarMaterials.delete(avatarUrl); + }; + img.onerror = function() { + avatarTextureCache.set(avatarUrl, 'error'); + pendingAvatarMaterials.delete(avatarUrl); + }; + img.src = sizedUrl; + } else if (avatarUrl && avatarTextureCache.get(avatarUrl) === 'loading') { + const pending = pendingAvatarMaterials.get(avatarUrl) || []; + pending.push(material); + pendingAvatarMaterials.set(avatarUrl, pending); + } else if (avatarUrl && avatarTextureCache.get(avatarUrl) !== 'error') { + material.map = avatarTextureCache.get(avatarUrl); + material.needsUpdate = true; + } + + return sprite; + } + + function createCommitPoints(x1, y1, z1, x2, y2, z2, commits, githubUrl, group) { + const numCommits = Math.min(commits.length, 10); + + for (let i = 0; i < numCommits; i++) { + const commit = commits[i]; + const t = (i + 1) / (numCommits + 1); + const cx = x1 + (x2 - x1) * t; + const cy = y1 + (y2 - y1) * t; + const cz = z1 + (z2 - z1) * t; + + const avatarUrl = commit.author_avatar_url; + let commitNode; + + if (avatarUrl) { + commitNode = createAvatarSprite(avatarUrl); + commitNode.scale.set(0.3, 0.3, 0.3); + } else { + const geo = new THREE.SphereGeometry(0.1, 12, 12); + const mat = new THREE.MeshBasicMaterial({ color: 0x4488ff }); + commitNode = new THREE.Mesh(geo, mat); + } + commitNode.position.set(cx, cy, cz); + commitNode.userData = { + type: 'commit', + commit: commit, + github_url: commit.github_url, + hasAvatar: !!avatarUrl + }; + group.add(commitNode); + commitNodes.push(commitNode); + } + + if (commits.length > numCommits) { + const label = createTextSprite(`+${commits.length - numCommits} more`, { + fontSize: 12, bgColor: 'rgba(100,100,200,0.7)', padding: 8, + }); + label.position.set( + (x1 + x2) / 2, + (y1 + y2) / 2 + 0.25, + (z1 + z2) / 2 + ); + label.scale.set(0.5, 0.25, 1); + group.add(label); + } + } + // ======================================================================== // Scene building // ======================================================================== @@ -520,6 +648,7 @@

Time Indicator

keep.forEach(c => sceneRoot.add(c)); nodes.clear(); repoGroups.clear(); + commitNodes = []; } function buildScene() { @@ -700,6 +829,12 @@

Time Indicator

}); group.add(new THREE.Line(lineGeo, lineMat)); + // Commit avatars along the connection line + const commits = node.commits_from_parent || []; + if (commits.length > 0) { + createCommitPoints(parentX, parentY, parentZ, x, y, z, commits, githubUrl, group); + } + // Name label const displayName = node.pr_title || node.name || node.branch; if (displayName) { @@ -977,6 +1112,7 @@

Time Indicator

raycaster.setFromCamera(pointer, camera); const allMeshes = []; nodes.forEach(m => allMeshes.push(m)); + commitNodes.forEach(m => allMeshes.push(m)); const intersects = raycaster.intersectObjects(allMeshes, false); if (intersects.length > 0) { @@ -990,10 +1126,13 @@

Time Indicator

} function onPointerDown(e) { - if (hoveredNode && hoveredNode.userData.data) { - const node = hoveredNode.userData.data; - const url = node.pr_url || hoveredNode.userData.githubUrl; - if (url && e.button === 0) window.open(url, '_blank'); + if (!hoveredNode || e.button !== 0) return; + const ud = hoveredNode.userData; + if (ud.type === 'commit' && ud.github_url) { + window.open(ud.github_url, '_blank'); + } else if (ud.data) { + const url = ud.data.pr_url || ud.githubUrl; + if (url) window.open(url, '_blank'); } } @@ -1023,20 +1162,30 @@

Time Indicator

function updateTooltip(e) { if (!hoveredNode || isImmersive) return; const tooltip = document.getElementById('tooltip'); - const node = hoveredNode.userData.data || hoveredNode.userData; + const ud = hoveredNode.userData; const esc = s => { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }; - tooltip.querySelector('.tooltip-title').textContent = node.pr_title || node.name || node.branch || hoveredNode.userData.name || ''; - tooltip.querySelector('.tooltip-repo').textContent = node.repo_name || ''; - tooltip.querySelector('.tooltip-time').textContent = node.last_updated ? `Updated ${getTimeText(node)} ago` : ''; - - let statusHtml = ''; - if (node.ci_status === 'pass') statusHtml = '✓ CI Passing'; - else if (node.ci_status === 'fail') statusHtml = '✗ CI Failing'; - else if (node.ci_status === 'pending') statusHtml = '⋯ CI Pending'; - if (node.pr_number) statusHtml = `PR #${esc(String(node.pr_number))} ${statusHtml}`; - if (node.pr_author) statusHtml += statusHtml ? ` · ${esc(node.pr_author)}` : esc(node.pr_author); - tooltip.querySelector('.tooltip-status').innerHTML = statusHtml; + + if (ud.type === 'commit' && ud.commit) { + const c = ud.commit; + tooltip.querySelector('.tooltip-title').textContent = c.message || c.short_sha; + tooltip.querySelector('.tooltip-repo').textContent = `${c.author || ''} · ${c.short_sha || ''}`; + tooltip.querySelector('.tooltip-time').textContent = c.date || ''; + tooltip.querySelector('.tooltip-status').innerHTML = ''; + } else { + const node = ud.data || ud; + tooltip.querySelector('.tooltip-title').textContent = node.pr_title || node.name || node.branch || ud.name || ''; + tooltip.querySelector('.tooltip-repo').textContent = node.repo_name || ''; + tooltip.querySelector('.tooltip-time').textContent = node.last_updated ? `Updated ${getTimeText(node)} ago` : ''; + + let statusHtml = ''; + if (node.ci_status === 'pass') statusHtml = '✓ CI Passing'; + else if (node.ci_status === 'fail') statusHtml = '✗ CI Failing'; + else if (node.ci_status === 'pending') statusHtml = '⋯ CI Pending'; + if (node.pr_number) statusHtml = `PR #${esc(String(node.pr_number))} ${statusHtml}`; + if (node.pr_author) statusHtml += statusHtml ? ` · ${esc(node.pr_author)}` : esc(node.pr_author); + tooltip.querySelector('.tooltip-status').innerHTML = statusHtml; + } tooltip.style.display = 'block'; tooltip.style.left = (e.clientX + 16) + 'px'; @@ -1197,28 +1346,61 @@

Time Indicator

// Init // ======================================================================== + const REFRESH_INTERVAL = 5000; // 5 seconds + let lastDataHash = ''; + + function hashData(data) { + // Quick fingerprint: tree keys + node count + session statuses + const treeKeys = Object.keys(data.trees || {}).sort().join(','); + const statuses = JSON.stringify(data.session_statuses || {}); + let nodeCount = 0; + for (const tree of Object.values(data.trees || {})) { + const count = (arr) => { if (!arr) return; for (const n of arr) { nodeCount++; if (n.children) count(n.children); } }; + count(tree.children); + } + return `${treeKeys}|${nodeCount}|${statuses}`; + } + + async function refreshData() { + const data = await fetchData(); + if (!data) return; + + const newHash = hashData(data); + const treeChanged = lastDataHash !== '' && newHash !== lastDataHash; + const firstLoad = lastDataHash === ''; + lastDataHash = newHash; + + trackerData = data; + updateStatusCounts(); + + // Only rebuild the 3D scene if the tree structure actually changed + if (firstLoad || treeChanged) { + buildScene(); + } + + const treeCount = Object.keys(data.trees || {}).length; + let nodeCount = 0; + nodes.forEach(() => nodeCount++); + const org = data.organization ? `${data.organization} · ` : ''; + document.getElementById('status-text').textContent = + `${org}${treeCount} repos · ${nodeCount} PRs`; + } + async function init() { initScene(); setupControls(); renderer.setAnimationLoop(animate); - const data = await fetchData(); - if (data) { - trackerData = data; - buildScene(); - updateStatusCounts(); - - const treeCount = Object.keys(data.trees || {}).length; - let nodeCount = 0; - nodes.forEach(() => nodeCount++); - const org = data.organization ? `${data.organization} · ` : ''; - document.getElementById('status-text').textContent = - `${org}${treeCount} repos · ${nodeCount} PRs`; - } else { + await refreshData(); + + if (!trackerData) { document.getElementById('status-text').textContent = 'No data found — use ?data=path/to/file.json'; } await setupXR(); + + // Auto-refresh every 5s to stay in sync + setInterval(refreshData, REFRESH_INTERVAL); } init(); From 2792ed8c95b2377155e5a128c1c07813cf907fef Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Mon, 30 Mar 2026 17:45:28 -0400 Subject: [PATCH 6/8] Add hide/show menu, zen badge, and sticky API source - Add backtick-toggled hide/show menu for repos (A-Z) and workspaces (a-z), with click support and 0 to show all - Zen mode now shows minimal working count badge in top-right - Lock onto live API source after first successful fetch to prevent flipping between Conductor and static BreadCoop data - Increase fetch timeout from 3s to 10s for reliability - Filter hidden repos/workspaces from scene building --- tracker3d-visionpro.html | 300 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 294 insertions(+), 6 deletions(-) diff --git a/tracker3d-visionpro.html b/tracker3d-visionpro.html index 0941ebb..fc32552 100644 --- a/tracker3d-visionpro.html +++ b/tracker3d-visionpro.html @@ -230,6 +230,88 @@ } .toolbar-btn.active { border-color: #39ff14; } .toolbar-btn:hover { background: rgba(20, 20, 30, 0.95); } + + #hide-menu { + display: none; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 200; + background: rgba(10, 10, 20, 0.95); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 16px; + backdrop-filter: blur(15px); + padding: 24px 28px; + min-width: 380px; + max-height: 80vh; + overflow-y: auto; + } + #hide-menu h2 { + font-size: 16px; + font-weight: 600; + color: #fff; + margin-bottom: 16px; + } + #hide-menu .menu-section { margin-bottom: 16px; } + #hide-menu .menu-section h4 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #666; + margin-bottom: 8px; + } + #hide-menu .menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s; + } + #hide-menu .menu-item:hover { background: rgba(255,255,255,0.06); } + #hide-menu .menu-item.hidden { opacity: 0.4; } + #hide-menu .menu-item .key { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; height: 22px; + border-radius: 4px; + background: rgba(255,255,255,0.08); + font-size: 11px; + font-weight: 600; + color: #aaa; + flex-shrink: 0; + } + #hide-menu .menu-item .name { + flex: 1; + font-size: 13px; + color: #ddd; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + #hide-menu .menu-item .status-badge { + font-size: 10px; + padding: 2px 8px; + border-radius: 4px; + font-weight: 600; + } + #hide-menu .menu-item .status-badge.visible { background: rgba(57,255,20,0.15); color: #39ff14; } + #hide-menu .menu-item .status-badge.hidden { background: rgba(239,68,68,0.15); color: #ef4444; } + #hide-menu .close-hint { + margin-top: 12px; + font-size: 11px; + color: #555; + text-align: center; + } + #hide-menu .close-hint kbd { + background: rgba(255,255,255,0.08); + padding: 1px 6px; + border-radius: 3px; + font-size: 11px; + } @@ -312,10 +394,34 @@

Time Indicator

+ + + + + +
+

Hide/Show Workspaces & Repos

+ + +
Press ` or Esc to close | 0 show all
@@ -370,6 +476,9 @@

Time Indicator

let autoRotate = true; let surroundMode = true; // true = sphere shell, false = flat circle + let hiddenRepos = new Set(); + let hiddenWorkspaces = new Set(); + let hideMenuOpen = false; // ======================================================================== // Data loading @@ -382,8 +491,27 @@

Time Indicator

return null; } + let liveSourceUrl = null; // once we find a live source, stick with it + async function fetchData() { const customUrl = getDataUrl(); + + // If we've already found a working live source, only try that + if (liveSourceUrl) { + try { + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), 10000); + const resp = await fetch(liveSourceUrl, { signal: ctrl.signal }); + clearTimeout(tid); + if (resp.ok) { + const data = await resp.json(); + if (!data.loading) return data; + } + } catch { /* fall through to full search */ } + // Live source failed — clear it and try all sources + liveSourceUrl = null; + } + const urls = customUrl ? [customUrl] : ['/api/tracker', 'http://localhost:8765/api/tracker', 'data/org_data.json']; @@ -391,12 +519,14 @@

Time Indicator

for (const url of urls) { try { const ctrl = new AbortController(); - const tid = setTimeout(() => ctrl.abort(), 3000); + const tid = setTimeout(() => ctrl.abort(), 8000); const resp = await fetch(url, { signal: ctrl.signal }); clearTimeout(tid); if (!resp.ok) continue; const data = await resp.json(); if (data.loading) continue; + // Lock onto this source if it's a live API (not static file) + if (url.includes('/api/')) liveSourceUrl = url; return data; } catch { /* try next */ } } @@ -656,7 +786,7 @@

Time Indicator

clearScene(); const trees = trackerData.trees; - const repoNames = Object.keys(trees); + const repoNames = Object.keys(trees).filter(name => !hiddenRepos.has(name)); const numRepos = repoNames.length; repoNames.forEach((repoName, i) => { @@ -771,11 +901,17 @@

Time Indicator

} function buildNodes(nodeList, group, parentX, parentY, parentZ, depth, baseAngle, githubUrl, outDir) { + // Filter out hidden workspaces + const visible = nodeList.filter(node => { + const wsId = node.workspace_id || `org-${node.repo_name || ''}-${node.pr_number || node.branch}`; + return !hiddenWorkspaces.has(wsId); + }); + const maxSpread = Math.PI * 0.8; - const totalSpread = Math.min((nodeList.length - 1) * 0.5, maxSpread); - const spreadFactor = nodeList.length > 1 ? totalSpread / (nodeList.length - 1) : 0; + const totalSpread = Math.min((visible.length - 1) * 0.5, maxSpread); + const spreadFactor = visible.length > 1 ? totalSpread / (visible.length - 1) : 0; - nodeList.forEach((node, idx) => { + visible.forEach((node, idx) => { const angleOffset = (idx - (nodeList.length - 1) / 2) * spreadFactor; const nodeAngle = baseAngle + angleOffset; const distance = CONFIG.prSpacing; @@ -1285,9 +1421,12 @@

Time Indicator

if (zenMode) { els.forEach(s => { const el = document.querySelector(s); if (el && el !== btnZen) el.style.display = 'none'; }); btnZen.style.opacity = '0.3'; + document.getElementById('zen-badge').style.display = 'block'; + updateZenBadge(); } else { els.forEach(s => { const el = document.querySelector(s); if (el) el.style.display = ''; }); btnZen.style.opacity = ''; + document.getElementById('zen-badge').style.display = 'none'; } }); @@ -1324,6 +1463,145 @@

Time Indicator

}, 10000); } + // ======================================================================== + // Hide/Show menu + // ======================================================================== + + function openHideMenu() { + hideMenuOpen = true; + updateHideMenu(); + document.getElementById('hide-menu').style.display = 'block'; + } + + function closeHideMenu() { + hideMenuOpen = false; + document.getElementById('hide-menu').style.display = 'none'; + } + + function collectAllWorkspaces(node, list) { + if (node.workspace_id) list.push(node); + if (node.children) node.children.forEach(c => collectAllWorkspaces(c, list)); + } + + function updateHideMenu() { + if (!trackerData) return; + + const reposList = document.getElementById('repos-list'); + const workspacesList = document.getElementById('workspaces-list'); + + const repos = Object.keys(trackerData.trees || {}).sort(); + reposList.innerHTML = repos.map((name, i) => { + const key = String.fromCharCode(65 + i); + const isHidden = hiddenRepos.has(name); + return ` + `; + }).join(''); + + const workspaces = []; + Object.values(trackerData.trees || {}).forEach(tree => collectAllWorkspaces(tree, workspaces)); + workspaces.sort((a, b) => (a.name || a.branch || '').localeCompare(b.name || b.branch || '')); + + workspacesList.innerHTML = workspaces.slice(0, 26).map((ws, i) => { + const key = String.fromCharCode(97 + i); + const wsId = ws.workspace_id || `org-${ws.repo_name || ''}-${ws.pr_number || ws.branch}`; + const isHidden = hiddenWorkspaces.has(wsId); + const displayName = ws.pr_title || ws.name || ws.branch || wsId; + return ` + `; + }).join(''); + + // Click handlers + reposList.querySelectorAll('.menu-item').forEach(item => { + item.addEventListener('click', () => { + const name = item.dataset.name; + if (hiddenRepos.has(name)) hiddenRepos.delete(name); + else hiddenRepos.add(name); + updateHideMenu(); + buildScene(); + updateHiddenInfo(); + }); + }); + + workspacesList.querySelectorAll('.menu-item').forEach(item => { + item.addEventListener('click', () => { + const id = item.dataset.id; + if (hiddenWorkspaces.has(id)) hiddenWorkspaces.delete(id); + else hiddenWorkspaces.add(id); + updateHideMenu(); + buildScene(); + updateHiddenInfo(); + }); + }); + } + + function handleHideMenuKey(key) { + if (!trackerData) return; + + const repos = Object.keys(trackerData.trees || {}).sort(); + const workspaces = []; + Object.values(trackerData.trees || {}).forEach(tree => collectAllWorkspaces(tree, workspaces)); + workspaces.sort((a, b) => (a.name || a.branch || '').localeCompare(b.name || b.branch || '')); + + if (key >= 'A' && key <= 'Z') { + const index = key.charCodeAt(0) - 65; + if (index < repos.length) { + const name = repos[index]; + if (hiddenRepos.has(name)) hiddenRepos.delete(name); + else hiddenRepos.add(name); + updateHideMenu(); + buildScene(); + updateHiddenInfo(); + } + } + + if (key >= 'a' && key <= 'z') { + const index = key.charCodeAt(0) - 97; + if (index < workspaces.length) { + const ws = workspaces[index]; + const wsId = ws.workspace_id || `org-${ws.repo_name || ''}-${ws.pr_number || ws.branch}`; + if (hiddenWorkspaces.has(wsId)) hiddenWorkspaces.delete(wsId); + else hiddenWorkspaces.add(wsId); + updateHideMenu(); + buildScene(); + updateHiddenInfo(); + } + } + } + + function updateHiddenInfo() { + const total = hiddenRepos.size + hiddenWorkspaces.size; + const el = document.getElementById('hidden-info'); + el.textContent = total > 0 ? `| ${total} hidden` : ''; + } + + function setupHideMenuKeyboard() { + window.addEventListener('keydown', (e) => { + if (e.key === '`') { + e.preventDefault(); + if (hideMenuOpen) closeHideMenu(); + else openHideMenu(); + } else if (e.key === 'Escape' && hideMenuOpen) { + closeHideMenu(); + } else if (e.key === '0' && hideMenuOpen) { + hiddenRepos.clear(); + hiddenWorkspaces.clear(); + updateHideMenu(); + buildScene(); + updateHiddenInfo(); + } else if (hideMenuOpen) { + handleHideMenuKey(e.key); + } + }); + } + function updateStatusCounts() { if (!trackerData) return; @@ -1340,6 +1618,15 @@

Time Indicator

document.getElementById('idle-count').textContent = idle; document.getElementById('error-count').textContent = error; document.getElementById('worktree-count').textContent = trackerData.worktree_count || nodes.size; + updateZenBadge(); + } + + function updateZenBadge() { + if (!trackerData) return; + const statuses = trackerData.session_statuses || {}; + let working = 0; + Object.values(statuses).forEach(s => { if (s.status === 'working') working++; }); + document.getElementById('zen-working-count').textContent = working; } // ======================================================================== @@ -1389,6 +1676,7 @@

Time Indicator

async function init() { initScene(); setupControls(); + setupHideMenuKeyboard(); renderer.setAnimationLoop(animate); await refreshData(); From 992ed10db6fdee308280800170cf13c17e4ca62b Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Mon, 30 Mar 2026 17:57:31 -0400 Subject: [PATCH 7/8] Fix data source flipping and make server threaded - Keep API source lock on transient failures instead of clearing it and falling back to static BreadCoop data - Remove data/org_data.json from fallback URLs entirely - Make tracker server threaded (ThreadingTCPServer) so concurrent requests don't block and cause timeouts --- tracker3d-visionpro.html | 7 +++---- tracker_server.py | 5 +++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tracker3d-visionpro.html b/tracker3d-visionpro.html index fc32552..f7a7d32 100644 --- a/tracker3d-visionpro.html +++ b/tracker3d-visionpro.html @@ -507,14 +507,13 @@

Workspaces

const data = await resp.json(); if (!data.loading) return data; } - } catch { /* fall through to full search */ } - // Live source failed — clear it and try all sources - liveSourceUrl = null; + } catch { /* transient failure — keep the lock, just skip this cycle */ } + return null; } const urls = customUrl ? [customUrl] - : ['/api/tracker', 'http://localhost:8765/api/tracker', 'data/org_data.json']; + : ['/api/tracker', 'http://localhost:8765/api/tracker']; for (const url of urls) { try { diff --git a/tracker_server.py b/tracker_server.py index 6802c13..fda520d 100644 --- a/tracker_server.py +++ b/tracker_server.py @@ -1167,9 +1167,10 @@ def log_message(self, format, *args): pass -class ReusableTCPServer(socketserver.TCPServer): - """TCP server that allows address reuse to avoid 'Address already in use' errors.""" +class ReusableTCPServer(socketserver.ThreadingTCPServer): + """Threaded TCP server that allows address reuse and concurrent requests.""" allow_reuse_address = True + daemon_threads = True def run_server(port: int = 8765): From 5a396d9b6d5241e97a42b95f42a9e1575605e01e Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Tue, 31 Mar 2026 20:08:59 -0400 Subject: [PATCH 8/8] Add WebSocket real-time updates with HTTP polling fallback - Add WebSocket broadcaster to tracker_server.py that polls TrackerState every 3s and pushes to clients only on changes - Client connects via wss:// through nginx proxy, falls back to direct ws://localhost:8766, then HTTP polling as last resort - Nginx config proxies /ws/ to WebSocket server on port 8766 - Supports refresh_ci action over WebSocket - Graceful reconnect with automatic fallback chain --- .nginx/nginx.conf | 24 ++++++++++ tracker3d-visionpro.html | 96 +++++++++++++++++++++++++++++++++---- tracker_server.py | 101 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 210 insertions(+), 11 deletions(-) diff --git a/.nginx/nginx.conf b/.nginx/nginx.conf index 608acf5..de4e5b5 100644 --- a/.nginx/nginx.conf +++ b/.nginx/nginx.conf @@ -33,6 +33,18 @@ http { add_header Access-Control-Allow-Origin *; } + # WebSocket proxy + location /ws/ { + proxy_pass http://127.0.0.1:8766/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + location / { try_files $uri $uri/ =404; add_header Access-Control-Allow-Origin *; @@ -56,6 +68,18 @@ http { add_header Access-Control-Allow-Origin *; } + # WebSocket proxy + location /ws/ { + proxy_pass http://127.0.0.1:8766/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + location / { try_files $uri $uri/ =404; add_header Access-Control-Allow-Origin *; diff --git a/tracker3d-visionpro.html b/tracker3d-visionpro.html index f7a7d32..b475663 100644 --- a/tracker3d-visionpro.html +++ b/tracker3d-visionpro.html @@ -1632,11 +1632,18 @@

Workspaces

// Init // ======================================================================== - const REFRESH_INTERVAL = 5000; // 5 seconds + // ======================================================================== + // Data sync — WebSocket first, HTTP polling fallback + // ======================================================================== + + const POLL_INTERVAL = 5000; + const WS_RECONNECT_DELAY = 3000; let lastDataHash = ''; + let ws = null; + let wsConnected = false; + let pollingInterval = null; function hashData(data) { - // Quick fingerprint: tree keys + node count + session statuses const treeKeys = Object.keys(data.trees || {}).sort().join(','); const statuses = JSON.stringify(data.session_statuses || {}); let nodeCount = 0; @@ -1647,10 +1654,7 @@

Workspaces

return `${treeKeys}|${nodeCount}|${statuses}`; } - async function refreshData() { - const data = await fetchData(); - if (!data) return; - + function handleNewData(data) { const newHash = hashData(data); const treeChanged = lastDataHash !== '' && newHash !== lastDataHash; const firstLoad = lastDataHash === ''; @@ -1659,7 +1663,6 @@

Workspaces

trackerData = data; updateStatusCounts(); - // Only rebuild the 3D scene if the tree structure actually changed if (firstLoad || treeChanged) { buildScene(); } @@ -1672,12 +1675,86 @@

Workspaces

`${org}${treeCount} repos · ${nodeCount} PRs`; } + async function refreshData() { + const data = await fetchData(); + if (data) handleNewData(data); + } + + // WebSocket connection with fallback + function getWsUrl() { + const loc = window.location; + const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${loc.host}/ws/`; + } + + function connectWebSocket() { + const urls = [getWsUrl(), 'ws://localhost:8766']; + tryWsConnect(urls, 0); + } + + function tryWsConnect(urls, index) { + if (index >= urls.length) { + console.log('WebSocket unavailable, using HTTP polling'); + startPolling(); + setTimeout(connectWebSocket, 15000); + return; + } + + const url = urls[index]; + const socket = new WebSocket(url); + const timeout = setTimeout(() => { socket.close(); tryWsConnect(urls, index + 1); }, 5000); + + socket.onopen = () => { + clearTimeout(timeout); + console.log('WebSocket connected:', url); + ws = socket; + wsConnected = true; + stopPolling(); + }; + + socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (!data.loading) handleNewData(data); + } catch (e) { console.error('WS parse error:', e); } + }; + + socket.onclose = () => { + clearTimeout(timeout); + if (wsConnected) { + wsConnected = false; + ws = null; + console.log('WebSocket disconnected, falling back to polling'); + startPolling(); + setTimeout(connectWebSocket, WS_RECONNECT_DELAY); + } else { + tryWsConnect(urls, index + 1); + } + }; + + socket.onerror = () => { clearTimeout(timeout); }; + } + + function startPolling() { + if (pollingInterval) return; + pollingInterval = setInterval(refreshData, POLL_INTERVAL); + } + + function stopPolling() { + if (pollingInterval) { clearInterval(pollingInterval); pollingInterval = null; } + } + + // ======================================================================== + // Init + // ======================================================================== + async function init() { initScene(); setupControls(); setupHideMenuKeyboard(); renderer.setAnimationLoop(animate); + // Initial load via HTTP (fast, guaranteed) await refreshData(); if (!trackerData) { @@ -1686,8 +1763,9 @@

Workspaces

await setupXR(); - // Auto-refresh every 5s to stay in sync - setInterval(refreshData, REFRESH_INTERVAL); + // Try WebSocket for real-time, fall back to polling + connectWebSocket(); + startPolling(); // immediate fallback — stopped if WS connects } init(); diff --git a/tracker_server.py b/tracker_server.py index fda520d..df6136d 100644 --- a/tracker_server.py +++ b/tracker_server.py @@ -7,6 +7,8 @@ """ import argparse +import asyncio +import hashlib import http.server import json import os @@ -23,6 +25,12 @@ from typing import Optional from urllib.parse import parse_qs, urlparse +try: + import websockets + HAS_WEBSOCKETS = True +except ImportError: + HAS_WEBSOCKETS = False + from fetch_org_data import fetch_org_data as _fetch_org_data, DEFAULT_CONFIG as ORG_DEFAULT_CONFIG @@ -1173,20 +1181,109 @@ class ReusableTCPServer(socketserver.ThreadingTCPServer): daemon_threads = True +# ============================================================================ +# WebSocket broadcaster +# ============================================================================ + +class WebSocketBroadcaster: + """Polls TrackerState and pushes updates to connected WebSocket clients.""" + + def __init__(self, tracker_state, poll_interval: float = 3.0): + self.tracker_state = tracker_state + self.poll_interval = poll_interval + self.clients: set = set() + self.last_hash = "" + + def _compute_hash(self, data: dict) -> str: + raw = json.dumps(data, sort_keys=True) + return hashlib.md5(raw.encode()).hexdigest() + + async def register(self, websocket): + self.clients.add(websocket) + try: + data = self.tracker_state.get_api_data() + await websocket.send(json.dumps(data)) + except Exception: + self.clients.discard(websocket) + + async def unregister(self, websocket): + self.clients.discard(websocket) + + async def handler(self, websocket): + await self.register(websocket) + try: + async for message in websocket: + try: + msg = json.loads(message) + if msg.get("action") == "refresh_ci": + self.tracker_state.force_refresh_ci() + data = self.tracker_state.get_api_data() + await websocket.send(json.dumps(data)) + except json.JSONDecodeError: + pass + except websockets.ConnectionClosed: + pass + finally: + await self.unregister(websocket) + + async def poll_and_broadcast(self): + while True: + await asyncio.sleep(self.poll_interval) + if not self.clients: + continue + try: + data = self.tracker_state.get_api_data() + new_hash = self._compute_hash(data) + if new_hash != self.last_hash: + self.last_hash = new_hash + payload = json.dumps(data) + disconnected = set() + for ws in self.clients: + try: + await ws.send(payload) + except Exception: + disconnected.add(ws) + self.clients -= disconnected + except Exception as e: + print(f"WebSocket broadcast error: {e}") + + +async def run_websocket_server(tracker_state, port: int = 8766): + broadcaster = WebSocketBroadcaster(tracker_state) + async with websockets.serve(broadcaster.handler, "0.0.0.0", port): + print(f"WebSocket server running at: ws://localhost:{port}") + await broadcaster.poll_and_broadcast() + + def run_server(port: int = 8765): """Run the HTTP server.""" config = load_config() - TrackerHandler.tracker_state = TrackerState(config) + tracker_state = TrackerState(config) + TrackerHandler.tracker_state = tracker_state TrackerHandler.org_state = OrgViewerState() # Set the directory for serving static files os.chdir(Path(__file__).parent) + # Start WebSocket server in background thread + ws_port = port + 1 + if HAS_WEBSOCKETS: + def start_ws(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(run_websocket_server(tracker_state, ws_port)) + ws_thread = threading.Thread(target=start_ws, daemon=True) + ws_thread.start() + else: + print("WARNING: 'websockets' not installed. WebSocket disabled. pip install websockets") + with ReusableTCPServer(("", port), TrackerHandler) as httpd: print(f"Conductor Worktree Tracker 3D Server") print(f"=" * 40) - print(f"Server running at: http://localhost:{port}") + print(f"HTTP server: http://localhost:{port}") print(f"API endpoint: http://localhost:{port}/api/tracker") + if HAS_WEBSOCKETS: + print(f"WebSocket: ws://localhost:{ws_port}") print(f"Org viewer: http://localhost:{port}/org") print(f"Database: {CONDUCTOR_DB}") print()