Clear sidebar notification when user submits prompt (#821)

* Clear sidebar notification when user submits prompt in Claude Code

Add UserPromptSubmit hook to the Claude Code wrapper that calls
`cmux claude-hook prompt-submit`. This clears the workspace notification
and sets status back to "Running" when the user addresses Claude's question,
so the "waiting for input" preview in the sidebar goes away.

Also adds --tab support to clear_notifications socket command and
--workspace support to the clear-notifications CLI command for
per-workspace notification clearing.

Closes https://github.com/manaflow-ai/cmux/issues/799

* Address review feedback: stricter error handling

- clear-notifications CLI: error on explicit --workspace failure instead of
  falling back to global clear. Env var still gracefully degrades.
- prompt-submit hook: propagate sendV1Command errors instead of swallowing
  with try?.
- clear_notifications socket: validate --tab flag is present before resolving,
  reject malformed args instead of falling back to selected tab.

* Gate env workspace fallback on windowId == nil in clear-notifications

Matches the pattern used by other CLI commands to avoid using
CMUX_WORKSPACE_ID from the caller shell when --window targets
a different window.
This commit is contained in:
Lawrence Chen 2026-03-03 18:48:32 -08:00 committed by GitHub
parent 355012b252
commit bfe843f0bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 56 additions and 7 deletions

View file

@ -1305,7 +1305,16 @@ struct CMUXCLI {
}
case "clear-notifications":
let response = try sendV1Command("clear_notifications", client: client)
var socketCmd = "clear_notifications"
if let wsFlag = optionValue(commandArgs, name: "--workspace") {
let wsId = try resolveWorkspaceId(wsFlag, client: client)
socketCmd += " --tab=\(wsId)"
} else if windowId == nil,
let envWs = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"],
let wsId = try? resolveWorkspaceId(envWs, client: client) {
socketCmd += " --tab=\(wsId)"
}
let response = try sendV1Command(socketCmd, client: client)
print(response)
case "claude-hook":
@ -4441,7 +4450,7 @@ struct CMUXCLI {
"""
case "claude-hook":
return """
Usage: cmux claude-hook <session-start|active|stop|idle|notification|notify> [flags]
Usage: cmux claude-hook <session-start|active|stop|idle|notification|notify|prompt-submit> [flags]
Hook for Claude Code integration. Reads JSON from stdin.
@ -4452,6 +4461,7 @@ struct CMUXCLI {
idle Alias for stop
notification Forward a Claude notification
notify Alias for notification
prompt-submit Clear notification and set Running on user prompt
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
@ -5700,6 +5710,24 @@ struct CMUXCLI {
print("OK")
}
case "prompt-submit":
telemetry.breadcrumb("claude-hook.prompt-submit")
var workspaceId = fallbackWorkspaceId
if let sessionId = parsedInput.sessionId,
let mapped = try? sessionStore.lookup(sessionId: sessionId),
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
workspaceId = mappedWorkspace
}
_ = try sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
try setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: "Running",
icon: "bolt.fill",
color: "#4C8DFF"
)
print("OK")
case "notification", "notify":
telemetry.breadcrumb("claude-hook.notification")
let summary = summarizeClaudeHookNotification(rawInput: rawInput)

View file

@ -50,7 +50,7 @@ done
# Build hooks settings JSON.
# Claude Code merges --settings additively with the user's own settings.json.
HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}]}}'
HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook prompt-submit","timeout":10}]}]}}'
if [[ "$SKIP_SESSION_ID" == true ]]; then
exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@"

View file

@ -878,7 +878,7 @@ class TerminalController {
return listNotifications()
case "clear_notifications":
return clearNotifications()
return clearNotifications(args)
case "set_app_focus":
return setAppFocusOverride(args)
@ -8790,7 +8790,7 @@ class TerminalController {
notify_surface <id|idx> <payload> - Notify a specific surface
notify_target <workspace_id> <surface_id> <payload> - Notify by workspace+surface
list_notifications - List all notifications
clear_notifications - Clear all notifications
clear_notifications [--tab=X] - Clear notifications (all or per-tab)
set_app_focus <active|inactive|clear> - Override app focus state
simulate_app_active - Trigger app active handler
set_status <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry
@ -10124,9 +10124,30 @@ class TerminalController {
return result.isEmpty ? "No notifications" : result
}
private func clearNotifications() -> String {
private func clearNotifications(_ args: String) -> String {
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
DispatchQueue.main.sync {
TerminalNotificationStore.shared.clearAll()
}
return "OK"
}
let parsed = parseOptions(trimmed)
guard let tabOption = parsed.options["tab"],
!tabOption.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return "ERROR: Usage: clear_notifications [--tab=X]"
}
var tabId: UUID?
DispatchQueue.main.sync {
TerminalNotificationStore.shared.clearAll()
if let tab = resolveTabForReport(trimmed) {
tabId = tab.id
}
}
guard let tabId else {
return "ERROR: Tab not found"
}
DispatchQueue.main.sync {
TerminalNotificationStore.shared.clearNotifications(forTabId: tabId)
}
return "OK"
}