diff --git a/.gitignore b/.gitignore index dc6e870d9..da7b0a9ec 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,4 @@ next-env.d.ts .env # Reference material downloaded for agents -examples \ No newline at end of file +examples diff --git a/apps/desktop/docs/EXTERNAL_FILES.md b/apps/desktop/docs/EXTERNAL_FILES.md new file mode 100644 index 000000000..9e3f5d0fc --- /dev/null +++ b/apps/desktop/docs/EXTERNAL_FILES.md @@ -0,0 +1,99 @@ +# External Files Written by Superset Desktop + +This document lists all files written by the Superset desktop app outside of user projects. +Understanding these files is critical for maintaining dev/prod separation and avoiding conflicts. + +## Environment-Specific Directories + +The app uses different home directories based on environment: +- **Development**: `~/.superset-dev/` +- **Production**: `~/.superset/` + +This separation prevents dev and prod from interfering with each other. + +## Files in `~/.superset[-dev]/` + +### `bin/` - Agent Wrapper Scripts + +| File | Purpose | +|------|---------| +| `claude` | Wrapper for Claude Code CLI that injects notification hooks | +| `codex` | Wrapper for Codex CLI that injects notification hooks | +| `opencode` | Wrapper for OpenCode CLI that sets `OPENCODE_CONFIG_DIR` | + +These wrappers are added to `PATH` via shell integration, allowing them to intercept +agent commands and inject Superset-specific configuration. + +### `hooks/` - Notification Hook Scripts + +| File | Purpose | +|------|---------| +| `notify.sh` | Shell script called by agents when they complete or need input | +| `claude-settings.json` | Claude Code settings file with hook configuration | +| `opencode/plugin/superset-notify.js` | OpenCode plugin for lifecycle events | + +### `zsh/` and `bash/` - Shell Integration + +| File | Purpose | +|------|---------| +| `init.zsh` | Zsh initialization script (sources .zshrc, sets up PATH) | +| `init.bash` | Bash initialization script (sources .bashrc, sets up PATH) | + +## Global Files (AVOID ADDING NEW ONES) + +**DO NOT write to global locations** like `~/.config/`, `~/Library/`, etc. +These cause dev/prod conflicts when both environments are running. + +### Known Issues with Global Files + +Previously, the OpenCode plugin was written to `~/.config/opencode/plugin/superset-notify.js`. +This caused severe issues: +1. Dev would overwrite prod's plugin with incompatible protocol +2. Prod terminals would send events that dev's server couldn't handle +3. Users received spam notifications for every agent message + +**Solution**: The global plugin is no longer written. On startup, any stale global plugin +with our marker is deleted to prevent conflicts from older versions. + +## Shell RC File Modifications + +The app modifies shell RC files to add the Superset bin directory to PATH: + +| Shell | RC File | Modification | +|-------|---------|--------------| +| Zsh | `~/.zshrc` | Prepends `~/.superset[-dev]/bin` to PATH | +| Bash | `~/.bashrc` | Prepends `~/.superset[-dev]/bin` to PATH | + +## Terminal Environment Variables + +Each terminal session receives these environment variables: + +| Variable | Purpose | +|----------|---------| +| `SUPERSET_PANE_ID` | Unique identifier for the terminal pane | +| `SUPERSET_TAB_ID` | Identifier for the containing tab | +| `SUPERSET_WORKSPACE_ID` | Identifier for the workspace | +| `SUPERSET_WORKSPACE_NAME` | Human-readable workspace name | +| `SUPERSET_WORKSPACE_PATH` | Filesystem path to the workspace | +| `SUPERSET_ROOT_PATH` | Root path of the project | +| `SUPERSET_PORT` | Port for the notification server | +| `SUPERSET_ENV` | Environment (`development` or `production`) | +| `SUPERSET_HOOK_VERSION` | Hook protocol version for compatibility | + +## Adding New External Files + +Before adding new files outside of `~/.superset[-dev]/`: + +1. **Consider if it's necessary** - Can you use the environment-specific directory instead? +2. **Check for conflicts** - Will dev and prod overwrite each other? +3. **Update this document** - Add the file to the appropriate section +4. **Add cleanup logic** - If migrating from global to local, clean up the old location + +## Debugging Cross-Environment Issues + +If you suspect dev/prod cross-talk: + +1. Check logs for "Environment mismatch" warnings +2. Verify `SUPERSET_ENV` and `SUPERSET_PORT` are set correctly in terminal +3. Delete stale global files: `rm -rf ~/.config/opencode/plugin/superset-notify.js` +4. Restart both dev and prod apps to regenerate hooks diff --git a/apps/desktop/src/lib/trpc/routers/notifications.ts b/apps/desktop/src/lib/trpc/routers/notifications.ts index eb539b812..f90d264b6 100644 --- a/apps/desktop/src/lib/trpc/routers/notifications.ts +++ b/apps/desktop/src/lib/trpc/routers/notifications.ts @@ -1,6 +1,6 @@ import { observable } from "@trpc/server/observable"; import { - type AgentCompleteEvent, + type AgentLifecycleEvent, type NotificationIds, notificationsEmitter, } from "main/lib/notifications/server"; @@ -9,8 +9,8 @@ import { publicProcedure, router } from ".."; type NotificationEvent = | { - type: typeof NOTIFICATION_EVENTS.AGENT_COMPLETE; - data?: AgentCompleteEvent; + type: typeof NOTIFICATION_EVENTS.AGENT_LIFECYCLE; + data?: AgentLifecycleEvent; } | { type: typeof NOTIFICATION_EVENTS.FOCUS_TAB; data?: NotificationIds }; @@ -18,21 +18,24 @@ export const createNotificationsRouter = () => { return router({ subscribe: publicProcedure.subscription(() => { return observable((emit) => { - const onComplete = (data: AgentCompleteEvent) => { - emit.next({ type: NOTIFICATION_EVENTS.AGENT_COMPLETE, data }); + const onLifecycle = (data: AgentLifecycleEvent) => { + emit.next({ type: NOTIFICATION_EVENTS.AGENT_LIFECYCLE, data }); }; const onFocusTab = (data: NotificationIds) => { emit.next({ type: NOTIFICATION_EVENTS.FOCUS_TAB, data }); }; - notificationsEmitter.on(NOTIFICATION_EVENTS.AGENT_COMPLETE, onComplete); + notificationsEmitter.on( + NOTIFICATION_EVENTS.AGENT_LIFECYCLE, + onLifecycle, + ); notificationsEmitter.on(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); return () => { notificationsEmitter.off( - NOTIFICATION_EVENTS.AGENT_COMPLETE, - onComplete, + NOTIFICATION_EVENTS.AGENT_LIFECYCLE, + onLifecycle, ); notificationsEmitter.off(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); }; diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index afbff9fc9..03371e0e4 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -36,7 +36,7 @@ const paneSchema = z.object({ type: z.enum(["terminal", "webview", "file-viewer"]), name: z.string(), isNew: z.boolean().optional(), - needsAttention: z.boolean().optional(), + status: z.enum(["idle", "working", "permission", "review"]).optional(), initialCommands: z.array(z.string()).optional(), initialCwd: z.string().optional(), url: z.string().optional(), diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 0ced8cdac..345d66a4f 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -12,7 +12,16 @@ import { export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; export const CLAUDE_SETTINGS_FILE = "claude-settings.json"; export const OPENCODE_PLUGIN_FILE = "superset-notify.js"; -export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v3"; + +const OPENCODE_PLUGIN_SIGNATURE = "// Superset opencode plugin"; +const OPENCODE_PLUGIN_VERSION = "v8"; +export const OPENCODE_PLUGIN_MARKER = `${OPENCODE_PLUGIN_SIGNATURE} ${OPENCODE_PLUGIN_VERSION}`; + +const OPENCODE_PLUGIN_TEMPLATE_PATH = path.join( + __dirname, + "templates", + "opencode-plugin.template.js", +); const REAL_BINARY_RESOLVER = `find_real_binary() { local name="$1" @@ -56,11 +65,7 @@ export function getOpenCodePluginPath(): string { return path.join(OPENCODE_PLUGIN_DIR, OPENCODE_PLUGIN_FILE); } -/** - * OpenCode auto-loads plugins from ~/.config/opencode/plugin/ - * See: https://opencode.ai/docs/plugins - * The plugin checks SUPERSET_TAB_ID env var so it only activates in Superset terminals. - */ +/** @see https://opencode.ai/docs/plugins */ export function getOpenCodeGlobalPluginPath(): string { const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); const configHome = xdgConfigHome?.length @@ -72,6 +77,7 @@ export function getOpenCodeGlobalPluginPath(): string { export function getClaudeSettingsContent(notifyPath: string): string { const settings = { hooks: { + UserPromptSubmit: [{ hooks: [{ type: "command", command: notifyPath }] }], Stop: [{ hooks: [{ type: "command", command: notifyPath }] }], PermissionRequest: [ { matcher: "*", hooks: [{ type: "command", command: notifyPath }] }, @@ -135,114 +141,12 @@ exec "$REAL_BIN" "$@" } export function getOpenCodePluginContent(notifyPath: string): string { - // Build "${" via char codes to avoid JS template literal interpolation in generated code - const templateOpen = String.fromCharCode(36, 123); - const shellLine = ` await $\`bash ${templateOpen}notifyPath} ${templateOpen}payload}\`;`; - return [ - OPENCODE_PLUGIN_MARKER, - "/**", - " * Superset Notification Plugin for OpenCode", - " *", - " * This plugin sends desktop notifications when OpenCode sessions need attention.", - " * It hooks into session.idle, session.error, and permission.ask events.", - " *", - " * IMPORTANT: Subagent/Background Task Filtering", - " * --------------------------------------------", - " * When using oh-my-opencode or similar tools that spawn background subagents", - " * (e.g., explore, librarian, oracle agents), each subagent runs in its own", - " * OpenCode session. These child sessions emit session.idle events when they", - " * complete, which would cause excessive notifications if not filtered.", - " *", - " * How we detect child sessions:", - " * - OpenCode sessions have a `parentID` field when they are subagent sessions", - " * - Main/root sessions have `parentID` as undefined", - " * - We use client.session.list() to look up the session and check parentID", - " *", - " * Reference: OpenCode's own notification handling in packages/app/src/context/notification.tsx", - " * uses the same approach to filter out child session notifications.", - " *", - " * @see https://github.com/sst/opencode/blob/dev/packages/app/src/context/notification.tsx", - " */", - "export const SupersetNotifyPlugin = async ({ $, client }) => {", - " if (globalThis.__supersetOpencodeNotifyPluginV3) return {};", - " globalThis.__supersetOpencodeNotifyPluginV3 = true;", - "", - " // Only run inside a Superset terminal session", - " if (!process?.env?.SUPERSET_TAB_ID) return {};", - "", - ` const notifyPath = "${notifyPath}";`, - "", - " /**", - " * Sends a notification to Superset's notification server.", - " * Best-effort only - failures are silently ignored to avoid breaking the agent.", - " */", - " const notify = async (hookEventName) => {", - " const payload = JSON.stringify({ hook_event_name: hookEventName });", - " try {", - shellLine, - " } catch {", - " // Best-effort only; do not break the agent if notification fails", - " }", - " };", - "", - " /**", - " * Checks if a session is a child/subagent session by looking up its parentID.", - " *", - " * Background: When oh-my-opencode spawns background agents (explore, librarian, etc.),", - " * each agent runs in a separate OpenCode session with a parentID pointing to the", - " * main session. We only want to notify for main sessions, not subagent completions.", - " *", - " * Implementation notes:", - " * - Uses client.session.list() because it reliably returns parentID", - " * - session.get() has parameter issues in some SDK versions", - " * - This is a local RPC call (~10ms), acceptable for infrequent notification events", - " * - On error, returns false (assumes main session) to avoid missing notifications", - " *", - " * @param sessionID - The session ID from the event", - " * @returns true if this is a child/subagent session, false if main session", - " */", - " const isChildSession = async (sessionID) => {", - " if (!sessionID || !client?.session?.list) return false;", - " try {", - " const sessions = await client.session.list();", - " const session = sessions.data?.find((s) => s.id === sessionID);", - " // Sessions with parentID are child/subagent sessions", - " return !!session?.parentID;", - " } catch {", - " // On error, assume it's a main session to avoid missing notifications", - " return false;", - " }", - " };", - "", - " return {", - " event: async ({ event }) => {", - " // Handle session completion events", - ' if (event.type === "session.idle" || event.type === "session.error") {', - " const sessionID = event.properties?.sessionID;", - "", - " // Skip notifications for child/subagent sessions", - " // This prevents notification spam when background agents complete", - " if (await isChildSession(sessionID)) {", - " return;", - " }", - "", - ' await notify("Stop");', - " }", - " },", - ' "permission.ask": async (_permission, output) => {', - ' if (output.status === "ask") {', - ' await notify("PermissionRequest");', - " }", - " },", - " };", - "};", - "", - ].join("\n"); + const template = fs.readFileSync(OPENCODE_PLUGIN_TEMPLATE_PATH, "utf-8"); + return template + .replace("{{MARKER}}", OPENCODE_PLUGIN_MARKER) + .replace("{{NOTIFY_PATH}}", notifyPath); } -/** - * Creates the Claude Code settings JSON file with notification hooks - */ function createClaudeSettings(): string { const settingsPath = getClaudeSettingsPath(); const notifyPath = getNotifyScriptPath(); @@ -252,9 +156,6 @@ function createClaudeSettings(): string { return settingsPath; } -/** - * Creates wrapper script for Claude Code - */ export function createClaudeWrapper(): void { const wrapperPath = getClaudeWrapperPath(); const settingsPath = createClaudeSettings(); @@ -263,9 +164,6 @@ export function createClaudeWrapper(): void { console.log("[agent-setup] Created Claude wrapper"); } -/** - * Creates wrapper script for Codex - */ export function createCodexWrapper(): void { const wrapperPath = getCodexWrapperPath(); const notifyPath = getNotifyScriptPath(); @@ -275,29 +173,41 @@ export function createCodexWrapper(): void { } /** - * Creates OpenCode plugin file with notification hooks + * Writes to environment-specific path only, NOT the global path. + * Global path causes dev/prod conflicts when both are running. */ export function createOpenCodePlugin(): void { const pluginPath = getOpenCodePluginPath(); const notifyPath = getNotifyScriptPath(); const content = getOpenCodePluginContent(notifyPath); fs.writeFileSync(pluginPath, content, { mode: 0o644 }); + console.log("[agent-setup] Created OpenCode plugin"); +} + +/** + * Removes stale global plugin written by older versions. + * Only removes if the file contains our signature to avoid deleting user plugins. + */ +export function cleanupGlobalOpenCodePlugin(): void { try { const globalPluginPath = getOpenCodeGlobalPluginPath(); - fs.mkdirSync(path.dirname(globalPluginPath), { recursive: true }); - fs.writeFileSync(globalPluginPath, content, { mode: 0o644 }); + if (!fs.existsSync(globalPluginPath)) return; + + const content = fs.readFileSync(globalPluginPath, "utf-8"); + if (content.includes(OPENCODE_PLUGIN_SIGNATURE)) { + fs.unlinkSync(globalPluginPath); + console.log( + "[agent-setup] Removed stale global OpenCode plugin to prevent dev/prod conflicts", + ); + } } catch (error) { console.warn( - "[agent-setup] Failed to write global OpenCode plugin:", + "[agent-setup] Failed to cleanup global OpenCode plugin:", error, ); } - console.log("[agent-setup] Created OpenCode plugin"); } -/** - * Creates wrapper script for OpenCode - */ export function createOpenCodeWrapper(): void { const wrapperPath = getOpenCodeWrapperPath(); const script = buildOpenCodeWrapperScript(OPENCODE_CONFIG_DIR); diff --git a/apps/desktop/src/main/lib/agent-setup/index.ts b/apps/desktop/src/main/lib/agent-setup/index.ts index d0ac5cb3e..e2ca3b6c8 100644 --- a/apps/desktop/src/main/lib/agent-setup/index.ts +++ b/apps/desktop/src/main/lib/agent-setup/index.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import { + cleanupGlobalOpenCodePlugin, createClaudeWrapper, createCodexWrapper, createOpenCodePlugin, @@ -34,6 +35,9 @@ export function setupAgentHooks(): void { fs.mkdirSync(BASH_DIR, { recursive: true }); fs.mkdirSync(OPENCODE_PLUGIN_DIR, { recursive: true }); + // Clean up stale global plugins that may cause dev/prod conflicts + cleanupGlobalOpenCodePlugin(); + // Create scripts createNotifyScript(); createClaudeWrapper(); diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts index c583486a9..5026f3a6a 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts @@ -6,52 +6,23 @@ import { HOOKS_DIR } from "./paths"; export const NOTIFY_SCRIPT_NAME = "notify.sh"; export const NOTIFY_SCRIPT_MARKER = "# Superset agent notification hook"; +const NOTIFY_SCRIPT_TEMPLATE_PATH = path.join( + __dirname, + "templates", + "notify-hook.template.sh", +); + export function getNotifyScriptPath(): string { return path.join(HOOKS_DIR, NOTIFY_SCRIPT_NAME); } export function getNotifyScriptContent(): string { - return `#!/bin/bash -${NOTIFY_SCRIPT_MARKER} -# Called by CLI agents (Claude Code, Codex, etc.) when they complete or need input - -# Only run if inside a Superset terminal -[ -z "$SUPERSET_TAB_ID" ] && exit 0 - -# Get JSON input - Codex passes as argument, Claude pipes to stdin -if [ -n "$1" ]; then - INPUT="$1" -else - INPUT=$(cat) -fi - -# Extract event type - Claude uses "hook_event_name", Codex uses "type" -EVENT_TYPE=$(echo "$INPUT" | grep -o '"hook_event_name":"[^"]*"' | cut -d'"' -f4) -if [ -z "$EVENT_TYPE" ]; then - # Check for Codex "type" field (e.g., "agent-turn-complete") - CODEX_TYPE=$(echo "$INPUT" | grep -o '"type":"[^"]*"' | cut -d'"' -f4) - if [ "$CODEX_TYPE" = "agent-turn-complete" ]; then - EVENT_TYPE="Stop" - fi -fi - -# Default to "Stop" if not found -[ -z "$EVENT_TYPE" ] && EVENT_TYPE="Stop" - -# Timeouts prevent blocking agent completion if notification server is unresponsive -curl -sG "http://127.0.0.1:\${SUPERSET_PORT:-${PORTS.NOTIFICATIONS}}/hook/complete" \\ - --connect-timeout 1 --max-time 2 \\ - --data-urlencode "paneId=$SUPERSET_PANE_ID" \\ - --data-urlencode "tabId=$SUPERSET_TAB_ID" \\ - --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \\ - --data-urlencode "eventType=$EVENT_TYPE" \\ - > /dev/null 2>&1 -`; + const template = fs.readFileSync(NOTIFY_SCRIPT_TEMPLATE_PATH, "utf-8"); + return template + .replace("{{MARKER}}", NOTIFY_SCRIPT_MARKER) + .replace("{{DEFAULT_PORT}}", String(PORTS.NOTIFICATIONS)); } -/** - * Creates the notify.sh script - */ export function createNotifyScript(): void { const notifyPath = getNotifyScriptPath(); const script = getNotifyScriptContent(); diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh new file mode 100644 index 000000000..fc5dd4051 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -0,0 +1,46 @@ +#!/bin/bash +{{MARKER}} +# Called by CLI agents (Claude Code, Codex, etc.) when they complete or need input + +# Only run if inside a Superset terminal +[ -z "$SUPERSET_TAB_ID" ] && exit 0 + +# Get JSON input - Codex passes as argument, Claude pipes to stdin +if [ -n "$1" ]; then + INPUT="$1" +else + INPUT=$(cat) +fi + +# Extract event type - Claude uses "hook_event_name", Codex uses "type" +# Use flexible pattern to handle optional whitespace: "key": "value" or "key":"value" +EVENT_TYPE=$(echo "$INPUT" | grep -oE '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') +if [ -z "$EVENT_TYPE" ]; then + # Check for Codex "type" field (e.g., "agent-turn-complete") + CODEX_TYPE=$(echo "$INPUT" | grep -oE '"type"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') + if [ "$CODEX_TYPE" = "agent-turn-complete" ]; then + EVENT_TYPE="Stop" + fi +fi + +# NOTE: We intentionally do NOT default to "Stop" if EVENT_TYPE is empty. +# Parse failures should not trigger completion notifications. +# The server will ignore requests with missing eventType (forward compatibility). + +# Map UserPromptSubmit to Start for simpler handling +[ "$EVENT_TYPE" = "UserPromptSubmit" ] && EVENT_TYPE="Start" + +# If no event type was found, skip the notification +# This prevents parse failures from causing false completion notifications +[ -z "$EVENT_TYPE" ] && exit 0 + +# Timeouts prevent blocking agent completion if notification server is unresponsive +curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ + --connect-timeout 1 --max-time 2 \ + --data-urlencode "paneId=$SUPERSET_PANE_ID" \ + --data-urlencode "tabId=$SUPERSET_TAB_ID" \ + --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \ + --data-urlencode "eventType=$EVENT_TYPE" \ + --data-urlencode "env=$SUPERSET_ENV" \ + --data-urlencode "version=$SUPERSET_HOOK_VERSION" \ + > /dev/null 2>&1 diff --git a/apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js b/apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js new file mode 100644 index 000000000..42b5a42f6 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js @@ -0,0 +1,187 @@ +{{MARKER}} +/** + * Superset Notification Plugin for OpenCode + * + * This plugin sends desktop notifications when OpenCode sessions need attention. + * It hooks into session.status (busy/idle), session.idle, session.error, and permission.ask events. + * + * ROBUSTNESS FEATURES (v8): + * - Session-scoped: Tracks root sessionID, ignores events from other sessions + * - Deduplication: Only sends Start on idle→busy, Stop on busy→idle transitions + * - Safe defaults: On error, assumes child session to avoid false positives + * - Debug logging: Set SUPERSET_DEBUG=1 to enable verbose logging + * + * SUBAGENT FILTERING: + * When using oh-my-opencode or similar tools that spawn background subagents + * (e.g., explore, librarian, oracle agents), each subagent runs in its own + * OpenCode session. These child sessions emit session.idle events when they + * complete, which would cause excessive notifications if not filtered. + * + * We detect child sessions by checking the `parentID` field - main/root sessions + * have `parentID` as undefined, while child sessions have it set. + * + * @see https://github.com/sst/opencode/blob/dev/packages/app/src/context/notification.tsx + */ +export const SupersetNotifyPlugin = async ({ $, client }) => { + if (globalThis.__supersetOpencodeNotifyPluginV8) return {}; + globalThis.__supersetOpencodeNotifyPluginV8 = true; + + // Only run inside a Superset terminal session + if (!process?.env?.SUPERSET_TAB_ID) return {}; + + const notifyPath = "{{NOTIFY_PATH}}"; + const debug = process?.env?.SUPERSET_DEBUG === '1'; + + // State tracking for deduplication and session-scoping + let currentState = 'idle'; // 'idle' | 'busy' + let rootSessionID = null; // The session we're tracking (first busy session) + let stopSent = false; // Prevent duplicate Stop notifications + + const log = (...args) => { + if (debug) console.log('[superset-plugin]', ...args); + }; + + /** + * Sends a notification to Superset's notification server. + * Best-effort only - failures are silently ignored to avoid breaking the agent. + */ + const notify = async (hookEventName) => { + const payload = JSON.stringify({ hook_event_name: hookEventName }); + log('Sending notification:', hookEventName); + try { + await $`bash ${notifyPath} ${payload}`; + log('Notification sent successfully'); + } catch (err) { + log('Notification failed:', err?.message || err); + } + }; + + /** + * Checks if a session is a child/subagent session by looking up its parentID. + * Uses caching to avoid repeated lookups for the same session. + * + * IMPORTANT: On error, returns TRUE (assumes child) to avoid false positives. + * This prevents race conditions where a failed lookup causes child session + * events to be treated as root session events. + */ + const childSessionCache = new Map(); + const isChildSession = async (sessionID) => { + if (!sessionID) return true; // No sessionID = can't verify, skip + if (!client?.session?.list) return true; // Can't check, skip + + // Check cache first + if (childSessionCache.has(sessionID)) { + return childSessionCache.get(sessionID); + } + + try { + const sessions = await client.session.list(); + const session = sessions.data?.find((s) => s.id === sessionID); + const isChild = !!session?.parentID; + childSessionCache.set(sessionID, isChild); + log('Session lookup:', sessionID, 'isChild:', isChild); + return isChild; + } catch (err) { + log('Session lookup failed:', err?.message || err, '- assuming child'); + // On error, assume child session to avoid false positives + // This prevents race conditions where failures cause incorrect notifications + return true; + } + }; + + /** + * Handles state transition to busy. + * Only sends Start if transitioning from idle and session matches root. + */ + const handleBusy = async (sessionID) => { + // If we don't have a root session yet, this becomes our root + if (!rootSessionID) { + rootSessionID = sessionID; + log('Root session set:', rootSessionID); + } + + // Only process events for our root session + if (sessionID !== rootSessionID) { + log('Ignoring busy from non-root session:', sessionID); + return; + } + + // Only send Start if transitioning from idle + if (currentState === 'idle') { + currentState = 'busy'; + stopSent = false; // Reset stop flag for new busy period + await notify('Start'); + } else { + log('Already busy, skipping Start'); + } + }; + + /** + * Handles state transition to idle/stopped. + * Only sends Stop once per busy period and only for root session. + * Resets rootSessionID after Stop so we can track new sessions. + */ + const handleStop = async (sessionID, reason) => { + // Only process events for our root session (if we have one) + if (rootSessionID && sessionID !== rootSessionID) { + log('Ignoring stop from non-root session:', sessionID, 'reason:', reason); + return; + } + + // Only send Stop if we're busy and haven't already sent Stop + if (currentState === 'busy' && !stopSent) { + currentState = 'idle'; + stopSent = true; + log('Stopping, reason:', reason); + await notify('Stop'); + // Reset rootSessionID so we can track a new session if OpenCode starts another conversation + rootSessionID = null; + log('Reset rootSessionID for next session'); + } else { + log('Skipping Stop - state:', currentState, 'stopSent:', stopSent, 'reason:', reason); + } + }; + + return { + event: async ({ event }) => { + const sessionID = event.properties?.sessionID; + log('Event:', event.type, 'sessionID:', sessionID); + + // Skip notifications for child/subagent sessions + if (await isChildSession(sessionID)) { + log('Skipping child session'); + return; + } + + // Handle session status changes (busy/idle/retry) + if (event.type === "session.status") { + const status = event.properties?.status; + log('Status:', status?.type); + if (status?.type === "busy") { + await handleBusy(sessionID); + } else if (status?.type === "idle") { + await handleStop(sessionID, 'session.status.idle'); + } + } + + // Handle deprecated/alternative event types (backwards compatibility) + // Some OpenCode versions may emit session.busy/session.idle as separate events + if (event.type === "session.busy") { + await handleBusy(sessionID); + } + if (event.type === "session.idle") { + await handleStop(sessionID, 'session.idle'); + } + + // Handle session errors (also means session stopped) + if (event.type === "session.error") { + await handleStop(sessionID, 'session.error'); + } + }, + "permission.ask": async (_permission, output) => { + if (output.status === "ask") { + await notify("PermissionRequest"); + } + }, + }; +}; diff --git a/apps/desktop/src/main/lib/notifications/server.test.ts b/apps/desktop/src/main/lib/notifications/server.test.ts new file mode 100644 index 000000000..94e095508 --- /dev/null +++ b/apps/desktop/src/main/lib/notifications/server.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test"; +import { mapEventType } from "./server"; + +describe("notifications/server", () => { + describe("mapEventType", () => { + it("should map 'Start' to 'Start'", () => { + expect(mapEventType("Start")).toBe("Start"); + }); + + it("should map 'UserPromptSubmit' to 'Start'", () => { + expect(mapEventType("UserPromptSubmit")).toBe("Start"); + }); + + it("should map 'Stop' to 'Stop'", () => { + expect(mapEventType("Stop")).toBe("Stop"); + }); + + it("should map 'agent-turn-complete' to 'Stop'", () => { + expect(mapEventType("agent-turn-complete")).toBe("Stop"); + }); + + it("should map 'PermissionRequest' to 'PermissionRequest'", () => { + expect(mapEventType("PermissionRequest")).toBe("PermissionRequest"); + }); + + it("should return null for unknown event types (forward compatibility)", () => { + expect(mapEventType("UnknownEvent")).toBeNull(); + expect(mapEventType("FutureEvent")).toBeNull(); + expect(mapEventType("SomeNewHook")).toBeNull(); + }); + + it("should return null for undefined eventType (not default to Stop)", () => { + // This is critical: missing eventType should NOT trigger a completion notification + expect(mapEventType(undefined)).toBeNull(); + }); + + it("should return null for empty string eventType", () => { + expect(mapEventType("")).toBeNull(); + }); + }); +}); diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index a27398d46..089824f0f 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -1,16 +1,24 @@ import { EventEmitter } from "node:events"; import express from "express"; import { NOTIFICATION_EVENTS } from "shared/constants"; +import { debugLog } from "shared/debug"; +import { env } from "shared/env.shared"; +import type { AgentLifecycleEvent } from "shared/notification-types"; +import { appState } from "../app-state"; +import { HOOK_PROTOCOL_VERSION } from "../terminal/env"; -export interface NotificationIds { - paneId?: string; - tabId?: string; - workspaceId?: string; -} +// Re-export types for backwards compatibility +export type { + AgentLifecycleEvent, + NotificationIds, +} from "shared/notification-types"; -export interface AgentCompleteEvent extends NotificationIds { - eventType: "Stop" | "PermissionRequest"; -} +/** + * The environment this server is running in. + * Used to validate incoming hook requests and detect cross-environment issues. + */ +const SERVER_ENV = + env.NODE_ENV === "development" ? "development" : "production"; export const notificationsEmitter = new EventEmitter(); @@ -29,20 +37,147 @@ app.use((req, res, next) => { next(); }); -// Agent completion hook +/** + * Maps incoming event types to canonical lifecycle events. + * Handles variations from different agent CLIs. + * + * Returns null for unknown events - caller should ignore these gracefully + * to maintain forward compatibility with newer hook versions. + * + * Note: We no longer default missing eventType to "Stop" to prevent + * parse failures from being treated as completions. + * + * @internal Exported for testing + */ +export function mapEventType( + eventType: string | undefined, +): "Start" | "Stop" | "PermissionRequest" | null { + if (!eventType) { + return null; // Missing eventType should be ignored, not treated as Stop + } + if (eventType === "Start" || eventType === "UserPromptSubmit") { + return "Start"; + } + if (eventType === "PermissionRequest") { + return "PermissionRequest"; + } + if (eventType === "Stop" || eventType === "agent-turn-complete") { + return "Stop"; + } + return null; // Unknown events are ignored for forward compatibility +} + +/** + * Resolves paneId from tabId or workspaceId using synced tabs state. + * Falls back to focused pane in active tab. + * + * If a paneId is provided but doesn't exist in state (stale reference), + * we fall through to tabId/workspaceId resolution instead of returning + * an invalid paneId that would corrupt the store. + */ +function resolvePaneId( + paneId: string | undefined, + tabId: string | undefined, + workspaceId: string | undefined, +): string | undefined { + try { + const tabsState = appState.data.tabsState; + if (!tabsState) return undefined; + + // If paneId provided, validate it exists before returning + if (paneId && tabsState.panes?.[paneId]) { + return paneId; + } + // If paneId was provided but doesn't exist, fall through to resolution + + // Try to resolve from tabId + if (tabId) { + const focusedPaneId = tabsState.focusedPaneIds?.[tabId]; + if (focusedPaneId && tabsState.panes?.[focusedPaneId]) { + return focusedPaneId; + } + } + + // Try to resolve from workspaceId + if (workspaceId) { + const activeTabId = tabsState.activeTabIds?.[workspaceId]; + if (activeTabId) { + const focusedPaneId = tabsState.focusedPaneIds?.[activeTabId]; + if (focusedPaneId && tabsState.panes?.[focusedPaneId]) { + return focusedPaneId; + } + } + } + } catch { + // App state not initialized yet, ignore + } + + return undefined; +} + +// Agent lifecycle hook app.get("/hook/complete", (req, res) => { - const { paneId, tabId, workspaceId, eventType } = req.query; + const { + paneId, + tabId, + workspaceId, + eventType, + env: clientEnv, + version, + } = req.query; + + // Environment validation: detect dev/prod cross-talk + // We still return success to not block the agent, but log a warning + if (clientEnv && clientEnv !== SERVER_ENV) { + console.warn( + `[notifications] Environment mismatch: received ${clientEnv} request on ${SERVER_ENV} server. ` + + `This may indicate a stale hook or misconfigured terminal. Ignoring request.`, + ); + return res.json({ success: true, ignored: true, reason: "env_mismatch" }); + } + + // Log version for debugging (helpful when troubleshooting hook issues) + if (version && version !== HOOK_PROTOCOL_VERSION) { + console.log( + `[notifications] Received hook v${version} request (server expects v${HOOK_PROTOCOL_VERSION})`, + ); + } + + const mappedEventType = mapEventType(eventType as string | undefined); + + debugLog("notifications", "Received hook:", { + eventType, + mappedEventType, + paneId, + tabId, + workspaceId, + }); + + // Unknown or missing eventType: return success but don't process + // This ensures forward compatibility and doesn't block the agent + if (!mappedEventType) { + if (eventType) { + console.log("[notifications] Ignoring unknown eventType:", eventType); + } + return res.json({ success: true, ignored: true }); + } + + const resolvedPaneId = resolvePaneId( + paneId as string | undefined, + tabId as string | undefined, + workspaceId as string | undefined, + ); - const event: AgentCompleteEvent = { - paneId: paneId as string | undefined, + const event: AgentLifecycleEvent = { + paneId: resolvedPaneId, tabId: tabId as string | undefined, workspaceId: workspaceId as string | undefined, - eventType: eventType === "PermissionRequest" ? "PermissionRequest" : "Stop", + eventType: mappedEventType, }; - notificationsEmitter.emit(NOTIFICATION_EVENTS.AGENT_COMPLETE, event); + notificationsEmitter.emit(NOTIFICATION_EVENTS.AGENT_LIFECYCLE, event); - res.json({ success: true, paneId, tabId }); + res.json({ success: true, paneId: resolvedPaneId, tabId }); }); // Health check diff --git a/apps/desktop/src/main/lib/terminal/env.test.ts b/apps/desktop/src/main/lib/terminal/env.test.ts index 6ed3c03a1..89bda1dfb 100644 --- a/apps/desktop/src/main/lib/terminal/env.test.ts +++ b/apps/desktop/src/main/lib/terminal/env.test.ts @@ -665,5 +665,17 @@ describe("env", () => { expect(typeof result.SUPERSET_PORT).toBe("string"); }); }); + + it("should include SUPERSET_ENV for dev/prod separation", () => { + const result = buildTerminalEnv(baseParams); + expect(result.SUPERSET_ENV).toBeDefined(); + expect(["development", "production"]).toContain(result.SUPERSET_ENV); + }); + + it("should include SUPERSET_HOOK_VERSION for protocol versioning", () => { + const result = buildTerminalEnv(baseParams); + expect(result.SUPERSET_HOOK_VERSION).toBeDefined(); + expect(result.SUPERSET_HOOK_VERSION).toBe("2"); + }); }); }); diff --git a/apps/desktop/src/main/lib/terminal/env.ts b/apps/desktop/src/main/lib/terminal/env.ts index 7709d6686..db0429834 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -2,8 +2,16 @@ import { execSync } from "node:child_process"; import os from "node:os"; import defaultShell from "default-shell"; import { PORTS } from "shared/constants"; +import { env } from "shared/env.shared"; import { getShellEnv } from "../agent-setup/shell-wrappers"; +/** + * Current hook protocol version. + * Increment when making breaking changes to the hook protocol. + * The server logs this for debugging version mismatches. + */ +export const HOOK_PROTOCOL_VERSION = "2"; + export const FALLBACK_SHELL = os.platform() === "win32" ? "cmd.exe" : "/bin/sh"; export const SHELL_CRASH_THRESHOLD_MS = 1000; @@ -340,7 +348,7 @@ export function buildTerminalEnv(params: { const shellEnv = getShellEnv(shell); const locale = getLocale(rawBaseEnv); - const env: Record = { + const terminalEnv: Record = { ...baseEnv, ...shellEnv, TERM_PROGRAM: "Superset", @@ -354,7 +362,13 @@ export function buildTerminalEnv(params: { SUPERSET_WORKSPACE_PATH: workspacePath || "", SUPERSET_ROOT_PATH: rootPath || "", SUPERSET_PORT: String(PORTS.NOTIFICATIONS), + // Environment identifier for dev/prod separation + SUPERSET_ENV: env.NODE_ENV === "development" ? "development" : "production", + // Hook protocol version for forward compatibility + SUPERSET_HOOK_VERSION: HOOK_PROTOCOL_VERSION, }; - return env; + delete terminalEnv.GOOGLE_API_KEY; + + return terminalEnv; } diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 0ba5fc33b..e70fd1cad 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -13,7 +13,7 @@ import { appState } from "../lib/app-state"; import { createApplicationMenu, registerMenuHotkeyUpdates } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; import { - type AgentCompleteEvent, + type AgentLifecycleEvent, notificationsApp, notificationsEmitter, } from "../lib/notifications/server"; @@ -86,10 +86,13 @@ export async function MainWindow() { }, ); - // Handle agent completion notifications + // Handle agent lifecycle notifications (Stop = completion, PermissionRequest = needs input) notificationsEmitter.on( - NOTIFICATION_EVENTS.AGENT_COMPLETE, - (event: AgentCompleteEvent) => { + NOTIFICATION_EVENTS.AGENT_LIFECYCLE, + (event: AgentLifecycleEvent) => { + // Only notify on Stop (completion) and PermissionRequest - not on Start + if (event.eventType === "Start") return; + if (Notification.isSupported()) { const isPermissionRequest = event.eventType === "PermissionRequest"; diff --git a/apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx b/apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx new file mode 100644 index 000000000..ea42c6c34 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx @@ -0,0 +1,69 @@ +import { cn } from "@superset/ui/utils"; +import type { ActivePaneStatus } from "shared/tabs-types"; + +// Re-export for consumers +export type { ActivePaneStatus } from "shared/tabs-types"; + +/** Lookup object for status indicator styling - avoids if/else chains */ +const STATUS_CONFIG = { + permission: { + pingColor: "bg-red-400", + dotColor: "bg-red-500", + pulse: true, + tooltip: "Needs input", + }, + working: { + pingColor: "bg-amber-400", + dotColor: "bg-amber-500", + pulse: true, + tooltip: "Agent working", + }, + review: { + pingColor: "", + dotColor: "bg-green-500", + pulse: false, + tooltip: "Ready for review", + }, +} as const satisfies Record< + ActivePaneStatus, + { pingColor: string; dotColor: string; pulse: boolean; tooltip: string } +>; + +interface StatusIndicatorProps { + status: ActivePaneStatus; + className?: string; +} + +/** + * Visual indicator for pane/workspace status. + * - Red pulsing: needs user input (permission) + * - Amber pulsing: agent working + * - Green static: ready for review + */ +export function StatusIndicator({ status, className }: StatusIndicatorProps) { + const config = STATUS_CONFIG[status]; + + return ( + + {config.pulse && ( + + )} + + + ); +} + +/** Get tooltip text for a status - for consumers that wrap with Tooltip */ +export function getStatusTooltip(status: ActivePaneStatus): string { + return STATUS_CONFIG[status].tooltip; +} diff --git a/apps/desktop/src/renderer/screens/main/components/StatusIndicator/index.ts b/apps/desktop/src/renderer/screens/main/components/StatusIndicator/index.ts new file mode 100644 index 000000000..8df166d7f --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/StatusIndicator/index.ts @@ -0,0 +1,5 @@ +export { + type ActivePaneStatus, + getStatusTooltip, + StatusIndicator, +} from "./StatusIndicator"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx index 7396e789a..294754b7b 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx @@ -24,6 +24,10 @@ export function WorkspaceSidebarControl() { }; const sidebarCollapsed = isCollapsed(); + const hotkeyDisplay = formatHotkeyDisplay( + getHotkey("TOGGLE_WORKSPACE_SIDEBAR"), + getCurrentPlatform(), + ); return ( @@ -46,10 +50,7 @@ export function WorkspaceSidebarControl() { {sidebarCollapsed ? "Expand" : "Collapse"} Workspaces - {formatHotkeyDisplay( - getHotkey("TOGGLE_WORKSPACE_SIDEBAR"), - getCurrentPlatform(), - ).map((key) => ( + {hotkeyDisplay.map((key) => ( {key} ))} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index c1751ae42..65ed07840 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -15,7 +15,7 @@ import { Input } from "@superset/ui/input"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; import { LuEye, LuEyeOff, LuFolder, LuFolderGit2 } from "react-icons/lu"; @@ -25,10 +25,12 @@ import { useSetActiveWorkspace, useWorkspaceDeleteHandler, } from "renderer/react-query/workspaces"; +import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; import { useCloseWorkspacesList } from "renderer/stores/app-state"; import { useTabsStore } from "renderer/stores/tabs/store"; import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; +import { getHighestPriorityStatus } from "shared/tabs-types"; import { STROKE_WIDTH } from "../constants"; import { BranchSwitcher, @@ -82,8 +84,8 @@ export function WorkspaceListItem({ const rename = useWorkspaceRename(id, name); const tabs = useTabsStore((s) => s.tabs); const panes = useTabsStore((s) => s.panes); - const clearWorkspaceAttention = useTabsStore( - (s) => s.clearWorkspaceAttention, + const clearWorkspaceAttentionStatus = useTabsStore( + (s) => s.clearWorkspaceAttentionStatus, ); const utils = trpc.useUtils(); const openInFinder = trpc.external.openInFinder.useMutation({ @@ -110,22 +112,29 @@ export function WorkspaceListItem({ }, ); - // Check if any pane in tabs belonging to this workspace needs attention (agent notifications) - const workspaceTabs = tabs.filter((t) => t.workspaceId === id); - const workspacePaneIds = new Set( - workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), - ); - const hasPaneAttention = Object.values(panes) - .filter((p) => p != null && workspacePaneIds.has(p.id)) - .some((p) => p.needsAttention); + // Memoize workspace pane IDs to avoid recalculating on every render + const workspacePaneIds = useMemo(() => { + const workspaceTabs = tabs.filter((t) => t.workspaceId === id); + return new Set( + workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), + ); + }, [tabs, id]); - // Show indicator if workspace is manually marked as unread OR has pane-level attention - const needsAttention = isUnread || hasPaneAttention; + // Compute aggregate status for workspace using shared priority logic + const workspaceStatus = useMemo(() => { + // Generator avoids array allocation + function* paneStatuses() { + for (const paneId of workspacePaneIds) { + yield panes[paneId]?.status; + } + } + return getHighestPriorityStatus(paneStatuses()); + }, [panes, workspacePaneIds]); const handleClick = () => { if (!rename.isRenaming) { setActiveWorkspace.mutate({ id }); - clearWorkspaceAttention(id); + clearWorkspaceAttentionStatus(id); // Close workspaces list view if open, to show the workspace's terminal view closeWorkspacesList(); } @@ -216,11 +225,16 @@ export function WorkspaceListItem({ strokeWidth={STROKE_WIDTH} /> )} - {/* Notification dot */} - {needsAttention && ( + {/* Status indicator */} + {workspaceStatus && ( + + + + )} + {/* Unread dot (only when no status) */} + {isUnread && !workspaceStatus && ( - - + )} @@ -297,7 +311,7 @@ export function WorkspaceListItem({
)} - {/* Icon with notification dot */} + {/* Icon with status indicator */}
@@ -312,10 +326,14 @@ export function WorkspaceListItem({ strokeWidth={STROKE_WIDTH} /> )} - {needsAttention && ( + {workspaceStatus && ( + + + + )} + {isUnread && !workspaceStatus && ( - - + )}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx index 1af096341..5ba28f787 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx @@ -2,13 +2,14 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { HiMiniXMark } from "react-icons/hi2"; -import type { Tab } from "renderer/stores/tabs/types"; +import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; +import type { PaneStatus, Tab } from "renderer/stores/tabs/types"; import { getTabDisplayName } from "renderer/stores/tabs/utils"; interface GroupItemProps { tab: Tab; isActive: boolean; - needsAttention: boolean; + status: PaneStatus | null; onSelect: () => void; onClose: () => void; } @@ -16,7 +17,7 @@ interface GroupItemProps { export function GroupItem({ tab, isActive, - needsAttention, + status, onSelect, onClose, }: GroupItemProps) { @@ -39,12 +40,7 @@ export function GroupItem({ {displayName} - {needsAttention && ( - - - - - )} + {status && status !== "idle" && }
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 66e3893ed..a984cc9df 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -24,6 +24,7 @@ import { trpc } from "renderer/lib/trpc"; import { usePresets } from "renderer/react-query/presets"; import { useOpenSettings } from "renderer/stores"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { type ActivePaneStatus, pickHigherStatus } from "shared/tabs-types"; import { GroupItem } from "./GroupItem"; export function GroupStrip() { @@ -74,12 +75,14 @@ export function GroupStrip() { ? activeTabIds[activeWorkspaceId] : null; - // Check which tabs have panes that need attention - const tabsWithAttention = useMemo(() => { - const result = new Set(); + // Compute aggregate status per tab using shared priority logic + const tabStatusMap = useMemo(() => { + const result = new Map(); for (const pane of Object.values(panes)) { - if (pane.needsAttention) { - result.add(pane.tabId); + if (!pane.status || pane.status === "idle") continue; + const higher = pickHigherStatus(result.get(pane.tabId), pane.status); + if (higher !== "idle") { + result.set(pane.tabId, higher); } } return result; @@ -137,7 +140,7 @@ export function GroupStrip() { handleSelectGroup(tab.id)} onClose={() => handleCloseGroup(tab.id)} /> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index bb4d77629..94e6fafdf 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -50,6 +50,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const updatePaneCwd = useTabsStore((s) => s.updatePaneCwd); const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); + const setPaneStatus = useTabsStore((s) => s.setPaneStatus); const terminalTheme = useTerminalTheme(); // Ref for initial theme to avoid recreating terminal on theme change @@ -223,6 +224,18 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { `\r\n\r\n[Process exited with code ${event.exitCode}]`, ); xtermRef.current.writeln("[Press any key to restart]"); + + // Clear transient pane status on terminal exit + // "working" and "permission" should clear (agent no longer active) + // "review" should persist (user needs to see completed work) + // Use store getter to get fresh pane status at event time (not stale closure) + const currentPane = useTabsStore.getState().panes[paneId]; + if ( + currentPane?.status === "working" || + currentPane?.status === "permission" + ) { + setPaneStatus(paneId, "idle"); + } } }; @@ -306,6 +319,15 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { setSubscriptionEnabled(false); xterm.writeln(`\r\n\r\n[Process exited with code ${event.exitCode}]`); xterm.writeln("[Press any key to restart]"); + + // Clear transient pane status (direct store access since we're in effect) + const currentPane = useTabsStore.getState().panes[paneId]; + if ( + currentPane?.status === "working" || + currentPane?.status === "permission" + ) { + useTabsStore.getState().setPaneStatus(paneId, "idle"); + } } } }; @@ -367,6 +389,23 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { commandBufferRef.current = commandBufferRef.current.slice(0, -1); } else if (domEvent.key === "c" && domEvent.ctrlKey) { commandBufferRef.current = ""; + // Ctrl+C interrupts agent - clear working/permission status + const currentPane = useTabsStore.getState().panes[paneId]; + if ( + currentPane?.status === "working" || + currentPane?.status === "permission" + ) { + useTabsStore.getState().setPaneStatus(paneId, "idle"); + } + } else if (domEvent.key === "Escape") { + // ESC interrupts agent (e.g., Claude Code "stop generating") - clear status + const currentPane = useTabsStore.getState().panes[paneId]; + if ( + currentPane?.status === "working" || + currentPane?.status === "permission" + ) { + useTabsStore.getState().setPaneStatus(paneId, "idle"); + } } else if ( domEvent.key.length === 1 && !domEvent.ctrlKey && diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 828d0679d..0f34ea2d6 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -199,14 +199,22 @@ export const useTabsStore = create()( ]; } - // Clear needsAttention for the focused pane in the tab being activated - const focusedPaneId = state.focusedPaneIds[tabId]; + // Clear attention status for panes in the selected tab + const tabPaneIds = extractPaneIdsFromLayout(tab.layout); const newPanes = { ...state.panes }; - if (focusedPaneId && newPanes[focusedPaneId]?.needsAttention) { - newPanes[focusedPaneId] = { - ...newPanes[focusedPaneId], - needsAttention: false, - }; + let hasChanges = false; + for (const paneId of tabPaneIds) { + const currentStatus = newPanes[paneId]?.status; + if (currentStatus === "review") { + // User acknowledged completion + newPanes[paneId] = { ...newPanes[paneId], status: "idle" }; + hasChanges = true; + } else if (currentStatus === "permission") { + // Assume permission granted, agent is now working + newPanes[paneId] = { ...newPanes[paneId], status: "working" }; + hasChanges = true; + } + // "working" status is NOT cleared by click - persists until Stop } set({ @@ -218,7 +226,7 @@ export const useTabsStore = create()( ...state.tabHistoryStacks, [workspaceId]: newHistoryStack, }, - panes: newPanes, + ...(hasChanges ? { panes: newPanes } : {}), }); }, @@ -506,46 +514,41 @@ export const useTabsStore = create()( const pane = state.panes[paneId]; if (!pane || pane.tabId !== tabId) return; - // Clear needsAttention for the pane being focused - const newPanes = pane.needsAttention - ? { - ...state.panes, - [paneId]: { ...pane, needsAttention: false }, - } - : state.panes; - set({ focusedPaneIds: { ...state.focusedPaneIds, [tabId]: paneId, }, - panes: newPanes, }); }, markPaneAsUsed: (paneId) => { - set((state) => ({ - panes: { - ...state.panes, - [paneId]: state.panes[paneId] - ? { ...state.panes[paneId], isNew: false } - : state.panes[paneId], - }, - })); + set((state) => { + // Guard: no-op for unknown panes to avoid corrupting panes map + if (!state.panes[paneId]) return state; + return { + panes: { + ...state.panes, + [paneId]: { ...state.panes[paneId], isNew: false }, + }, + }; + }); }, - setNeedsAttention: (paneId, needsAttention) => { - set((state) => ({ + setPaneStatus: (paneId, status) => { + const state = get(); + // Guard: no-op for unknown panes to avoid corrupting panes map with undefined + if (!state.panes[paneId]) return; + + set({ panes: { ...state.panes, - [paneId]: state.panes[paneId] - ? { ...state.panes[paneId], needsAttention } - : state.panes[paneId], + [paneId]: { ...state.panes[paneId], status }, }, - })); + }); }, - clearWorkspaceAttention: (workspaceId) => { + clearWorkspaceAttentionStatus: (workspaceId) => { const state = get(); const workspaceTabs = state.tabs.filter( (t) => t.workspaceId === workspaceId, @@ -561,10 +564,17 @@ export const useTabsStore = create()( const newPanes = { ...state.panes }; let hasChanges = false; for (const paneId of workspacePaneIds) { - if (newPanes[paneId]?.needsAttention) { - newPanes[paneId] = { ...newPanes[paneId], needsAttention: false }; + const currentStatus = newPanes[paneId]?.status; + if (currentStatus === "review") { + // User acknowledged completion + newPanes[paneId] = { ...newPanes[paneId], status: "idle" }; + hasChanges = true; + } else if (currentStatus === "permission") { + // Assume permission granted, Claude is now working + newPanes[paneId] = { ...newPanes[paneId], status: "working" }; hasChanges = true; } + // "working" status is NOT cleared by click - persists until Stop } if (hasChanges) { @@ -573,29 +583,37 @@ export const useTabsStore = create()( }, updatePaneCwd: (paneId, cwd, confirmed) => { - set((state) => ({ - panes: { - ...state.panes, - [paneId]: state.panes[paneId] - ? { ...state.panes[paneId], cwd, cwdConfirmed: confirmed } - : state.panes[paneId], - }, - })); + set((state) => { + // Guard: no-op for unknown panes to avoid corrupting panes map + if (!state.panes[paneId]) return state; + return { + panes: { + ...state.panes, + [paneId]: { + ...state.panes[paneId], + cwd, + cwdConfirmed: confirmed, + }, + }, + }; + }); }, clearPaneInitialData: (paneId) => { - set((state) => ({ - panes: { - ...state.panes, - [paneId]: state.panes[paneId] - ? { - ...state.panes[paneId], - initialCommands: undefined, - initialCwd: undefined, - } - : state.panes[paneId], - }, - })); + set((state) => { + // Guard: no-op for unknown panes to avoid corrupting panes map + if (!state.panes[paneId]) return state; + return { + panes: { + ...state.panes, + [paneId]: { + ...state.panes[paneId], + initialCommands: undefined, + initialCwd: undefined, + }, + }, + }; + }); }, // Split operations @@ -776,7 +794,38 @@ export const useTabsStore = create()( }), { name: "tabs-storage", + version: 2, storage: trpcTabsStorage, + migrate: (persistedState, version) => { + const state = persistedState as TabsState; + if (version < 2 && state.panes) { + // Migrate needsAttention → status + for (const pane of Object.values(state.panes)) { + // biome-ignore lint/suspicious/noExplicitAny: migration from old schema + const legacyPane = pane as any; + if (legacyPane.needsAttention === true) { + pane.status = "review"; + } + delete legacyPane.needsAttention; + } + } + return state; + }, + merge: (persistedState, currentState) => { + const persisted = persistedState as TabsState; + // Clear stale transient statuses on startup: + // - "working": Agent can't be working if app just restarted + // - "permission": Permission dialog is gone after restart + // Note: "review" is intentionally preserved so users see missed completions + if (persisted.panes) { + for (const pane of Object.values(persisted.panes)) { + if (pane.status === "working" || pane.status === "permission") { + pane.status = "idle"; + } + } + } + return { ...currentState, ...persisted }; + }, }, ), { name: "TabsStore" }, diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 9638df6e0..3b7e28310 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -1,9 +1,15 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; import type { ChangeCategory } from "shared/changes-types"; -import type { BaseTab, BaseTabsState, Pane, PaneType } from "shared/tabs-types"; +import type { + BaseTab, + BaseTabsState, + Pane, + PaneStatus, + PaneType, +} from "shared/tabs-types"; // Re-export shared types -export type { Pane, PaneType }; +export type { Pane, PaneStatus, PaneType }; /** * A Tab is a container that holds one or more Panes in a Mosaic layout. @@ -73,8 +79,8 @@ export interface TabsStore extends TabsState { removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; markPaneAsUsed: (paneId: string) => void; - setNeedsAttention: (paneId: string, needsAttention: boolean) => void; - clearWorkspaceAttention: (workspaceId: string) => void; + setPaneStatus: (paneId: string, status: PaneStatus) => void; + clearWorkspaceAttentionStatus: (workspaceId: string) => void; updatePaneCwd: ( paneId: string, cwd: string | null, diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index 0b158830c..19e44b17a 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -2,13 +2,35 @@ import { useRef } from "react"; import { trpc } from "renderer/lib/trpc"; import { useSetActiveWorkspace } from "renderer/react-query/workspaces/useSetActiveWorkspace"; import { NOTIFICATION_EVENTS } from "shared/constants"; +import { debugLog } from "shared/debug"; import { useAppStore } from "../app-state"; import { useTabsStore } from "./store"; import { resolveNotificationTarget } from "./utils/resolve-notification-target"; /** - * Hook that listens for notification events via tRPC subscription. - * Handles agent completions and focus requests from native notifications. + * Hook that listens for agent lifecycle events via tRPC subscription and updates + * pane status indicators accordingly. + * + * STATUS MAPPING: + * - Start → "working" (amber pulsing indicator) + * - Stop → "review" (green static) if pane not active, "idle" if active + * - PermissionRequest → "permission" (red pulsing indicator) + * - Terminal Exit → "idle" (handled in Terminal.tsx, clears stuck indicators) + * + * KNOWN LIMITATIONS (External - Claude Code / OpenCode hook systems): + * + * 1. User Interrupt (Ctrl+C): Claude Code's Stop hook does NOT fire when the user + * interrupts the agent. However, the terminal exit handler in Terminal.tsx + * will automatically clear the "working" indicator when the process exits. + * + * 2. Permission Denied: No hook fires when the user denies a permission request. + * The terminal exit handler will clear the "permission" indicator on process exit. + * + * 3. Tool Failures: No hook fires when a tool execution fails. The status + * continues until the agent stops or terminal exits. + * + * Note: Terminal exit detection (in Terminal.tsx) provides a reliable fallback + * for clearing stuck indicators when agent hooks fail to fire. */ export function useAgentHookListener() { const setActiveWorkspace = useSetActiveWorkspace(); @@ -28,17 +50,51 @@ export function useAgentHookListener() { const { paneId, workspaceId } = target; - if (event.type === NOTIFICATION_EVENTS.AGENT_COMPLETE) { + if (event.type === NOTIFICATION_EVENTS.AGENT_LIFECYCLE) { + debugLog("agent-hooks", "Received:", { + eventType: event.data?.eventType, + paneId, + workspaceId, + activeWorkspace: activeWorkspaceRef.current?.id, + }); + if (!paneId) return; - const activeTabId = state.activeTabIds[workspaceId]; - const focusedPaneId = activeTabId && state.focusedPaneIds[activeTabId]; - const isAlreadyActive = - activeWorkspaceRef.current?.id === workspaceId && - focusedPaneId === paneId; + const lifecycleEvent = event.data; + if (!lifecycleEvent) return; + + const { eventType } = lifecycleEvent; + + if (eventType === "Start") { + // Agent started working - always set to working + state.setPaneStatus(paneId, "working"); + } else if (eventType === "PermissionRequest") { + // Agent needs permission - always set to permission (overrides working) + state.setPaneStatus(paneId, "permission"); + } else if (eventType === "Stop") { + // Agent completed - only mark as review if not currently active + const activeTabId = state.activeTabIds[workspaceId]; + const focusedPaneId = + activeTabId && state.focusedPaneIds[activeTabId]; + const isAlreadyActive = + activeWorkspaceRef.current?.id === workspaceId && + focusedPaneId === paneId; + + debugLog("agent-hooks", "Stop event:", { + isAlreadyActive, + activeTabId, + focusedPaneId, + paneId, + willSetTo: isAlreadyActive ? "idle" : "review", + }); - if (!isAlreadyActive) { - state.setNeedsAttention(paneId, true); + if (isAlreadyActive) { + // User is watching - go straight to idle + state.setPaneStatus(paneId, "idle"); + } else { + // User not watching - mark for review + state.setPaneStatus(paneId, "review"); + } } } else if (event.type === NOTIFICATION_EVENTS.FOCUS_TAB) { const appState = useAppStore.getState(); diff --git a/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts b/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts index 65bcf1ea0..84b164698 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts @@ -1,4 +1,4 @@ -import type { NotificationIds } from "main/lib/notifications/server"; +import type { NotificationIds } from "shared/notification-types"; import type { Pane, Tab } from "../types"; interface TabsState { diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 1ec904ae1..d35968ade 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -40,7 +40,7 @@ export const CONFIG_TEMPLATE = `{ }`; export const NOTIFICATION_EVENTS = { - AGENT_COMPLETE: "agent-complete", + AGENT_LIFECYCLE: "agent-lifecycle", FOCUS_TAB: "focus-tab", } as const; diff --git a/apps/desktop/src/shared/debug.ts b/apps/desktop/src/shared/debug.ts new file mode 100644 index 000000000..6778e452d --- /dev/null +++ b/apps/desktop/src/shared/debug.ts @@ -0,0 +1,37 @@ +/** + * Debug logging utility for development and QA. + * + * Enable debug logs by setting environment variable: + * SUPERSET_DEBUG=1 bun run desktop + * + * Or in .env: + * SUPERSET_DEBUG=1 + * + * Usage: + * import { debugLog } from "shared/debug"; + * debugLog("notifications", "Received hook:", data); + * // Logs: [debug:notifications] Received hook: {...} + */ + +const isDebugEnabled = + typeof process !== "undefined" && + (process.env.SUPERSET_DEBUG === "1" || process.env.SUPERSET_DEBUG === "true"); + +/** + * Log a debug message if SUPERSET_DEBUG is enabled. + * + * @param namespace - Category for the log (e.g., "notifications", "agent-hooks") + * @param args - Values to log (same as console.log) + */ +export function debugLog(namespace: string, ...args: unknown[]): void { + if (isDebugEnabled) { + console.log(`[debug:${namespace}]`, ...args); + } +} + +/** + * Check if debug mode is enabled. + */ +export function isDebug(): boolean { + return isDebugEnabled; +} diff --git a/apps/desktop/src/shared/notification-types.ts b/apps/desktop/src/shared/notification-types.ts new file mode 100644 index 000000000..8339cf314 --- /dev/null +++ b/apps/desktop/src/shared/notification-types.ts @@ -0,0 +1,14 @@ +/** + * Shared notification types used by both main and renderer processes. + * Kept in shared/ to avoid cross-boundary imports. + */ + +export interface NotificationIds { + paneId?: string; + tabId?: string; + workspaceId?: string; +} + +export interface AgentLifecycleEvent extends NotificationIds { + eventType: "Start" | "Stop" | "PermissionRequest"; +} diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index d8c921186..101dc9d88 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -10,6 +10,64 @@ import type { ChangeCategory } from "./changes-types"; */ export type PaneType = "terminal" | "webview" | "file-viewer"; +/** + * Pane status for agent lifecycle indicators + * - idle: No indicator shown (default) + * - working: Agent actively processing (amber) + * - permission: Agent blocked, needs user action (red) + * - review: Agent completed, ready for review (green) + */ +export type PaneStatus = "idle" | "working" | "permission" | "review"; + +/** Non-idle status for UI indicators */ +export type ActivePaneStatus = Exclude; + +/** + * Status priority order (higher = more urgent). + * Single source of truth for aggregation logic. + */ +export const STATUS_PRIORITY = { + idle: 0, + review: 1, + working: 2, + permission: 3, +} as const satisfies Record; + +/** + * Compare two statuses and return the higher priority one. + * Useful for reducing/folding over pane statuses. + */ +export function pickHigherStatus( + a: PaneStatus | undefined, + b: PaneStatus | undefined, +): PaneStatus { + const aPriority = a ? STATUS_PRIORITY[a] : 0; + const bPriority = b ? STATUS_PRIORITY[b] : 0; + if (aPriority >= bPriority) return a ?? "idle"; + return b ?? "idle"; +} + +/** + * Get the highest priority status from an iterable of statuses. + * Returns null if all statuses are idle/undefined (no indicator needed). + */ +export function getHighestPriorityStatus( + statuses: Iterable, +): ActivePaneStatus | null { + let highest: PaneStatus = "idle"; + + for (const status of statuses) { + if (!status) continue; + if (STATUS_PRIORITY[status] > STATUS_PRIORITY[highest]) { + highest = status; + // Early exit for max priority + if (highest === "permission") break; + } + } + + return highest === "idle" ? null : highest; +} + /** * File viewer display modes */ @@ -53,7 +111,7 @@ export interface Pane { type: PaneType; name: string; isNew?: boolean; - needsAttention?: boolean; + status?: PaneStatus; initialCommands?: string[]; initialCwd?: string; url?: string; // For webview panes diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 39ebe60eb..00b8bc816 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -14,5 +14,11 @@ "electron-builder.ts", "index.d.ts" ], - "exclude": ["node_modules", "dist", "dist-electron", "release"] + "exclude": [ + "node_modules", + "dist", + "dist-electron", + "release", + "src/**/templates/**" + ] } diff --git a/biome.jsonc b/biome.jsonc index ce8290bd6..aec415e7d 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -6,7 +6,7 @@ "useIgnoreFile": true }, "files": { - "includes": ["**", "!**/drizzle"] + "includes": ["**", "!**/drizzle", "!**/*.template.js", "!**/*.template.sh"] }, "formatter": { "formatWithErrors": true