diff --git a/.claude/plugins/test-automation/plugin.json b/.claude/plugins/test-automation/plugin.json index 50c9a0b5..4a445fe0 100644 --- a/.claude/plugins/test-automation/plugin.json +++ b/.claude/plugins/test-automation/plugin.json @@ -10,6 +10,8 @@ "skills": [ "skills/spec.md", "skills/qa-test-cases.md", - "skills/automate-tests.md" + "skills/automate-tests.md", + "skills/debug-robot-browser.md", + "skills/debug-robot-api.md" ] } diff --git a/.claude/plugins/test-automation/skills/debug-robot-api.md b/.claude/plugins/test-automation/skills/debug-robot-api.md new file mode 100644 index 00000000..7d8acab5 --- /dev/null +++ b/.claude/plugins/test-automation/skills/debug-robot-api.md @@ -0,0 +1,323 @@ +--- +name: debug-robot-api +description: This skill should be used when the user asks to "debug robot api tests", "fix a failing api test", "diagnose a robot api test failure", "why is my robot api test failing", or "run api tests and fix them". Drives a fast run-parse-fix cycle for Robot Framework API tests using RequestsLibrary. +--- + +## Purpose + +Drive a fast debug cycle for failing Robot Framework API tests: +**run with debug logging → parse output for HTTP details → identify cause → fix → verify**. + +API tests fail at the HTTP layer (status codes, response bodies, auth tokens) — not the DOM. The debug workflow is entirely different from browser tests. + +--- + +## Step 1 — Run with debug logging + +```bash +# All API tests with verbose HTTP output +cd robot_tests && pixi run robot --loglevel DEBUG api/ + +# Single suite (fastest) +cd robot_tests && pixi run robot --loglevel DEBUG api/keycloak_auth.robot + +# Single test by name +cd robot_tests && pixi run robot --loglevel DEBUG --test "TC-KC-001*" api/keycloak_auth.robot +``` + +`--loglevel DEBUG` logs the full request URL, headers, and response body for every HTTP call. Without it, you only see the status code. + +--- + +## Step 2 — Parse the failure output + +### output.xml — status codes and response bodies + +```bash +# All FAIL lines with surrounding context +python3 -c " +import xml.etree.ElementTree as ET +try: + tree = ET.parse('robot_tests/output.xml') + for msg in tree.getroot().iter('msg'): + if msg.get('level') == 'FAIL' or (msg.text and 'status' in (msg.text or '').lower()): + print(msg.text[:300]) + print('---') +except: pass +" 2>/dev/null | head -80 + +# Quick grep for HTTP status codes in output +grep -o 'status.*[0-9]\{3\}\|[0-9]\{3\}.*status\|HTTPError\|ConnectionError\|FAIL' \ + robot_tests/output.xml | sort -u | head -20 + +# Response body at failure point +grep -o '.\{0,50\}response\|body\|detail.\{0,200\}' robot_tests/output.xml | head -20 +``` + +### log.html — easiest to read + +Open in a browser for full formatted output with collapsible sections: +```bash +open robot_tests/log.html +``` + +### Backend container logs + +Often the clearest signal — shows the actual exception or validation error: +```bash +docker logs ushadow-test-backend-test-1 --tail 50 + +# Follow while running tests +docker logs ushadow-test-backend-test-1 -f +``` + +--- + +## Step 3 — Identify the failure pattern + +### Pattern A: Connection refused / backend not ready + +**Symptom:** +``` +ConnectionError: HTTPConnectionPool(host='localhost', port=8200): Max retries exceeded +``` + +**Cause:** Backend container not running or still starting. + +**Diagnosis:** +```bash +# Check container status +docker ps | grep ushadow-test + +# Check if backend responds +curl -s http://localhost:8200/health | python3 -m json.tool + +# Start containers if needed +cd robot_tests && make start +``` + +**Fix:** Wait for containers before running. The suite setup handles this — if containers are already running it reuses them. If not, run `make start` first or ensure the suite setup keyword is included. + +--- + +### Pattern B: 401 Unauthorized + +**Symptom:** +``` +Expected status 200 but got 401 +``` + +**Cause:** Missing auth token, expired token, or wrong client credentials. + +**Diagnosis:** +```bash +# Check if token endpoint works +curl -s -X POST http://localhost:8181/realms/ushadow/protocol/openid-connect/token \ + -d "grant_type=password&client_id=ushadow-frontend&username=kctest@example.com&password=TestKeycloak123!" \ + | python3 -m json.tool + +# Check if test user exists in Keycloak +TOKEN=$(curl -s -X POST http://localhost:8181/realms/master/protocol/openid-connect/token \ + -d "grant_type=password&client_id=admin-cli&username=admin&password=admin" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") +curl -s -H "Authorization: Bearer $TOKEN" \ + "http://localhost:8181/admin/realms/ushadow/users?email=kctest@example.com" | python3 -m json.tool +``` + +**Common fixes:** +- Test user not created → suite setup should call `Ensure Keycloak Test User Exists` +- Wrong client_id in token request → check `KEYCLOAK_CLIENT_ID` in `test_env.py` (should be `ushadow-frontend` or `ushadow-cli`) +- Token expired mid-suite → refresh in suite setup or request a fresh token per test + +--- + +### Pattern C: 403 Forbidden + +**Symptom:** +``` +Expected status 200 but got 403 +``` + +**Cause:** Token present but insufficient permissions, or using wrong client type. + +**Common case — introspection with public client:** +Keycloak's token introspection endpoint (`/protocol/openid-connect/token/introspect`) requires a **confidential client** with client credentials. The `ushadow-frontend` client is public and will get 401/403. + +**Fix:** +```robot +# Check status gracefully — skip if introspection not supported +${response}= POST On Session ... expected_status=any +IF '${response.status_code}' in ['401', '403'] + Skip Token introspection requires confidential client (public client limitation) +END +Should Be Equal As Integers ${response.status_code} 200 +``` + +Or use the CLI client (`ushadow-cli`) if it's configured as confidential: +```robot +# Use CLI client for introspection +${token}= Get Token Via CLI Client +``` + +--- + +### Pattern D: 404 Not Found + +**Symptom:** +``` +Expected status 200 but got 404 +``` + +**Cause:** Endpoint path is wrong, or the route doesn't exist. + +**Diagnosis:** +```bash +# Check what routes are registered on the backend +curl -s http://localhost:8200/openapi.json | python3 -c " +import sys, json +spec = json.load(sys.stdin) +for path in sorted(spec.get('paths', {}).keys()): + print(path) +" | grep -i auth + +# Or view Swagger UI +open http://localhost:8200/docs +``` + +**Fix:** Correct the endpoint path in the robot file. Check the backend's router to confirm the exact path. + +--- + +### Pattern E: Response body mismatch + +**Symptom:** +``` +'expected_field' != 'actual_field' +Should Be Equal As Strings FAIL +``` + +**Cause:** The response JSON structure changed, or the test is checking the wrong key. + +**Diagnosis:** +```bash +# Make the call manually and inspect the actual response +curl -s -X POST http://localhost:8200/api/auth/token \ + -H "Content-Type: application/json" \ + -d '{"code":"test","code_verifier":"test","redirect_uri":"http://localhost:3001/oauth/callback"}' \ + | python3 -m json.tool +``` + +Check the actual field names in the response vs what the test asserts. Common mismatches: +- `access_token` vs `token` +- `user_id` vs `id` +- Nested vs flat structure (`user.email` vs `email`) + +--- + +### Pattern F: Suite setup failure + +**Symptom:** Tests show as `NOT RUN` or the suite itself fails before any tests execute. + +**Diagnosis:** +```bash +# Check the first FAIL message +grep -m5 'FAIL\|ERROR' robot_tests/output.xml + +# Check container health +docker ps --format "table {{.Names}}\t{{.Status}}" | grep ushadow-test +``` + +**Common causes:** +- MongoDB not cleared (`✓ MongoDB test database cleared` should appear in console) +- Keycloak not reachable at `http://localhost:8181` +- Backend health check failing at `http://localhost:8200/health` + +--- + +## Step 4 — Common fixes reference + +### Auth keywords (from `resources/auth_keywords.robot`) + +```robot +# Get a token for the test user +${TOKEN}= Get Keycloak Token ${KEYCLOAK_TEST_EMAIL} ${KEYCLOAK_TEST_PASSWORD} + +# Ensure the test user exists (idempotent — safe to call in setup) +Ensure Keycloak Test User Exists + +# Create auth header dict +${HEADERS}= Create Dictionary Authorization=Bearer ${TOKEN} +``` + +### Session management + +```robot +# Create a session pointing at the backend +Create Session api ${BACKEND_URL} headers=${HEADERS} + +# Make a call +${resp}= GET On Session api /api/auth/me expected_status=200 + +# Log full response for debugging +Log Response: ${resp.status_code} ${resp.text} +``` + +### Graceful skip vs fail + +```robot +# Skip if feature not available (e.g. confidential client not configured) +${resp}= POST On Session api ${ENDPOINT} expected_status=any +Skip If '${resp.status_code}' == '501' Feature not implemented + +# Assert with helpful message +Should Be Equal As Integers ${resp.status_code} 200 +... Expected 200 but got ${resp.status_code}: ${resp.text} +``` + +--- + +## Step 5 — Fix and verify + +```bash +# Re-run with debug logging to confirm fix +cd robot_tests && pixi run robot --loglevel DEBUG api/keycloak_auth.robot +``` + +Expected output when passing: +``` +TC-KC-001: Authenticate with Keycloak Direct Grant | PASS | +TC-KC-002: OAuth Authorization Code Flow (skip) | SKIP | +TC-KC-003: Validate Access Token | PASS | +TC-KC-004: Introspect Access Token (skip) | SKIP | +TC-KC-005: Refresh Access Token | PASS | +TC-KC-006: Logout and Revoke Tokens | PASS | +6 tests, 4 passed, 0 failed, 2 skipped +``` + +--- + +## Environment quick reference + +| Service | Port | URL | +|---------|------|-----| +| Backend | 8200 | `http://localhost:8200` | +| Keycloak (test) | 8181 | `http://localhost:8181` | +| MongoDB (test) | 27118 | `mongodb://localhost:27118` | +| Redis (test) | 6480 | `redis://localhost:6480` | + +Keycloak admin: `admin` / `admin` +Test user: `kctest@example.com` / `TestKeycloak123!` +Realm: `ushadow` +Frontend client: `ushadow-frontend` (public) +CLI client: `ushadow-cli` (confidential, for direct grant) + +## Key files + +| File | Purpose | +|------|---------| +| `robot_tests/api/keycloak_auth.robot` | Keycloak auth test suite | +| `robot_tests/resources/auth_keywords.robot` | Shared auth keywords | +| `robot_tests/resources/setup/test_env.py` | URLs, ports, credentials | +| `robot_tests/resources/setup/suite_setup.robot` | Container start/stop | +| `robot_tests/output.xml` | Last run results | +| `robot_tests/log.html` | Human-readable log (open in browser) | diff --git a/.claude/plugins/test-automation/skills/debug-robot-browser.md b/.claude/plugins/test-automation/skills/debug-robot-browser.md new file mode 100644 index 00000000..5371bf40 --- /dev/null +++ b/.claude/plugins/test-automation/skills/debug-robot-browser.md @@ -0,0 +1,243 @@ +--- +name: debug-robot-browser +description: This skill should be used when the user asks to "debug robot browser tests", "fix a failing browser test", "diagnose a robot test failure", "why is my robot test failing", or "run browser tests and fix them". Drives a fast run-parse-fix cycle for Robot Framework browser tests (RF Browser / Playwright wrapper). +--- + +## Purpose + +Drive a fast debug cycle for failing Robot Framework browser tests: +**run with short timeout → parse failure output → identify cause → fix → verify**. + +Always prefer the reduced-timeout run for diagnosis. The default 15 s timeout means a single failing test wastes 15 s; 5 s surfaces the same failure 3× faster. + +--- + +## Step 1 — Run with reduced timeout + +```bash +# All browser tests (fastest diagnosis) +cd robot_tests && pixi run robot -v HEADLESS:true -v TIMEOUT:5s browser/ + +# Single test by name pattern (even faster) +cd robot_tests && pixi run robot -v HEADLESS:true -v TIMEOUT:5s --test "TC-BR-KC-002*" browser/ +``` + +| Variable | Diagnosis | Normal CI | +|----------|-----------|-----------| +| `TIMEOUT` | `5s` | `15s` (default in robot file) | +| `HEADLESS` | `true` | `true` | + +Do **not** edit the robot file to change the timeout — pass it via `-v`. + +--- + +## Step 2 — Parse the failure output + +After the run, three files contain everything needed: + +### output.xml — what failed and where + +```bash +# Failing URL at the moment of failure +grep -o '.\{0,80\}Failed at URL.\{0,120\}' robot_tests/output.xml + +# Expected selector that timed out +grep -o 'waiting for locator.*visible' robot_tests/output.xml | sort -u + +# Full test status summary +grep 'status status=' robot_tests/output.xml | grep FAIL +``` + +### playwright-log.txt — page source at failure time + +The log contains the full page HTML right after every screenshot. Extract it: + +```bash +# All Keycloak element IDs and classes (kc-*) +grep -o 'kc-[a-zA-Z-]*' robot_tests/playwright-log.txt | sort -u + +# All data-testid values present on the page +grep -o 'data-testid="[^"]*"' robot_tests/playwright-log.txt | sort -u + +# Full page source blob (first 4000 chars) +grep -o '"msg":"[^"]*"' robot_tests/playwright-log.txt | head -c 4000 + +# URL the browser was at when it failed +grep 'Failed at URL\|navigate\|goto' robot_tests/playwright-log.txt | grep localhost | tail -20 +``` + +### browser/screenshot/ — visual snapshot at failure + +```bash +ls -lt robot_tests/browser/screenshot/*.png | head -5 +# Open the most recent: open robot_tests/browser/screenshot/-failure.png +``` + +--- + +## Step 3 — Identify the failure pattern + +### Pattern A: "Invalid parameter: redirect_uri" on Keycloak page + +**Symptom:** Browser lands on Keycloak "We are sorry..." page with `redirect_uri` error instead of the login form. + +**Cause:** Race condition — the app's `SettingsContext` fetches the Keycloak URL from the backend (`/api/settings/config`, ~250 ms). If the login button is clicked before settings load, `keycloakConfig.url` is still the default `http://localhost:8081` (main env Keycloak). The OAuth request goes to the wrong Keycloak, which rejects the redirect URI. + +**Diagnosis:** Two Keycloak containers run simultaneously: +- `localhost:8081` — main dev environment (wrong for tests) +- `localhost:8181` — test environment (correct) + +**Fix:** Add `Wait For Load State networkidle` before clicking the login button. This waits until all in-flight network requests (including the settings API) settle. + +```robot +Navigate To Login And Wait For Settings + [Documentation] Navigate to /login and wait for settings API before clicking. + New Page ${WEB_URL}/login + Wait For Elements State css=[data-testid="login-button-keycloak"] visible timeout=${TIMEOUT} + Wait For Load State networkidle timeout=${TIMEOUT} + Click css=[data-testid="login-button-keycloak"] +``` + +Use this keyword instead of inline `New Page` + `Click` sequences. + +--- + +### Pattern B: Wrong selector for Keycloak error message + +**Symptom:** `waiting for locator('id=kc-error-message') to be visible` times out after submitting bad credentials. + +**Cause:** Keycloak 26 (PatternFly v5) moved the error from `
` to `
{/* Tmux Manager */} @@ -1386,15 +1601,21 @@ function App() { - {/* Settings / Credentials Button */} + {/* Auth Button */} + + + {/* Settings Button */} {/* Refresh */} @@ -1412,53 +1633,71 @@ function App() { {/* Main Content */}
{appMode === 'install' ? ( - /* Install Page - One-Click Launch (Landing Page) */ -
-
-

One-Click Launch

-

- Automatically install prerequisites and start Ushadow -

-
+ /* Install Page - Project Configuration */ +
+ {multiProjectMode ? ( + /* Multi-Project Mode - Project Manager */ + <> +
+

Project Management

+

+ Manage multiple projects with independent configurations +

+
+
+ +
+ + ) : ( + /* Single-Project Mode - One-Click Launch */ +
+
+

One-Click Launch

+

+ Automatically install prerequisites and start Ushadow +

+
- {/* Project Folder Display */} -
- - Project folder: - - {projectRoot || 'Not set'} - - -
+ {/* Project Folder Display */} +
+ + Project folder: + + {projectRoot || 'Not set'} + + +
- + +
+ )}
) : appMode === 'infra' ? ( /* Infra Page - Prerequisites & Infrastructure Setup */ @@ -1466,7 +1705,7 @@ function App() {

Setup & Installation

- Install prerequisites and configure your single environment + Install prerequisites and configure shared infrastructure

@@ -1493,70 +1732,23 @@ function App() { onStop={handleStopInfra} onRestart={handleRestartInfra} isLoading={loadingInfra} + selectedServices={selectedInfraServices} + onToggleService={handleToggleInfraService} />
- {/* Single Environment Section for Consumers */} -
-

Your Environment

- {!discovery || discovery.environments.length === 0 ? ( -
-

No environment created yet

- -
- ) : ( -
- {discovery.environments.map(env => ( -
-
-
- {env.name} - - {env.status} - -
-
- {env.running ? ( - <> - - - - ) : ( - - )} -
-
- ))} -
- )} -
+ {/* Infrastructure Configuration */} + {effectiveProjectRoot && ( + { + // TODO: Save to backend + }} + /> + )}
- ) : ( + ) : appMode === 'environments' ? ( /* Environments Page - Worktree Management */
setCreatingEnvs(prev => prev.filter(e => e.name !== name))} loadingEnv={loadingEnv} tmuxStatuses={tmuxStatuses} + selectedEnvironment={selectedEnvironment} + onSelectEnvironment={(env) => { + console.log('[App] onSelectEnvironment called with:', env?.name) + setSelectedEnvironment(env) + }} />
- )} + ) : appMode === 'kanban' && kanbanEnabled ? ( + /* Kanban Page - Ticket Management */ + (() => { + // Use the first available backend (running or not) + const backendUrl = discovery?.environments.find(e => e.running)?.localhost_url + || discovery?.environments[0]?.localhost_url + || 'http://localhost:8000' + + return ( + + ) + })() + ) : appMode === 'claude' && claudeEnabled ? ( + /* Claude Sessions Page */ + { + if (env.localhost_url) { + setEmbeddedView({ url: env.localhost_url, envName: env.name, envColor: env.color ?? env.name, envPath: env.path ?? null }) + } + }} + /> + ) : null}
{/* Log Panel - Bottom */} @@ -1601,7 +1830,7 @@ function App() { {/* New Environment Dialog */} setShowNewEnvDialog(false)} onLink={handleNewEnvLink} onWorktree={handleNewEnvWorktree} @@ -1619,6 +1848,26 @@ function App() { isOpen={showSettingsDialog} onClose={() => setShowSettingsDialog(false)} /> + + {/* Environment Conflict Dialog */} + + + {/* Claude Session Notch Overlay */} + { + if (env.localhost_url) { + setEmbeddedView({ url: env.localhost_url, envName: env.name, envColor: env.color ?? env.name, envPath: env.path ?? null }) + } + }} + /> ) } diff --git a/ushadow/launcher/src/components/AuthButton.tsx b/ushadow/launcher/src/components/AuthButton.tsx new file mode 100644 index 00000000..4b36ed90 --- /dev/null +++ b/ushadow/launcher/src/components/AuthButton.tsx @@ -0,0 +1,291 @@ +import { useState, useEffect } from 'react' +import { LogIn, LogOut, User, Loader2 } from 'lucide-react' +import { tauri, type UshadowEnvironment } from '../hooks/useTauri' +import { TokenManager } from '../services/tokenManager' +import { generateCodeVerifier, generateCodeChallenge, generateState } from '../utils/pkce' + +interface AuthButtonProps { + // Optional: Pass specific environment to auth against + // If not provided, will use first running environment + environment?: UshadowEnvironment | null + // Show as large button in center of page (for login prompt) + variant?: 'header' | 'centered' +} + +export function AuthButton({ environment, variant = 'header' }: AuthButtonProps) { + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [username, setUsername] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // Check auth status when environment changes + useEffect(() => { + if (!environment) { + setIsLoading(false) + return + } + + checkAuthStatus() + + // Periodically check for token expiration (every 30 seconds) + const intervalId = setInterval(() => { + checkAuthStatus() + }, 30000) + + return () => clearInterval(intervalId) + }, [environment]) + + const checkAuthStatus = () => { + if (TokenManager.isAuthenticated()) { + const userInfo = TokenManager.getUserInfo() + setIsAuthenticated(true) + setUsername(userInfo?.preferred_username || userInfo?.email || 'User') + } else { + setIsAuthenticated(false) + } + setIsLoading(false) + } + + const handleLogin = async () => { + if (!environment) { + alert('No environment selected. Please start an environment first.') + return + } + + setIsLoading(true) + + try { + console.log('[AuthButton] Starting OAuth flow with HTTP callback server...') + + // Get backend URL from environment + const backendUrl = `http://localhost:${environment.backend_port}` + console.log('[AuthButton] Backend URL:', backendUrl) + + // Declare variables at function scope + let keycloakUrl: string + let port: number + let callbackUrl: string + + // Fetch Keycloak config from backend (using Tauri HTTP client to bypass CORS) + console.log('[AuthButton] Fetching Keycloak config from backend...') + const configResponse = await tauri.httpRequest(`${backendUrl}/api/settings/config`, 'GET') + console.log('[AuthButton] Config response status:', configResponse.status) + if (configResponse.status !== 200) { + throw new Error(`Failed to fetch config from backend: ${configResponse.status} - ${configResponse.body}`) + } + const config = JSON.parse(configResponse.body) + keycloakUrl = config.keycloak?.public_url || 'http://localhost:8081' + console.log('[AuthButton] Using Keycloak URL:', keycloakUrl) + + // Start OAuth callback server + console.log('[AuthButton] Starting OAuth callback server...') + ;[port, callbackUrl] = await tauri.startOAuthServer() + console.log('[AuthButton] ✓ Callback server running on port:', port) + console.log('[AuthButton] Callback URL:', callbackUrl) + + // Register callback URL with Keycloak (using Tauri HTTP client to bypass CORS) + console.log('[AuthButton] Registering callback URL with Keycloak...') + const registerResponse = await tauri.httpRequest( + `${backendUrl}/api/auth/register-redirect-uri`, + 'POST', + { 'Content-Type': 'application/json' }, + JSON.stringify({ redirect_uri: callbackUrl }) + ) + + console.log('[AuthButton] Register response status:', registerResponse.status) + if (registerResponse.status !== 200) { + throw new Error(`Failed to register callback URL: ${registerResponse.status} - ${registerResponse.body}`) + } + console.log('[AuthButton] ✓ Callback URL registered') + + // Generate PKCE parameters + const codeVerifier = generateCodeVerifier() + const codeChallenge = await generateCodeChallenge(codeVerifier) + const state = generateState() + + // Store for callback validation + localStorage.setItem('pkce_code_verifier', codeVerifier) + localStorage.setItem('oauth_state', state) + localStorage.setItem('oauth_backend_url', backendUrl) + + // Build Keycloak login URL + const authUrl = new URL(`${keycloakUrl}/realms/ushadow/protocol/openid-connect/auth`) + authUrl.searchParams.set('client_id', 'ushadow-frontend') + authUrl.searchParams.set('redirect_uri', callbackUrl) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('scope', 'openid profile email') + authUrl.searchParams.set('state', state) + authUrl.searchParams.set('code_challenge', codeChallenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + + // Open system browser + console.log('[AuthButton] Opening system browser...') + await tauri.openBrowser(authUrl.toString()) + console.log('[AuthButton] ✓ Browser opened, waiting for callback...') + + // Wait for OAuth callback (this will block until callback or timeout) + const result = await tauri.waitForOAuthCallback(port) + + if (!result.success || !result.code || !result.state) { + throw new Error(result.error || 'Login failed or cancelled') + } + + console.log('[AuthButton] ✓ Callback received') + + // Validate state (CSRF protection) + const savedState = localStorage.getItem('oauth_state') + if (result.state !== savedState) { + throw new Error('Invalid state parameter - possible CSRF attack') + } + + // Exchange code for tokens + const savedCodeVerifier = localStorage.getItem('pkce_code_verifier') + if (!savedCodeVerifier) { + throw new Error('Missing PKCE code verifier') + } + + console.log('[AuthButton] Exchanging code for tokens...') + const tokenResponse = await tauri.httpRequest( + `${backendUrl}/api/auth/token`, + 'POST', + { 'Content-Type': 'application/json' }, + JSON.stringify({ + code: result.code, + code_verifier: savedCodeVerifier, + redirect_uri: callbackUrl, + }) + ) + + if (tokenResponse.status !== 200) { + throw new Error(`Token exchange failed: ${tokenResponse.body}`) + } + + const tokens = JSON.parse(tokenResponse.body) + + // Store tokens + TokenManager.storeTokens(tokens) + console.log('[AuthButton] ✓ Login successful') + + // Clean up + localStorage.removeItem('oauth_state') + localStorage.removeItem('pkce_code_verifier') + localStorage.removeItem('oauth_backend_url') + + // Refresh the embedded environment view + const iframe = document.getElementById('embedded-iframe') as HTMLIFrameElement + if (iframe) { + console.log('[AuthButton] Refreshing environment view...') + iframe.src = iframe.src // Reload to pick up new tokens + } + + // Update UI + checkAuthStatus() + } catch (error) { + console.error('[AuthButton] Login error:', error) + alert(`Login failed: ${error}`) + setIsLoading(false) + } + } + + const handleLogout = async () => { + TokenManager.clearTokens() + setIsAuthenticated(false) + setUsername(null) + + // Optionally open Keycloak logout page + if (environment) { + try { + const backendUrl = `http://localhost:${environment.backend_port}` + const configResponse = await tauri.httpRequest(`${backendUrl}/api/settings/config`, 'GET') + if (configResponse.status === 200) { + const config = JSON.parse(configResponse.body) + const keycloakUrl = config.keycloak?.public_url || 'http://localhost:8081' + const logoutUrl = `${keycloakUrl}/realms/ushadow/protocol/openid-connect/logout` + await tauri.openBrowser(logoutUrl) + } + } catch (error) { + console.error('[AuthButton] Logout error:', error) + } + } + } + + // Don't show button if no environment + if (!environment) { + return null + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isAuthenticated) { + if (variant === 'centered') { + return null // Don't show anything when authenticated in centered mode + } + + return ( +
+
+ + {username} +
+ +
+ ) + } + + // Centered variant - large button in middle of page + if (variant === 'centered') { + return ( +
+
+

Authentication Required

+

+ You need to log in to access Ushadow environments. +

+

+ Click below to open the login page in your browser. +

+
+ + + + {environment && ( +

+ Environment: {environment.name} • + Port: {environment.webui_port} +

+ )} +
+ ) + } + + // Header variant - compact button + return ( + + ) +} diff --git a/ushadow/launcher/src/components/ClaudeSessionsPanel.tsx b/ushadow/launcher/src/components/ClaudeSessionsPanel.tsx new file mode 100644 index 00000000..33769deb --- /dev/null +++ b/ushadow/launcher/src/components/ClaudeSessionsPanel.tsx @@ -0,0 +1,750 @@ +import { useState, useEffect, useRef } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { + Bot, Download, Code2, AppWindow, Clock, Zap, CheckCircle, AlertCircle, + Loader2, MessageSquare, RefreshCw, ShieldQuestion, Minimize2, + Ticket as TicketIcon, ArrowUpDown, ThumbsUp, ThumbsDown, Terminal, +} from 'lucide-react' +import { getColors } from '../utils/colors' +import type { ClaudeSession, ClaudeSessionStatus } from '../hooks/useClaudeSessions' +import type { ClaudeSessionEvent, TranscriptMessage, Ticket, UshadowEnvironment } from '../hooks/useTauri' +import { tauri } from '../hooks/useTauri' + +interface ClaudeSessionsPanelProps { + sessions: ClaudeSession[] + hooksInstalled: boolean | null + installing: boolean + error: string | null + installSuccess: string | null + onInstallHooks: () => void + environments: UshadowEnvironment[] + onOpenInApp: (env: UshadowEnvironment) => void +} + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function statusIcon(status: ClaudeSessionStatus, className = 'w-3 h-3') { + switch (status) { + case 'Working': return + case 'Processing': return + case 'WaitingForInput': return + case 'WaitingForApproval': return + case 'Compacting': return + case 'Ended': return + default: return + } +} + +function statusLabel(status: ClaudeSessionStatus): string { + switch (status) { + case 'Working': return 'Working' + case 'Processing': return 'Processing' + case 'WaitingForInput': return 'Waiting' + case 'WaitingForApproval': return 'Needs approval' + case 'Compacting': return 'Compacting' + case 'Ended': return 'Ended' + } +} + +function formatAge(iso: string): string { + const ms = Date.now() - new Date(iso).getTime() + if (ms < 60_000) return `${Math.round(ms / 1000)}s` + if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m` + return `${Math.round(ms / 3_600_000)}h` +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +const TICKET_STATUS_STYLE: Record = { + backlog: 'text-text-muted/60 bg-white/5', + todo: 'text-blue-400/80 bg-blue-400/10', + in_progress: 'text-yellow-400/90 bg-yellow-400/10', + in_review: 'text-purple-400/90 bg-purple-400/10', + done: 'text-green-400/80 bg-green-400/10', + archived: 'text-text-muted/30 bg-white/3', +} + +// ─── EventRow ──────────────────────────────────────────────────────────────── + +function EventRow({ event }: { event: ClaudeSessionEvent }) { + const data = event.data as { + tool?: string; description?: string; path?: string; message?: string + last_message?: string; compaction_type?: string + } + + let label = event.event_type + let detail = '' + let labelColor = 'text-text-muted' + + switch (event.event_type) { + case 'UserPromptSubmit': label = 'You'; detail = data.message ?? ''; labelColor = 'text-primary-400'; break + case 'PreToolUse': label = data.tool ?? 'Tool'; detail = data.description || data.path || ''; labelColor = 'text-yellow-400/80'; break + case 'PostToolUse': label = `✓ ${data.tool ?? 'Tool'}`; labelColor = 'text-green-400/60'; break + case 'Notification': label = 'Notice'; detail = data.message ?? ''; labelColor = 'text-blue-400/80'; break + case 'Stop': label = 'Done'; detail = data.last_message ?? ''; labelColor = 'text-text-muted'; break + case 'SubagentStop': label = 'Agent done'; labelColor = 'text-text-muted'; break + case 'SessionStart': label = 'Started'; labelColor = 'text-cyan-400/60'; break + case 'SessionEnd': label = 'Ended'; labelColor = 'text-text-muted/40'; break + case 'PreCompact': label = 'Compacting'; detail = data.compaction_type ?? ''; labelColor = 'text-purple-400/80'; break + } + + const isToolEvent = event.event_type === 'PreToolUse' || event.event_type === 'PostToolUse' + + return ( +
+ {isToolEvent && detail ? ( +
+ {label} + {detail} +
+ ) : ( + <> + {label} + {detail && {detail}} + + )} + {formatTime(event.timestamp)} +
+ ) +} + +// ─── TranscriptRow ─────────────────────────────────────────────────────────── + +const mdComponents: React.ComponentProps['components'] = { + p: ({ children }) =>

{children}

, + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) =>
    {children}
    , + hr: () =>
    , + a: ({ href, children }) => {children}, + code: ({ children, className }) => { + const isBlock = className?.startsWith('language-') + return isBlock + ? {children} + : {children} + }, + pre: ({ children }) => ( +
    +      {children}
    +    
    + ), +} + +function TranscriptRow({ msg }: { msg: TranscriptMessage }) { + if (msg.role === 'user') { + return ( +
    +
    +

    {msg.text}

    +
    +
    + ) + } + + return ( +
    + +
    + {msg.text && ( + + {msg.text} + + )} + {msg.tools.length > 0 && ( +
    + {msg.tools.slice(0, 4).map((tool, i) => ( + + {tool.name} + {(tool.description || tool.path) && ( + + {(tool.description ?? tool.path ?? '').slice(0, 25)} + + )} + + ))} + {msg.tools.length > 4 && ( + +{msg.tools.length - 4} + )} +
    + )} +
    +
    + ) +} + +// ─── TicketCard ────────────────────────────────────────────────────────────── + +function TicketCard({ ticket }: { ticket: Ticket }) { + const statusStyle = TICKET_STATUS_STYLE[ticket.status] ?? 'text-text-muted/60 bg-white/5' + const statusText = ticket.status.replace('_', ' ') + + return ( +
    +
    + +
    +

    {ticket.title}

    +
    + + {statusText} + + {ticket.branch_name && ( + + {ticket.branch_name} + + )} + {ticket.tags.slice(0, 2).map(tag => ( + {tag} + ))} +
    +
    +
    +
    + ) +} + +// ─── SessionDetail (right column) ─────────────────────────────────────────── + +function SessionDetail({ + session, + tickets, + onOpenInApp, +}: { + session: ClaudeSession + tickets: Ticket[] + onOpenInApp: (env: UshadowEnvironment) => void +}) { + const [transcript, setTranscript] = useState(null) + const [loadingTranscript, setLoadingTranscript] = useState(false) + const transcriptBottomRef = useRef(null) + + const env = session.environment + const colors = getColors(env?.color ?? env?.name ?? session.cwd.split('/').pop() ?? 'default') + + // Reset transcript when session changes + useEffect(() => { + setTranscript(null) + setLoadingTranscript(false) + }, [session.session_id]) + + // Poll transcript for active sessions + useEffect(() => { + const fetch = () => { + tauri.readClaudeTranscript(session.session_id, session.cwd) + .then(msgs => { setTranscript(msgs); setLoadingTranscript(false) }) + .catch(() => { setTranscript([]); setLoadingTranscript(false) }) + } + + setLoadingTranscript(true) + fetch() + + if (session.status === 'Ended') return + + const interval = setInterval(fetch, 3000) + return () => clearInterval(interval) + }, [session.session_id, session.cwd, session.status]) // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-scroll to bottom when transcript updates + useEffect(() => { + transcriptBottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [transcript]) + + const [approvalPending, setApprovalPending] = useState(false) + const [approvalError, setApprovalError] = useState(null) + const [approvedLocally, setApprovedLocally] = useState(false) + + // Reset approval state when switching sessions + useEffect(() => { + setApprovedLocally(false) + setApprovalError(null) + }, [session.session_id]) + + const handleApprove = async (approve: boolean) => { + setApprovalPending(true) + setApprovalError(null) + try { + await tauri.sendClaudeApproval(session.cwd, approve) + setApprovedLocally(true) // dismiss banner optimistically on success + } catch (e) { + setApprovalError(String(e)) + } finally { + setApprovalPending(false) + } + } + + const handleOpenVscode = (e: React.MouseEvent) => { + e.stopPropagation() + if (env?.path) tauri.openInVscode(env.path, env.name) + } + + const handleOpenApp = (e: React.MouseEvent) => { + e.stopPropagation() + if (env) onOpenInApp(env) + } + + return ( +
    + {/* Header */} +
    +
    + + {env?.name ?? session.cwd.split('/').slice(-2).join('/')} + + + {session.cwd.split('/').slice(-2).join('/')} + +
    + + {/* Approval banner */} + {session.status === 'WaitingForApproval' && !approvedLocally && ( +
    +
    + +
    +

    Waiting for your approval

    + {session.current_tool && ( +

    + {session.current_tool} + {session.current_tool_description && ( + {session.current_tool_description} + )} + {!session.current_tool_description && session.current_tool_path && ( + {session.current_tool_path.split('/').slice(-2).join('/')} + )} +

    + )} +
    +
    +
    + + + {env?.path && ( + + )} +
    + {approvalError && ( +
    +

    {approvalError}

    + +
    + )} +
    + )} + + {/* Kanban tickets */} + {tickets.length > 0 && ( +
    +

    Tickets

    +
    + {tickets.map(t => )} +
    +
    + )} + + {/* Conversation */} +
    +

    Conversation

    + {loadingTranscript && ( +
    + + Loading… +
    + )} + {!loadingTranscript && transcript !== null && transcript.length === 0 && ( +
    + {[...session.events].reverse().slice(0, 10).map((evt, i) => ( + + ))} +
    + )} + {!loadingTranscript && transcript && transcript.length > 0 && ( +
    + {transcript.map(msg => )} +
    +
    + )} +
    + + {/* Action buttons */} +
    + {env?.path && ( + + )} + {env?.running && ( + + )} +
    +
    + ) +} + +// ─── SessionTile (left column) ─────────────────────────────────────────────── + +function SessionTile({ + session, + selected, + onSelect, +}: { + session: ClaudeSession + selected: boolean + onSelect: () => void +}) { + const env = session.environment + const colors = getColors(env?.color ?? env?.name ?? session.cwd.split('/').pop() ?? 'default') + const isEnded = session.status === 'Ended' + + return ( +
    +
    + {/* Name + status row */} +
    +
    + + {env?.name ?? session.cwd.split('/').slice(-1)[0]} + +
    + {statusIcon(session.status)} + {statusLabel(session.status)} +
    +
    + + {formatAge(session.last_event_at)} +
    +
    + + {/* Task preview */} + {session.user_message && session.status !== 'Ended' && ( +

    + {session.user_message} +

    + )} + + {/* Current tool */} + {session.current_tool && session.status === 'Working' && ( +

    + {session.current_tool} + {session.current_tool_description && ( + + {session.current_tool_description.slice(0, 40)} + + )} +

    + )} + + {session.status === 'WaitingForApproval' && ( +
    +

    + ⚠ Approve{session.current_tool ? `: ${session.current_tool}` : ''} + {session.current_tool_description && ( + {session.current_tool_description.slice(0, 40)} + )} +

    +
    + )} +
    +
    + ) +} + +// ─── InstallHooksCard ──────────────────────────────────────────────────────── + +function InstallHooksCard({ installing, error, onInstall }: { + installing: boolean; error: string | null; onInstall: () => void +}) { + return ( +
    +
    + +
    +

    Connect Claude Code Sessions

    +

    + Install lightweight hooks that capture Claude Code session activity into the launcher. + Hooks run asynchronously and never block Claude. +

    + {error &&

    {error}

    } + +

    + Adds entries to ~/.claude/settings.json · Writes ~/.claude/hooks/ushadow_launcher_hook.py +

    +
    + ) +} + +// ─── ClaudeSessionsPanel ──────────────────────────────────────────────────── + +export function ClaudeSessionsPanel({ + sessions, hooksInstalled, installing, error, installSuccess, onInstallHooks, onOpenInApp, +}: ClaudeSessionsPanelProps) { + const [selectedId, setSelectedId] = useState(null) + const [sortInverted, setSortInverted] = useState(false) + const [allTickets, setAllTickets] = useState([]) + + // Fetch kanban tickets once on mount + useEffect(() => { + tauri.getTickets().then(setAllTickets).catch(() => {}) + }, []) + + // Apply sort inversion before splitting into active/ended + const allSessions = sortInverted ? [...sessions].reverse() : sessions + const active = allSessions.filter(s => s.status !== 'Ended') + const ended = allSessions.filter(s => s.status === 'Ended').slice(0, 5) + const selectedSession = + allSessions.find(s => s.session_id === selectedId) ?? + active[0] ?? + ended[0] ?? + null + + // Update selectedId when the auto-selected session changes (new session appears) + useEffect(() => { + if (!selectedId && active.length > 0) { + setSelectedId(active[0].session_id) + } + }, [active, selectedId]) + + // Tickets for the selected session: match by environment name or worktree path + const sessionTickets = selectedSession + ? allTickets.filter(t => + (t.environment_name && t.environment_name === selectedSession.environment?.name) || + (t.worktree_path && selectedSession.cwd.startsWith(t.worktree_path)) + ) + : [] + + return ( +
    + {/* Header — full width */} +
    +
    + +

    Claude Sessions

    + {active.length > 0 && ( + + {active.length} active + + )} +
    + {hooksInstalled && ( +
    + {installSuccess && !installing && ( + + + Updated + + )} + {error && !installing && ( + + {error} + + )} +
    +
    + Hooks active +
    + +
    + )} +
    + + {/* Loading / not-installed states — span both columns */} + {hooksInstalled === null && ( +
    + + Checking hooks… +
    + )} + {hooksInstalled === false && ( + + )} + + {/* Two-column body */} + {hooksInstalled && ( +
    + {/* ── Left: session tiles ── */} +
    +
    + Sessions + +
    + {active.length === 0 && ended.length === 0 && ( +
    + +

    No sessions yet.

    +

    + Run claude in any worktree. +

    +
    + )} + + {active.length > 0 && ( +
    +

    Active

    +
    + {active.map(s => ( + setSelectedId(s.session_id)} + /> + ))} +
    +
    + )} + + {ended.length > 0 && ( +
    +

    Ended

    +
    + {ended.map(s => ( + setSelectedId(s.session_id)} + /> + ))} +
    +
    + )} + +
    + + Last 24 hours +
    +
    + + {/* ── Right: session detail ── */} +
    + {selectedSession ? ( + + ) : ( +
    + +

    Select a session

    +
    + )} +
    +
    + )} +
    + ) +} diff --git a/ushadow/launcher/src/components/CreateEpicDialog.tsx b/ushadow/launcher/src/components/CreateEpicDialog.tsx new file mode 100644 index 00000000..d43c7a89 --- /dev/null +++ b/ushadow/launcher/src/components/CreateEpicDialog.tsx @@ -0,0 +1,180 @@ +import { useState } from 'react' + +interface CreateEpicDialogProps { + isOpen: boolean + onClose: () => void + onCreated: () => void + projectId?: string + backendUrl: string +} + +const PRESET_COLORS = [ + '#3B82F6', // blue + '#8B5CF6', // purple + '#EC4899', // pink + '#F59E0B', // amber + '#10B981', // green + '#06B6D4', // cyan + '#F97316', // orange + '#EF4444', // red +] + +export function CreateEpicDialog({ + isOpen, + onClose, + onCreated, + projectId, + backendUrl, +}: CreateEpicDialogProps) { + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [color, setColor] = useState(PRESET_COLORS[0]) + const [baseBranch, setBaseBranch] = useState('main') + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + + if (!isOpen) return null + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setCreating(true) + + try { + const { tauri } = await import('../hooks/useTauri') + + await tauri.createEpic( + title, + description || null, + color, + baseBranch, + null, // branch_name (not set during creation) + projectId || null + ) + + onCreated() + // Reset form + setTitle('') + setDescription('') + setColor(PRESET_COLORS[0]) + setBaseBranch('main') + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create epic') + } finally { + setCreating(false) + } + } + + return ( +
    +
    e.stopPropagation()} + > +

    Create New Epic

    + +
    + {/* Title */} +
    + + setTitle(e.target.value)} + className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white" + placeholder="Epic title" + required + data-testid="create-epic-title" + /> +
    + + {/* Description */} +
    + +