- 01.Starting Point
- 02.First Implementation: Basic Hint Pill
- 03.Bug 1: State Persisting Across Disconnects
- 04.Bug 2: Proactive Dismissal Blocking Reactive
- 05.UX Refinement: Suggestive Copy
- 06.The Two Scenarios
- 07.Proactive (new session)
- 08.Reactive (error detected)
- 09.Progressive Disclosure
- 10.Files Changed
- 11.Commits
- 12.Key Insight
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:
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:
- That they need to unlock
- When to do it
- 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.swift—KeychainHintTriggerenum, "Missing API key" detectionPocket/Pocket/UI/TerminalSessionOverlay.swift— Full hint UX, components, state management
Commits
aa9abcefeat: Add long-press disconnect for project cards (testing helper)27df507feat: 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.