diff --git a/.env.example b/.env.example index 4f1b2ad5..760464d0 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,28 @@ PASSKEY_RP_ID=localhost PASSKEY_RP_NAME=OpenCode Manager PASSKEY_ORIGIN=http://localhost:5003 +# ============================================ +# Push Notifications (VAPID) +# Enables push notifications for the PWA (background alerts for agent +# questions, permission requests, errors, and session completions). +# +# Setup: +# 1. Generate keys: npx web-push generate-vapid-keys +# 2. Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY from the output +# 3. Set VAPID_SUBJECT to a mailto: address (REQUIRED for iOS/Safari) +# +# IMPORTANT: VAPID_SUBJECT MUST use the mailto: format for iOS/Safari +# push notifications to work. Apple's push service rejects https:// subjects. +# Example: mailto:you@yourdomain.com +# +# Browser requirements: +# - HTTPS required (except localhost) +# - Safari/iOS: Requires mailto: VAPID_SUBJECT and HTTPS +# ============================================ +# VAPID_PUBLIC_KEY= +# VAPID_PRIVATE_KEY= +# VAPID_SUBJECT=mailto:you@yourdomain.com + # ============================================ # Frontend Configuration (Vite) # These are optional - frontend uses defaults if not set diff --git a/.gitignore b/.gitignore index b49b896d..4f097953 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ coverage/ .vscode/ .idea/ *.test.js +*.tsbuildinfo data/ workspace/ diff --git a/README.md b/README.md index 0e1240ee..c35a69f8 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ On first launch, you'll be prompted to create an admin account. That's it! - **Mobile-First Design** - Responsive UI optimized for mobile - **PWA Installable** - Add to home screen on any device - **iOS Optimized** - Proper keyboard handling and swipe navigation +- **Push Notifications** - Background alerts for agent events when app is closed ## Installation @@ -163,6 +164,19 @@ PASSKEY_RP_NAME=OpenCode Manager PASSKEY_ORIGIN=https://yourdomain.com ``` +### Push Notifications (VAPID) + +Enable push notifications for the PWA (background alerts for agent questions, permission requests, errors, and session completions): + +```bash +# Generate keys: npx web-push generate-vapid-keys +VAPID_PUBLIC_KEY=your-public-key +VAPID_PRIVATE_KEY=your-private-key +VAPID_SUBJECT=mailto:you@yourdomain.com +``` + +**Important**: `VAPID_SUBJECT` MUST use `mailto:` format for iOS/Safari push notifications to work. Apple's push service rejects `https://` subjects. + ### Dev Server Ports The Docker container exposes ports `5100-5103` for running dev servers inside repositories: diff --git a/backend/package.json b/backend/package.json index 6646a42d..0c2d7dcf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "eventsource": "^4.1.0", "hono": "^4.11.7", "strip-json-comments": "^3.1.1", + "web-push": "^3.6.7", "zod": "^4.1.12" }, "devDependencies": { @@ -32,6 +33,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/bun": "latest", "@types/eventsource": "^3.0.0", + "@types/web-push": "^3.6.4", "@vitest/ui": "^3.2.4", "eslint": "^9.39.1", "typescript-eslint": "^8.45.0", diff --git a/backend/src/index.ts b/backend/src/index.ts index 7ce32e83..16593834 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,6 +17,7 @@ import { createProvidersRoutes } from './routes/providers' import { createOAuthRoutes } from './routes/oauth' import { createTitleRoutes } from './routes/title' import { createSSERoutes } from './routes/sse' +import { createNotificationRoutes } from './routes/notifications' import { createAuthRoutes, createAuthInfoRoutes, syncAdminFromEnv } from './routes/auth' import { createAuth } from './auth' import { createAuthMiddleware } from './auth/middleware' @@ -26,6 +27,7 @@ import { SettingsService } from './services/settings' import { opencodeServerManager } from './services/opencode-single-server' import { cleanupOrphanedDirectories } from './services/repo' import { proxyRequest } from './services/proxy' +import { NotificationService } from './services/notification' import { logger } from './utils/logger' import { getWorkspacePath, @@ -201,6 +203,27 @@ try { logger.error('Failed to initialize workspace:', error) } +const notificationService = new NotificationService(db) + +if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { + if (!ENV.VAPID.SUBJECT) { + logger.warn('VAPID_SUBJECT is not set — push notifications require a mailto: subject (e.g. mailto:you@example.com)') + } else if (!ENV.VAPID.SUBJECT.startsWith('mailto:')) { + logger.warn(`VAPID_SUBJECT="${ENV.VAPID.SUBJECT}" does not use mailto: format — iOS/Safari push notifications will fail`) + } + + notificationService.configureVapid({ + publicKey: ENV.VAPID.PUBLIC_KEY, + privateKey: ENV.VAPID.PRIVATE_KEY, + subject: ENV.VAPID.SUBJECT || 'mailto:push@localhost', + }) + sseAggregator.onEvent((directory, event) => { + notificationService.handleSSEEvent(directory, event).catch((err) => { + logger.error('Push notification dispatch error:', err) + }) + }) +} + app.route('/api/auth', createAuthRoutes(auth)) app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) @@ -218,6 +241,7 @@ protectedApi.route('/tts', createTTSRoutes(db)) protectedApi.route('/stt', createSTTRoutes(db)) protectedApi.route('/generate-title', createTitleRoutes()) protectedApi.route('/sse', createSSERoutes()) +protectedApi.route('/notifications', createNotificationRoutes(notificationService)) app.route('/api', protectedApi) diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts new file mode 100644 index 00000000..ad44ae78 --- /dev/null +++ b/backend/src/routes/notifications.ts @@ -0,0 +1,99 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { PushSubscriptionRequestSchema } from "@opencode-manager/shared/schemas"; +import type { NotificationService } from "../services/notification"; + +export function createNotificationRoutes( + notificationService: NotificationService +) { + const app = new Hono(); + + app.get("/vapid-public-key", (c) => { + const publicKey = notificationService.getVapidPublicKey(); + if (!publicKey) { + return c.json( + { error: "Push notifications are not configured" }, + 503 + ); + } + return c.json({ publicKey }); + }); + + app.post("/subscribe", async (c) => { + const userId = c.req.query('userId') || 'default' + const body = await c.req.json(); + const parsed = PushSubscriptionRequestSchema.safeParse(body); + + if (!parsed.success) { + return c.json({ error: "Invalid subscription data", details: parsed.error.issues }, 400); + } + + const { endpoint, keys, deviceName } = parsed.data; + + const subscription = notificationService.saveSubscription( + userId, + endpoint, + keys.p256dh, + keys.auth, + deviceName + ); + + return c.json({ subscription }); + }); + + app.delete("/subscribe", async (c) => { + const userId = c.req.query('userId') || 'default' + const body = await c.req.json(); + const parsed = z.object({ endpoint: z.string().url() }).safeParse(body); + + if (!parsed.success) { + return c.json({ error: "Valid endpoint URL is required", details: parsed.error.issues }, 400); + } + + const removed = notificationService.removeSubscription(parsed.data.endpoint, userId); + return c.json({ success: removed }); + }); + + app.get("/subscriptions", (c) => { + const userId = c.req.query('userId') || 'default' + const subscriptions = notificationService.getSubscriptions(userId); + return c.json({ subscriptions }); + }); + + app.delete("/subscriptions/:id", (c) => { + const userId = c.req.query('userId') || 'default' + const id = parseInt(c.req.param("id"), 10); + + if (isNaN(id)) { + return c.json({ error: "Invalid subscription ID" }, 400); + } + + const removed = notificationService.removeSubscriptionById(id, userId); + return c.json({ success: removed }); + }); + + app.post("/test", async (c) => { + const userId = c.req.query('userId') || 'default' + + if (!notificationService.isConfigured()) { + return c.json( + { error: "Push notifications are not configured" }, + 503 + ); + } + + const subscriptions = notificationService.getSubscriptions(userId); + if (subscriptions.length === 0) { + return c.json( + { error: "No push subscriptions registered" }, + 404 + ); + } + + await notificationService.sendTestNotification(userId); + + return c.json({ success: true, devicesNotified: subscriptions.length }); + }); + + return app; +} diff --git a/backend/src/routes/sse.ts b/backend/src/routes/sse.ts index 980e2caf..bf8384b2 100644 --- a/backend/src/routes/sse.ts +++ b/backend/src/routes/sse.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' import { stream } from 'hono/streaming' import { sseAggregator } from '../services/sse-aggregator' -import { SSESubscribeSchema } from '@opencode-manager/shared/schemas' +import { SSESubscribeSchema, SSEVisibilitySchema } from '@opencode-manager/shared/schemas' import { logger } from '../utils/logger' import { DEFAULTS } from '@opencode-manager/shared/config' @@ -88,6 +88,19 @@ export function createSSERoutes() { return c.json({ success: true }) }) + app.post('/visibility', async (c) => { + const body = await c.req.json() + const result = SSEVisibilitySchema.safeParse(body) + if (!result.success) { + return c.json({ success: false, error: 'Invalid request', details: result.error.issues }, 400) + } + const success = sseAggregator.setClientVisibility(result.data.clientId, result.data.visible) + if (!success) { + return c.json({ success: false, error: 'Client not found' }, 404) + } + return c.json({ success: true }) + }) + app.get('/status', (c) => { return c.json({ ...sseAggregator.getConnectionStatus(), diff --git a/backend/src/services/files.ts b/backend/src/services/files.ts index a3452cc7..7da56d68 100644 --- a/backend/src/services/files.ts +++ b/backend/src/services/files.ts @@ -274,36 +274,24 @@ function getMimeType(filePath: string): AllowedMimeType { return mimeTypes[ext] || 'text/plain' } -async function countFileLines(filePath: string): Promise { - return new Promise((resolve, reject) => { - let lineCount = 0 - const stream = createReadStream(filePath, { encoding: 'utf8' }) - const rl = createInterface({ input: stream, crlfDelay: Infinity }) - - rl.on('line', () => { lineCount++ }) - rl.on('close', () => resolve(lineCount)) - rl.on('error', reject) - }) -} - -async function readFileLines(filePath: string, startLine: number, endLine: number): Promise { +async function readFileLinesAndCount( + filePath: string, + startLine: number, + endLine: number +): Promise<{ lines: string[]; totalLines: number }> { return new Promise((resolve, reject) => { const lines: string[] = [] let currentLine = 0 const stream = createReadStream(filePath, { encoding: 'utf8' }) const rl = createInterface({ input: stream, crlfDelay: Infinity }) - + rl.on('line', (line) => { if (currentLine >= startLine && currentLine < endLine) { lines.push(line) } currentLine++ - if (currentLine >= endLine) { - rl.close() - stream.destroy() - } }) - rl.on('close', () => resolve(lines)) + rl.on('close', () => resolve({ lines, totalLines: currentLine })) rl.on('error', reject) }) } @@ -322,9 +310,8 @@ export async function getFileRange(userPath: string, startLine: number, endLine: throw { message: 'Path is a directory', statusCode: 400 } } - const totalLines = await countFileLines(validatedPath) + const { lines, totalLines } = await readFileLinesAndCount(validatedPath, startLine, endLine) const clampedEnd = Math.min(endLine, totalLines) - const lines = await readFileLines(validatedPath, startLine, clampedEnd) const mimeType = getMimeType(validatedPath) return { diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts new file mode 100644 index 00000000..ea7ce3ee --- /dev/null +++ b/backend/src/services/notification.ts @@ -0,0 +1,296 @@ +import { Database } from "bun:sqlite"; +import webpush from "web-push"; +import { logger } from "../utils/logger"; +import type { PushSubscriptionRecord } from "../types/settings"; +import type { PushNotificationPayload } from "@opencode-manager/shared/types"; +import { + NotificationEventType, + DEFAULT_NOTIFICATION_PREFERENCES, +} from "@opencode-manager/shared/schemas"; +import { SettingsService } from "./settings"; +import { sseAggregator, type SSEEvent } from "./sse-aggregator"; +import { getRepoByLocalPath } from "../db/queries"; +import { getReposPath } from "@opencode-manager/shared/config/env"; +import path from "path"; + +interface VapidConfig { + publicKey: string; + privateKey: string; + subject: string; +} + +const EVENT_CONFIG: Record< + string, + { preferencesKey: keyof typeof DEFAULT_NOTIFICATION_PREFERENCES.events; title: string; bodyFn: (props: Record) => string } +> = { + [NotificationEventType.PERMISSION_ASKED]: { + preferencesKey: "permissionAsked", + title: "Permission Required", + bodyFn: () => "OpenCode needs your approval to continue", + }, + [NotificationEventType.QUESTION_ASKED]: { + preferencesKey: "questionAsked", + title: "Question from Agent", + bodyFn: () => "The agent has a question for you", + }, + [NotificationEventType.SESSION_ERROR]: { + preferencesKey: "sessionError", + title: "Session Error", + bodyFn: (props) => { + const error = props.error as { message?: string } | undefined; + return error?.message ?? "A session encountered an error"; + }, + }, + [NotificationEventType.SESSION_IDLE]: { + preferencesKey: "sessionIdle", + title: "Session Complete", + bodyFn: () => "Your session has finished processing", + }, +}; + +export class NotificationService { + private vapidConfig: VapidConfig | null = null; + private settingsService: SettingsService; + + constructor(private db: Database) { + this.settingsService = new SettingsService(db); + this.initializePushSubscriptionsTable(); + } + + private initializePushSubscriptionsTable(): void { + this.db.run(` + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + device_name TEXT, + created_at INTEGER NOT NULL, + last_used_at INTEGER + ) + `); + this.db.run( + "CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_id)" + ); + this.db.run( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_push_sub_endpoint ON push_subscriptions(endpoint)" + ); + } + + configureVapid(config: VapidConfig): void { + this.vapidConfig = config; + webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey); + } + + getVapidPublicKey(): string | null { + return this.vapidConfig?.publicKey ?? null; + } + + isConfigured(): boolean { + return this.vapidConfig !== null; + } + + saveSubscription( + userId: string, + endpoint: string, + p256dh: string, + auth: string, + deviceName?: string + ): PushSubscriptionRecord { + const now = Date.now(); + + this.db + .prepare( + `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device_name, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET + user_id = excluded.user_id, + p256dh = excluded.p256dh, + auth = excluded.auth, + device_name = excluded.device_name, + last_used_at = excluded.last_used_at` + ) + .run(userId, endpoint, p256dh, auth, deviceName ?? null, now, now); + + const row = this.db + .prepare("SELECT * FROM push_subscriptions WHERE endpoint = ?") + .get(endpoint) as { + id: number; + user_id: string; + endpoint: string; + p256dh: string; + auth: string; + device_name: string | null; + created_at: number; + last_used_at: number | null; + }; + + return { + id: row.id, + userId: row.user_id, + endpoint: row.endpoint, + p256dh: row.p256dh, + auth: row.auth, + deviceName: row.device_name, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + }; + } + + removeSubscription(endpoint: string, userId?: string): boolean { + if (userId) { + const result = this.db + .prepare("DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?") + .run(endpoint, userId); + return result.changes > 0; + } + const result = this.db + .prepare("DELETE FROM push_subscriptions WHERE endpoint = ?") + .run(endpoint); + return result.changes > 0; + } + + removeSubscriptionById(id: number, userId: string): boolean { + const result = this.db + .prepare("DELETE FROM push_subscriptions WHERE id = ? AND user_id = ?") + .run(id, userId); + return result.changes > 0; + } + + getSubscriptions(userId: string): PushSubscriptionRecord[] { + const rows = this.db + .prepare("SELECT * FROM push_subscriptions WHERE user_id = ? ORDER BY created_at DESC") + .all(userId) as Array<{ + id: number; + user_id: string; + endpoint: string; + p256dh: string; + auth: string; + device_name: string | null; + created_at: number; + last_used_at: number | null; + }>; + + return rows.map((row) => ({ + id: row.id, + userId: row.user_id, + endpoint: row.endpoint, + p256dh: row.p256dh, + auth: row.auth, + deviceName: row.device_name, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + })); + } + + getAllUserIds(): string[] { + const rows = this.db + .prepare("SELECT DISTINCT user_id FROM push_subscriptions") + .all() as Array<{ user_id: string }>; + return rows.map((r) => r.user_id); + } + + private hasActiveSSEClients(): boolean { + return sseAggregator.hasVisibleClients(); + } + + async handleSSEEvent( + _directory: string, + event: SSEEvent + ): Promise { + const config = EVENT_CONFIG[event.type]; + if (!config) return; + + if (this.hasActiveSSEClients()) return; + + if (!this.isConfigured()) return; + + const userIds = this.getAllUserIds(); + + for (const userId of userIds) { + const settings = this.settingsService.getSettings(userId); + const notifPrefs = + settings.preferences.notifications ?? DEFAULT_NOTIFICATION_PREFERENCES; + + if (!notifPrefs.enabled) continue; + if (!notifPrefs.events[config.preferencesKey]) continue; + + const sessionId = event.properties.sessionID as string | undefined; + + let notificationUrl = "/"; + if (sessionId && _directory) { + const reposBasePath = getReposPath(); + const localPath = path.relative(reposBasePath, _directory); + const repo = getRepoByLocalPath(this.db, localPath); + + if (repo) { + notificationUrl = `/repos/${repo.id}/sessions/${sessionId}`; + } + } + + const payload: PushNotificationPayload = { + title: config.title, + body: config.bodyFn(event.properties), + tag: `${event.type}-${sessionId ?? "global"}`, + data: { + eventType: event.type, + sessionId, + directory: _directory, + url: notificationUrl, + }, + }; + + await this.sendToUser(userId, payload); + } + } + + async sendTestNotification(userId: string): Promise { + await this.sendToUser(userId, { + title: "Test Notification", + body: "Push notifications are working correctly", + tag: "test", + data: { eventType: "test", url: "/" }, + }); + } + + private async sendToUser( + userId: string, + payload: PushNotificationPayload + ): Promise { + const subscriptions = this.getSubscriptions(userId); + const expiredEndpoints: string[] = []; + + await Promise.allSettled( + subscriptions.map(async (sub) => { + try { + await webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { p256dh: sub.p256dh, auth: sub.auth }, + }, + JSON.stringify(payload) + ); + + this.db + .prepare( + "UPDATE push_subscriptions SET last_used_at = ? WHERE id = ?" + ) + .run(Date.now(), sub.id); + } catch (error) { + const statusCode = (error as { statusCode?: number }).statusCode; + + if (statusCode === 404 || statusCode === 410) { + expiredEndpoints.push(sub.endpoint); + } else { + logger.error(`Push delivery failed for ${sub.endpoint.slice(0, 50)}:`, error); + } + } + }) + ); + + for (const endpoint of expiredEndpoints) { + this.removeSubscription(endpoint); + } + } +} diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index 91c24d3c..f4833872 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -4,11 +4,13 @@ import { ENV } from '@opencode-manager/shared/config/env' import { DEFAULTS } from '@opencode-manager/shared/config' type SSEClientCallback = (event: string, data: string) => void +type SSEEventListener = (directory: string, event: SSEEvent) => void interface SSEClient { id: string callback: SSEClientCallback directories: Set + visible: boolean } interface DirectoryConnection { @@ -18,7 +20,7 @@ interface DirectoryConnection { isConnected: boolean } -interface SSEEvent { +export interface SSEEvent { type: string properties: Record } @@ -33,6 +35,7 @@ class SSEAggregator { private activeSessions: Map> = new Map() private idleTimeouts: Map> = new Map() private sessionStateVersion: Map = new Map() + private eventListeners: Set = new Set() private constructor() {} @@ -47,7 +50,8 @@ class SSEAggregator { const client: SSEClient = { id, callback, - directories: new Set(directories) + directories: new Set(directories), + visible: false } this.clients.set(id, client) @@ -192,10 +196,18 @@ class SSEAggregator { }, conn.reconnectDelay) } + onEvent(listener: SSEEventListener): () => void { + this.eventListeners.add(listener) + return () => { this.eventListeners.delete(listener) } + } + private broadcastToDirectory(directory: string, event: string, data: string): void { try { const parsed = JSON.parse(data) as SSEEvent this.handleEvent(directory, parsed) + this.eventListeners.forEach(listener => { + try { listener(directory, parsed) } catch { /* ignore listener errors */ } + }) } catch { // Ignore parse errors } @@ -341,6 +353,23 @@ class SSEAggregator { return this.clients.size } + setClientVisibility(id: string, visible: boolean): boolean { + const client = this.clients.get(id) + if (!client) { + logger.warn(`setClientVisibility: client ${id} not found`) + return false + } + client.visible = visible + return true + } + + hasVisibleClients(): boolean { + for (const client of this.clients.values()) { + if (client.visible) return true + } + return false + } + getActiveDirectories(): string[] { return Array.from(this.connections.keys()) } @@ -364,6 +393,7 @@ class SSEAggregator { }) this.connections.clear() this.clients.clear() + this.eventListeners.clear() } getActiveSessions(): Record { diff --git a/docker-compose.yml b/docker-compose.yml index 27ef3746..88f4caf7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,9 @@ services: - PASSKEY_RP_ID=${PASSKEY_RP_ID:-localhost} - PASSKEY_RP_NAME=${PASSKEY_RP_NAME:-OpenCode Manager} - PASSKEY_ORIGIN=${PASSKEY_ORIGIN:-http://localhost:5003} + - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-} + - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-} + - VAPID_SUBJECT=${VAPID_SUBJECT:-} volumes: - opencode-workspace:/workspace - opencode-data:/app/data diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index d5119150..7d48b0e5 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -33,9 +33,38 @@ Complete reference for all configuration options. | `PASSKEY_RP_NAME` | Display name for passkey prompts | `OpenCode Manager` | | `PASSKEY_ORIGIN` | Origin URL for WebAuthn (backend port) | `http://localhost:5003` | -!!! tip "IMPORTANT" - - `PASSKEY_ORIGIN` must use the **backend** port (5003), not the frontend port (5173) - - The origin must exactly match where the auth API is served + +## Push Notifications (VAPID) + +| Variable | Description | Required | +|----------|-------------|----------| +| `VAPID_PUBLIC_KEY` | VAPID public key for push notifications | Yes | +| `VAPID_PRIVATE_KEY` | VAPID private key for push notifications | Yes | +| `VAPID_SUBJECT` | Contact email for VAPID (MUST use `mailto:` format) | Yes | + +### Generating VAPID Keys + +Generate VAPID public/private key pair: + +```bash +npx web-push generate-vapid-keys +``` + +Add to `.env`: + +```bash +VAPID_PUBLIC_KEY=BMx-1234567890abcdefghijklmnopqrstuv... +VAPID_PRIVATE_KEY=abcd1234567890abcdef... +VAPID_SUBJECT=mailto:you@example.com +``` + +!!! warning "iOS/Safari Requirement" + `VAPID_SUBJECT` **MUST** use `mailto:` format for iOS/Safari push notifications to work. Apple's push service rejects `https://` subjects. + + **Correct:** `VAPID_SUBJECT=mailto:you@yourdomain.com` + **Incorrect:** `VAPID_SUBJECT=https://yourdomain.com` + +When configured, users can enable push notifications in Settings → Notifications to receive background alerts for agent events. ## Server @@ -67,6 +96,11 @@ GITHUB_CLIENT_SECRET=your-client-secret PASSKEY_RP_ID=localhost PASSKEY_RP_NAME=OpenCode Manager PASSKEY_ORIGIN=http://localhost:5003 + +# Push notifications (optional) +VAPID_PUBLIC_KEY=BMx-1234567890abcdefghijklmnopqrstuv... +VAPID_PRIVATE_KEY=abcd1234567890abcdef... +VAPID_SUBJECT=mailto:you@example.com ``` ## Generating Secrets @@ -84,10 +118,32 @@ Output example: K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols= ``` -!!! warning "Security" - - Never commit AUTH_SECRET to version control - - Use different secrets for development and production - - Rotate secrets periodically + +### VAPID Keys + +Generate VAPID public/private key pair for push notifications: + +```bash +npx web-push generate-vapid-keys +``` + +Output example: +``` +======================================= +Public Key: +BMx-1234567890abcdefghijklmnopqrstuv... + +Private Key: +abcd1234567890abcdef... + +Subject: +mailto:you@example.com +=========================================== +``` + +!!! warning "iOS/Safari Requirement" + `VAPID_SUBJECT` **MUST** use `mailto:` format for iOS/Safari push notifications to work. + ## Environment Precedence @@ -98,14 +154,4 @@ Variables are loaded in this order (later overrides earlier): 3. Docker Compose `environment` section 4. Docker Compose `env_file` reference -## Validating Configuration - -Check your configuration: - -```bash -# View current environment (inside container) -docker exec opencode-manager env | grep -E '^(AUTH|ADMIN|PASSKEY)' - -# Check if secrets are set (without revealing values) -docker exec opencode-manager sh -c 'if [ -n "$AUTH_SECRET" ]; then echo "AUTH_SECRET: set"; else echo "AUTH_SECRET: NOT SET"; fi' ``` diff --git a/docs/features/notifications.md b/docs/features/notifications.md new file mode 100644 index 00000000..91386045 --- /dev/null +++ b/docs/features/notifications.md @@ -0,0 +1,116 @@ +# Push Notifications + +Send background notifications when the OpenCode Manager PWA is closed, keeping you informed of agent activity without keeping the app open. + +## Overview + +Push notifications allow you to receive alerts on your mobile device or desktop when: + +- The **agent needs permission** to continue (file operations, tool use, etc.) +- The **agent has a question** for you (clarifications, confirmations) +- A **session encounters an error** during execution +- A **session completes successfully** + +Notifications are only sent when you don't have the app open (no SSE connections), preventing duplicate alerts while you're actively monitoring a session. + +## Supported Events + +| Event | Description | Default | +|-------|-------------|---------| +| `permissionAsked` | Agent requests permission for an action | Enabled | +| `questionAsked` | Agent asks a clarifying question | Enabled | +| `sessionError` | Session encounters an error | Enabled | +| `sessionIdle` | Session completes successfully | Disabled | + +## Browser Compatibility + +Push notifications require HTTPS (except on `localhost`) and browser support: + +| Browser | Support | Notes | +|---------|---------|-------| +| Chrome/Edge | ✅ Full | Works well | +| Firefox | ✅ Full | Works well | +| Safari (iOS/macOS) | ✅ Full | Requires `mailto:` VAPID subject | +| Android browser | ✅ Full | Works well | + +### iOS/Safari Requirements + +Apple's Push Notification Service (APNs) has strict requirements: + +1. **HTTPS is required** - `localhost` testing requires Safari Dev Tools +2. **VAPID_SUBJECT must use `mailto:` format** - `https://` subjects are rejected + +**Correct:** `VAPID_SUBJECT=mailto:you@yourdomain.com` +**Incorrect:** `VAPID_SUBJECT=https://yourdomain.com` + +## Setup + +### 1. Generate VAPID Keys + +Generate VAPID public/private key pair: + +```bash +npx web-push generate-vapid-keys +``` + +Output: +``` +======================================= +Public Key: +BMx-123456... (your public key here) + +Private Key: +abcd1234... (your private key here) + +Subject: +mailto:your-email@example.com +======================================= +``` + +### 2. Configure Environment Variables + +Add to your `.env` file: + +```bash +VAPID_PUBLIC_KEY=BMx-123456... +VAPID_PRIVATE_KEY=abcd1234... +VAPID_SUBJECT=mailto:you@yourdomain.com +``` + +### 3. Subscribe Devices + +1. Open OpenCode Manager in your browser +2. Go to **Settings** → **Notifications** +3. Click **Enable Push Notifications** +4. Allow browser permission when prompted +5. Your device is now subscribed + +## Managing Subscriptions + +### View Subscribed Devices + +Navigate to **Settings** → **Notifications** to see all registered devices: + +- Device name (if provided) +- Subscription date +- Last used timestamp + +### Remove a Device + +Click **Unsubscribe** next to a device to remove it from receiving notifications. + +### Test Notifications + +Go to **Settings** → **Notifications** and click **Send Test Notification** to verify your setup is working. + +## Notification Preferences + +Control which events trigger notifications: + +**Notification Settings:** +- **Enable Push Notifications** - Master toggle (default: off) +- **Permission Requested** - Get notified when agent needs permission (default: on) +- **Question Asked** - Get notified when agent has a question (default: on) +- **Session Error** - Get notified on session errors (default: on) +- **Session Complete** - Get notified when session finishes (default: off) + diff --git a/docs/features/overview.md b/docs/features/overview.md index a22cf818..821eecc6 100644 --- a/docs/features/overview.md +++ b/docs/features/overview.md @@ -68,3 +68,12 @@ OpenCode Manager provides a comprehensive web interface for managing OpenCode AI [Learn more →](mobile.md) +### Push Notifications + +- **Background Alerts** - Receive notifications when the app is closed +- **Agent Events** - Get alerted for permissions, questions, errors, and completions +- **Multi-Device** - Subscribe multiple devices (phone, tablet, desktop) +- **Customizable** - Control which events trigger notifications + +[Learn more →](notifications.md) + diff --git a/docs/index.md b/docs/index.md index ba78df68..2a2cbcef 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,7 @@ OpenCode Manager provides a web-based interface for OpenCode AI agents, allowing - **Manage repositories** - Clone, browse, and work with multiple git repos - **Chat with AI** - Real-time streaming chat with file mentions and slash commands - **View diffs** - See code changes with syntax highlighting -- **Control from anywhere** - Mobile-first PWA works on any device +- **Control from anywhere** - Mobile-first PWA with push notifications - **Configure AI** - Manage models, providers, and MCP servers ## Key Features @@ -31,6 +31,7 @@ OpenCode Manager provides a web-based interface for OpenCode AI agents, allowing - **Git Integration** - View diffs, manage branches, create PRs directly from the UI - **Real-time Chat** - Stream responses with file mentions and custom slash commands - **Mobile-First PWA** - Install as an app on any device with offline support +- **Push Notifications** - Get background alerts for agent events when app is closed - **AI Configuration** - Configure models, providers, OAuth, and custom agents - **MCP Servers** - Add local or remote MCP servers with pre-built templates diff --git a/frontend/index.html b/frontend/index.html index 1b4cc870..811d6096 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,7 @@ + diff --git a/frontend/package.json b/frontend/package.json index b8ba2f8e..d1ba49a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -73,6 +73,8 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7", - "vitest": "^3.2.4" + "vite-plugin-pwa": "^1.2.0", + "vitest": "^3.2.4", + "workbox-precaching": "^7.4.0" } } diff --git a/frontend/public/icons/apple-touch-icon.png b/frontend/public/icons/apple-touch-icon.png new file mode 100644 index 00000000..dba0c396 Binary files /dev/null and b/frontend/public/icons/apple-touch-icon.png differ diff --git a/frontend/public/icons/icon-192x192.png b/frontend/public/icons/icon-192x192.png new file mode 100644 index 00000000..fe7dfb41 Binary files /dev/null and b/frontend/public/icons/icon-192x192.png differ diff --git a/frontend/public/icons/icon-512x512.png b/frontend/public/icons/icon-512x512.png new file mode 100644 index 00000000..e7953319 Binary files /dev/null and b/frontend/public/icons/icon-512x512.png differ diff --git a/frontend/public/icons/icon-maskable-192x192.png b/frontend/public/icons/icon-maskable-192x192.png new file mode 100644 index 00000000..226cfcaa Binary files /dev/null and b/frontend/public/icons/icon-maskable-192x192.png differ diff --git a/frontend/public/icons/icon-maskable-512x512.png b/frontend/public/icons/icon-maskable-512x512.png new file mode 100644 index 00000000..7c6f6603 Binary files /dev/null and b/frontend/public/icons/icon-maskable-512x512.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index b5536536..05d18061 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -1,4 +1,5 @@ { + "id": "/", "name": "OpenCode Manager", "short_name": "OpenCode", "description": "AI-powered coding assistant interface", @@ -13,8 +14,30 @@ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" + }, + { + "src": "/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/icons/icon-maskable-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/icon-maskable-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ], "categories": ["developer", "productivity", "utilities"], "lang": "en" -} \ No newline at end of file +} diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts new file mode 100644 index 00000000..6b44c744 --- /dev/null +++ b/frontend/src/api/notifications.ts @@ -0,0 +1,64 @@ +import type { PushSubscriptionRecord } from "@opencode-manager/shared/types"; +import { API_BASE_URL } from "@/config"; +import { fetchWrapper } from "./fetchWrapper"; + +export const notificationsApi = { + getVapidPublicKey: async (): Promise<{ publicKey: string }> => { + return fetchWrapper(`${API_BASE_URL}/api/notifications/vapid-public-key`); + }, + + subscribe: async ( + subscription: PushSubscriptionJSON, + deviceName?: string, + userId = 'default' + ): Promise<{ subscription: PushSubscriptionRecord }> => { + return fetchWrapper(`${API_BASE_URL}/api/notifications/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint: subscription.endpoint, + keys: subscription.keys, + deviceName, + }), + params: { userId }, + }); + }, + + unsubscribe: async (endpoint: string, userId = 'default'): Promise<{ success: boolean }> => { + return fetchWrapper(`${API_BASE_URL}/api/notifications/subscribe`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint }), + params: { userId }, + }); + }, + + getSubscriptions: async (userId = 'default'): Promise<{ + subscriptions: PushSubscriptionRecord[]; + }> => { + return fetchWrapper(`${API_BASE_URL}/api/notifications/subscriptions`, { + params: { userId }, + }); + }, + + removeSubscription: async ( + id: number, + userId = 'default' + ): Promise<{ success: boolean }> => { + return fetchWrapper( + `${API_BASE_URL}/api/notifications/subscriptions/${id}`, + { method: "DELETE", params: { userId } } + ); + }, + + sendTest: async (userId = 'default'): Promise<{ + success: boolean; + devicesNotified: number; + }> => { + return fetchWrapper(`${API_BASE_URL}/api/notifications/test`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + params: { userId }, + }); + }, +}; diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index 76f00d20..ba4098a1 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -8,8 +8,9 @@ import { type STTConfig, type OpenCodeConfigContent, } from '@opencode-manager/shared' +import type { NotificationPreferences } from '@opencode-manager/shared/types' -export type { TTSConfig, STTConfig, OpenCodeConfigContent } +export type { TTSConfig, STTConfig, OpenCodeConfigContent, NotificationPreferences } export { DEFAULT_TTS_CONFIG, DEFAULT_STT_CONFIG, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_USER_PREFERENCES, DEFAULT_LEADER_KEY } export interface CustomCommand { @@ -54,6 +55,7 @@ export interface UserPreferences { gitIdentity?: GitIdentity tts?: TTSConfig stt?: STTConfig + notifications?: NotificationPreferences repoOrder?: number[] } diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx index bf4b079f..a7bd732f 100644 --- a/frontend/src/components/file-browser/FilePreview.tsx +++ b/frontend/src/components/file-browser/FilePreview.tsx @@ -9,7 +9,7 @@ import { MarkdownRenderer } from './MarkdownRenderer' const API_BASE = API_BASE_URL -const VIRTUALIZATION_THRESHOLD_BYTES = 8_000 +const VIRTUALIZATION_THRESHOLD_BYTES = 50_000 const MARKDOWN_PREVIEW_SIZE_LIMIT = 1_000_000 interface FilePreviewProps { diff --git a/frontend/src/components/message/EditableUserMessage.tsx b/frontend/src/components/message/EditableUserMessage.tsx index 43de5ec3..cc3aa8ce 100644 --- a/frontend/src/components/message/EditableUserMessage.tsx +++ b/frontend/src/components/message/EditableUserMessage.tsx @@ -89,7 +89,7 @@ export const EditableUserMessage = memo(function EditableUserMessage({ onKeyDown={handleKeyDown} onFocus={() => setIsEditingMessage(true)} onBlur={() => setIsEditingMessage(false)} - className="w-full p-3 rounded-lg bg-background border border-primary/50 focus:border-primary focus:ring-1 focus:ring-primary outline-none resize-none min-h-[60px] text-sm" + className="w-full p-3 rounded-lg bg-background border border-primary/50 focus:border-primary focus:ring-1 focus:ring-primary outline-none resize-none min-h-[60px] text-[16px] md:text-sm" placeholder="Edit your message..." disabled={refreshMessage.isPending} /> diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 680fe783..087b651c 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -12,10 +12,9 @@ import { useUserBash } from '@/stores/userBashStore' import { useMobile } from '@/hooks/useMobile' import { useSessionStatusForSession } from '@/stores/sessionStatusStore' import { usePermissions } from '@/contexts/EventContext' -import { ChevronDown, Upload, X, Mic } from 'lucide-react' +import { ChevronDown, Upload, X, Mic, MicOff } from 'lucide-react' import { CommandSuggestions } from '@/components/command/CommandSuggestions' -import { SquareFill } from '@/components/ui/square-fill' import { MentionSuggestions, type MentionItem } from './MentionSuggestions' import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' import { ModelQuickSelect } from '@/components/model/ModelQuickSelect' @@ -765,7 +764,7 @@ return ( className="border fixed bottom-19 right-0 md:hidden z-50 p-3 rounded-xl transition-all duration-200 active:scale-95 hover:scale-105 bg-gradient-to-br from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-destructive-foreground border border-red-500/60 shadow-lg shadow-red-500/30" title="Stop" > - + )} @@ -863,7 +862,7 @@ return ( className="hidden md:block p-1.5 px-5 md:p-2 md:px-6 rounded-lg transition-all duration-200 active:scale-95 hover:scale-105 bg-gradient-to-br from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-destructive-foreground border border-red-500/60 hover:border-red-400 shadow-md shadow-red-500/30 hover:shadow-red-500/40 ring-1 ring-red-500/20 hover:ring-red-500/30" title="Stop" > - + )} ) : isRecording ? ( - + ) : ( )} @@ -920,7 +919,7 @@ return ( ) : isProcessing && !isRecording ? (
) : isRecording ? ( - + ) : ( )} diff --git a/frontend/src/components/message/RecordingOverlay.tsx b/frontend/src/components/message/RecordingOverlay.tsx index da266ed7..1ee7a961 100644 --- a/frontend/src/components/message/RecordingOverlay.tsx +++ b/frontend/src/components/message/RecordingOverlay.tsx @@ -1,5 +1,4 @@ -import { X, Mic } from 'lucide-react' -import { SquareFill } from '@/components/ui/square-fill' +import { X, Mic, MicOff } from 'lucide-react' interface RecordingOverlayProps { interimTranscript: string @@ -44,7 +43,7 @@ export function RecordingOverlay({ onClick={onStop} className="flex items-center gap-2 px-4 py-1.5 rounded-lg text-sm font-medium bg-gradient-to-br from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-white border border-red-500/60 shadow-md shadow-red-500/30 transition-all duration-200 active:scale-95" > - + Done
diff --git a/frontend/src/components/model/ModelSelectDialog.tsx b/frontend/src/components/model/ModelSelectDialog.tsx index 6f0aa103..decc1797 100644 --- a/frontend/src/components/model/ModelSelectDialog.tsx +++ b/frontend/src/components/model/ModelSelectDialog.tsx @@ -57,7 +57,7 @@ function SearchInput({ onSearch, initialValue = "" }: SearchInputProps) { placeholder="Search models..." value={value} onChange={(e) => setValue(e.target.value)} - className="pl-10 text-sm" + className="pl-10 md:text-sm" /> diff --git a/frontend/src/components/repo/RepoMcpDialog.tsx b/frontend/src/components/repo/RepoMcpDialog.tsx index 5aacf365..a5b0d50a 100644 --- a/frontend/src/components/repo/RepoMcpDialog.tsx +++ b/frontend/src/components/repo/RepoMcpDialog.tsx @@ -1,21 +1,17 @@ import { useState, useEffect, useCallback } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Switch } from '@/components/ui/switch' +import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Loader2, XCircle, AlertCircle, Plug } from 'lucide-react' -import { mcpApi, type McpStatus } from '@/api/mcp' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { DeleteDialog } from '@/components/ui/delete-dialog' +import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { Loader2, XCircle, AlertCircle, Plug, Shield, MoreVertical, Key, RefreshCw } from 'lucide-react' +import { McpOAuthDialog } from '@/components/settings/McpOAuthDialog' +import { mcpApi, type McpStatus, type McpServerConfig, type McpAuthStartResponse } from '@/api/mcp' import { useMutation } from '@tanstack/react-query' import { showToast } from '@/lib/toast' -interface McpServerConfig { - type: 'local' | 'remote' - enabled?: boolean - command?: string[] - url?: string - environment?: Record - timeout?: number -} - interface RepoMcpDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -28,6 +24,8 @@ interface RepoMcpDialogProps { export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcpDialogProps) { const [localStatus, setLocalStatus] = useState>({}) const [isLoadingStatus, setIsLoadingStatus] = useState(false) + const [removeAuthConfirmServer, setRemoveAuthConfirmServer] = useState(null) + const [authDialogServerId, setAuthDialogServerId] = useState(null) const mcpServers = config?.content?.mcp as Record | undefined || {} const serverIds = Object.keys(mcpServers) @@ -68,6 +66,40 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp showToast.error(error instanceof Error ? error.message : 'Failed to update MCP server') }, }) + + const removeAuthMutation = useMutation({ + mutationFn: async (serverId: string) => { + if (!directory) throw new Error('No directory provided') + await mcpApi.removeAuthDirectory(serverId, directory) + }, + onSuccess: async () => { + showToast.success('Authentication removed for this location') + setRemoveAuthConfirmServer(null) + await fetchStatus() + }, + onError: (error) => { + showToast.error(error instanceof Error ? error.message : 'Failed to remove authentication') + }, + }) + + const handleOAuthAutoAuth = async () => { + if (!authDialogServerId || !directory) return + await mcpApi.authenticateDirectory(authDialogServerId, directory) + await fetchStatus() + setAuthDialogServerId(null) + } + + const handleOAuthStartAuth = async (): Promise => { + if (!authDialogServerId) throw new Error('No server ID') + return await mcpApi.startAuth(authDialogServerId) + } + + const handleOAuthCompleteAuth = async (code: string) => { + if (!authDialogServerId) return + await mcpApi.completeAuth(authDialogServerId, code) + await fetchStatus() + setAuthDialogServerId(null) + } useEffect(() => { if (open && directory) { @@ -156,7 +188,14 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp const serverConfig = mcpServers[serverId] const status = localStatus[serverId] const isConnected = status?.status === 'connected' + const needsAuth = status?.status === 'needs_auth' const failed = status?.status === 'failed' + const isRemote = serverConfig.type === 'remote' + const hasOAuthConfig = isRemote && !!serverConfig.oauth + const hasOAuthError = failed && isRemote && /oauth|auth.*state/i.test(status.error) + const isOAuthServer = hasOAuthConfig || hasOAuthError || (needsAuth && isRemote) + const connectedWithOAuth = isOAuthServer && isConnected + const showAuthButton = needsAuth || (isOAuthServer && failed) return (
{getDisplayName(serverId)}

+ {connectedWithOAuth && ( + + + + )} {getStatusBadge(status)}

@@ -181,20 +225,94 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp )} - { - toggleMutation.mutate({ serverId, enable: enabled }) - }} - onClick={(e) => e.stopPropagation()} - /> +

+ {showAuthButton ? ( + + ) : ( + { + toggleMutation.mutate({ serverId, enable: enabled }) + }} + onClick={(e) => e.stopPropagation()} + /> + )} + {(isOAuthServer || needsAuth) && ( + + + + + + {showAuthButton && ( + setAuthDialogServerId(serverId)}> + + Authenticate + + )} + {connectedWithOAuth && ( + setAuthDialogServerId(serverId)}> + + Re-authenticate + + )} + {connectedWithOAuth && ( + <> + + setRemoveAuthConfirmServer(serverId)} + disabled={removeAuthMutation.isPending} + > + + {removeAuthMutation.isPending ? 'Removing...' : 'Remove Auth'} + + + )} + + + )} +
) })} )} + + setRemoveAuthConfirmServer(null)} + onConfirm={() => { + if (removeAuthConfirmServer) { + removeAuthMutation.mutate(removeAuthConfirmServer) + } + }} + onCancel={() => setRemoveAuthConfirmServer(null)} + title="Remove Authentication" + description="This will remove the OAuth credentials for this MCP server at this location. You will need to re-authenticate to use this server here again." + itemName={removeAuthConfirmServer ? getDisplayName(removeAuthConfirmServer) : ''} + isDeleting={removeAuthMutation.isPending} + /> + + !o && setAuthDialogServerId(null)} + serverName={authDialogServerId || ''} + onAutoAuth={handleOAuthAutoAuth} + onStartAuth={handleOAuthStartAuth} + onCompleteAuth={handleOAuthCompleteAuth} + directory={directory} + /> ) diff --git a/frontend/src/components/session/QuestionPrompt.tsx b/frontend/src/components/session/QuestionPrompt.tsx index da675d07..b531fdd7 100644 --- a/frontend/src/components/session/QuestionPrompt.tsx +++ b/frontend/src/components/session/QuestionPrompt.tsx @@ -435,7 +435,7 @@ function QuestionStep({ value={customInput} onChange={(e) => onCustomInputChange(e.target.value)} placeholder="Type your own answer..." - className="min-h-[60px] sm:min-h-[80px] text-xs sm:text-sm resize-none border-blue-500/30 focus:border-blue-500" + className="min-h-[60px] sm:min-h-[80px] text-[16px] sm:text-xs md:text-sm resize-none border-blue-500/30 focus:border-blue-500" onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() diff --git a/frontend/src/components/settings/AccountSettings.tsx b/frontend/src/components/settings/AccountSettings.tsx index d355044f..932bf191 100644 --- a/frontend/src/components/settings/AccountSettings.tsx +++ b/frontend/src/components/settings/AccountSettings.tsx @@ -163,11 +163,11 @@ export function AccountSettings() {
- +
- +
@@ -227,7 +227,7 @@ export function AccountSettings() { value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="At least 8 characters" - className="h-9 sm:h-10 text-sm" + className="h-9 sm:h-10 md:text-sm" />
@@ -271,7 +271,7 @@ export function AccountSettings() { placeholder="Passkey name (optional)" value={passkeyName} onChange={(e) => setPasskeyName(e.target.value)} - className="h-9 sm:h-10 text-sm" + className="h-9 sm:h-10 md:text-sm" /> + {serverType === 'local' ? ( +
+ + setCommand(e.target.value)} + placeholder="npx @modelcontextprotocol/server-filesystem /tmp" + className="bg-background border-border font-mono" + /> +

+ Command and arguments to run the MCP server +

- {environment.map((env, index) => ( -
- handleUpdateEnvironmentVar(index, 'key', e.target.value)} - placeholder="API_KEY" - className="bg-background border-border font-mono" - /> - handleUpdateEnvironmentVar(index, 'value', e.target.value)} - placeholder="your-api-key-here" - className="bg-background border-border font-mono" + ) : ( +
+ + setUrl(e.target.value)} + placeholder="http://localhost:3000/mcp" + className="bg-background border-border font-mono" + /> +

+ URL of the remote MCP server +

+
+ )} + + {serverType === 'remote' && ( +
+
+ - {environment.length > 1 && ( - - )} +
- ))} + {oauthEnabled && ( +
+

+ Leave fields blank to use the server's default OAuth discovery +

+
+ + setOauthClientId(e.target.value)} + placeholder="Optional" + className="bg-background border-border font-mono" + /> +
+
+ + setOauthClientSecret(e.target.value)} + placeholder="Optional" + className="bg-background border-border font-mono" + /> +
+
+ + setOauthScope(e.target.value)} + placeholder="e.g., read write" + className="bg-background border-border font-mono" + /> +
+
+ )} +
+ )} + + {serverType === 'local' && ( +
+
+ + +
+ {environment.map((env, index) => ( +
+ handleUpdateEnvironmentVar(index, 'key', e.target.value)} + placeholder="API_KEY" + className="bg-background border-border font-mono" + /> + handleUpdateEnvironmentVar(index, 'value', e.target.value)} + placeholder="your-api-key-here" + className="bg-background border-border font-mono" + /> + {environment.length > 1 && ( + + )} +
+ ))} +

+ Environment variables to set when running the MCP server +

+
+ )} + +
+ + setTimeout(e.target.value)} + placeholder="5000" + className="bg-background border-border" + />

- Environment variables to set when running the MCP server + Timeout in milliseconds for fetching tools (default: 5000)

- )} -
- - setTimeout(e.target.value)} - placeholder="5000" - className="bg-background border-border" - /> -

- Timeout in milliseconds for fetching tools (default: 5000) -

-
- -
- - +
+ + +
- -