COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
POCKET

Keychain Hint UX — From Button to Context-Aware System

Previous session (`5f878b4`) added a simple 🔐 button in the shortcuts row that runs `security unlock-keychain`. This solved the immediate problem but required users to know:

2026-01-16 // RAW LEARNING CAPTURE
PROJECTPOCKET

Starting Point

Previous session (5f878b4) added a simple 🔐 button in the shortcuts row that runs security unlock-keychain. This solved the immediate problem but required users to know:

  1. That they need to unlock
  2. When to do it
  3. Where the button is

This session: make it proactive and context-aware.

First Implementation: Basic Hint Pill

Added a floating pill that appears on new connections:

// TerminalSessionOverlay.swift
@State private var showKeychainHelp = false
@State private var keychainHintDismissed = false

.overlay(alignment: .bottomTrailing) {
    if let trigger = etClient.keychainHintTrigger, !keychainHintDismissed {
        KeychainHintPill(...)
    }
}

With reactive detection in EtClient:

// EtClient.swift
if text.contains("Missing API key") {
    DispatchQueue.main.async {
        if client.keychainHintTrigger == nil {
            client.keychainHintTrigger = .reactive
        }
    }
}

Bug 1: State Persisting Across Disconnects

User flow: connect → see hint → dismiss → disconnect → reconnect → no hint.

Problem: keychainHintDismissed is @State on the view. The ZStack overlay pattern keeps the view alive across disconnect/reconnect, so the state persists.

Fix: Reset state when connection re-establishes:

.onChange(of: etClient.isConnected) { wasConnected, isNowConnected in
    if !wasConnected && isNowConnected {
        keychainHintDismissed = false
        hasSeenKeychainExplanation = false
        etClient.keychainHintTrigger = nil
    }
}

Bug 2: Proactive Dismissal Blocking Reactive

User flow: connect → see proactive hint → dismiss → run claude → see "Missing API key" → no reactive hint.

Problem: Single keychainHintDismissed flag blocked BOTH hint types:

if let trigger = etClient.keychainHintTrigger, !keychainHintDismissed {

When user dismissed the proactive hint, keychainHintDismissed = true. Then reactive detection fired and set keychainHintTrigger = .reactive, but the view condition failed because keychainHintDismissed was still true.

Fix: Separate dismiss states:

@State private var proactiveHintDismissed = false
@State private var reactiveHintDismissed = false

// In the overlay condition:
if let trigger = etClient.keychainHintTrigger,
   (trigger == .proactive && !proactiveHintDismissed) ||
   (trigger == .reactive && !reactiveHintDismissed) {

// In dismissKeychainHint():
if let trigger = etClient.keychainHintTrigger {
    switch trigger {
    case .proactive:
        proactiveHintDismissed = true
    case .reactive:
        reactiveHintDismissed = true
    }
}

UX Refinement: Suggestive Copy

User feedback: "The claude message about API key stuff could indicate they need to login — so maybe phrase it more around 'Keychain locked?' and explain the likely reason."

Changed from definitive to suggestive:

// Pill label
case .reactive:
    return "Keychain locked?"  // was "Keychain locked"

// Sheet explanation
case .reactive:
    return "If you're SSH'd into a Mac, this is likely a locked keychain. Claude Code stores API keys there, and it locks after reboot or idle."
    // was "Claude Code can't access your API key because macOS keychain is locked..."

The Two Scenarios

Proactive (new session)

  • Trigger: Terminal becomes visible
  • Action: Just run unlock command (user at shell prompt)
  • Auto-dismiss: 8 seconds
  • Label: "Unlock keychain?"

Reactive (error detected)

  • Trigger: "Missing API key" in terminal output
  • Action: Ctrl+C × 2 (exit Claude), wait 1s, then unlock
  • Auto-dismiss: Never (user is stuck)
  • Label: "Keychain locked?"
private func executeKeychainUnlock(trigger: KeychainHintTrigger) {
    let unlockCommand = "security unlock-keychain ~/Library/Keychains/login.keychain-db\r"

    switch trigger {
    case .proactive:
        if let data = unlockCommand.data(using: .utf8) {
            etClient.sendInput(data: data)
        }

    case .reactive:
        let ctrlC = Data([0x03])
        etClient.sendInput(data: ctrlC)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
            etClient.sendInput(data: ctrlC)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            if let data = unlockCommand.data(using: .utf8) {
                etClient.sendInput(data: data)
            }
        }
    }
}

Progressive Disclosure

First tap shows explanation sheet. Subsequent taps run action directly:

@State private var hasSeenKeychainExplanation = false

private func handleKeychainHintTap(trigger: KeychainHintTrigger) {
    autoHideWorkItem?.cancel()

    if hasSeenKeychainExplanation {
        executeKeychainUnlock(trigger: trigger)
    } else {
        showKeychainHelp = true
    }
}

Files Changed

  • Pocket/Pocket/Network/EtClient.swiftKeychainHintTrigger enum, "Missing API key" detection
  • Pocket/Pocket/UI/TerminalSessionOverlay.swift — Full hint UX, components, state management

Commits

  • aa9abce feat: Add long-press disconnect for project cards (testing helper)
  • 27df507 feat: Add context-aware keychain unlock hints

Key Insight

Single boolean flags for dismissal don't work when you have multiple trigger sources with different semantics. Proactive hints are "nice to have" — dismissable, auto-hide. Reactive hints are "you're stuck" — should always show when triggered, regardless of earlier proactive dismissal.

The state model needs to match the UX model: separate concerns, separate state.

LOG.ENTRY_END
ref:pocket
RAW