Add end session user feedback survey with skaband client coordination

Implement comprehensive end session feedback system with thumbs up/down rating,
optional text comments, and proper coordination with skaband clients to ensure
feedback delivery before process termination.

- Add EndFeedback struct with Happy boolean and Comment string fields
- Extend Agent struct with endFeedback field and GetEndFeedback/SetEndFeedback methods
- Update State struct to include End field for exposing feedback in API
- Modify /end handler to accept survey data (happy, comment fields)
- Add skaband client coordination with WaitGroup and wait_for_end parameter

- Replace simple window.confirm with custom modal dialog
- Create comprehensive survey UI with thumbs up/down buttons
- Add optional textarea for detailed feedback
- Implement proper button state management and validation
- Maintain existing redirect behavior after successful end request

- Track clients connecting with /stream?wait_for_end=true parameter
- Use WaitGroup to coordinate end session messaging
- Proper WaitGroup management with conditional defer to prevent double-decrement
- Smart timeout handling: measure elapsed time and ensure minimum 100ms response delay
- Wait up to 2 seconds for waiting clients, timeout gracefully if needed

**Survey Dialog Features:**
- Modal overlay with centered positioning and backdrop click handling
- Visual feedback for thumbs up/down selection with color coding
- Expandable textarea for optional detailed comments
- Cancel option to abort end session process
- Proper event handling and cleanup

**State Management:**
- EndFeedback data flows through Agent -> State -> SSE streams
- Clients waiting for end automatically receive feedback via state updates
- WaitGroup ensures coordinated shutdown after notification delivery
- Exactly one Done() call per Add() to prevent race conditions

**API Contract:**
POST /end now accepts:
{
  "reason": "user requested end of session",
  "happy": true,  // optional boolean
  "comment": "Great experience!"  // optional string
}

**Error Handling:**
- Survey submission proceeds even if rating not selected (null happy value)
- Empty comments are handled gracefully
- Network failures fall back to existing error reporting
- Proper resource cleanup and thread-safe operations

**Testing:**
- Added comprehensive unit tests for EndFeedback functionality
- Updated mockAgent to implement new CodingAgent interface methods
- All existing tests continue to pass
- Manual API testing confirmed survey data flows correctly

This enhances user experience by collecting valuable feedback while maintaining
robust session termination and proper coordination with external monitoring
systems via the skaband protocol.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s16561e134e8e81aak
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index e62947e..53776aa 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -95,6 +95,7 @@
 	OutsideWorkingDir    string                        `json:"outside_working_dir,omitempty"`
 	InsideWorkingDir     string                        `json:"inside_working_dir,omitempty"`
 	TodoContent          string                        `json:"todo_content,omitempty"` // Contains todo list JSON data
+	End                  *loop.EndFeedback             `json:"end,omitempty"`          // End session feedback
 }
 
 type InitRequest struct {
@@ -121,6 +122,8 @@
 	terminalSessions map[string]*terminalSession
 	sshAvailable     bool
 	sshError         string
+	// WaitGroup for clients waiting for end
+	endWaitGroup sync.WaitGroup
 }
 
 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -676,7 +679,9 @@
 
 		// Parse the request body (optional)
 		var requestBody struct {
-			Reason string `json:"reason"`
+			Reason  string `json:"reason"`
+			Happy   *bool  `json:"happy,omitempty"`
+			Comment string `json:"comment,omitempty"`
 		}
 
 		decoder := json.NewDecoder(r.Body)
@@ -691,6 +696,16 @@
 			endReason = requestBody.Reason
 		}
 
+		// Store end feedback if provided
+		if requestBody.Happy != nil {
+			feedback := &loop.EndFeedback{
+				Happy:   *requestBody.Happy,
+				Comment: requestBody.Comment,
+			}
+			s.agent.SetEndFeedback(feedback)
+			slog.Info("End session feedback received", "happy", feedback.Happy, "comment", feedback.Comment)
+		}
+
 		// Send success response before exiting
 		w.Header().Set("Content-Type", "application/json")
 		json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
@@ -701,9 +716,29 @@
 		// Log that we're shutting down
 		slog.Info("Ending session", "reason", endReason)
 
-		// Exit the process after a short delay to allow response to be sent
+		// Wait for skaband clients that are waiting for end (with timeout)
 		go func() {
-			time.Sleep(100 * time.Millisecond)
+			startTime := time.Now()
+			// Wait up to 2 seconds for waiting clients to receive the end message
+			done := make(chan struct{})
+			go func() {
+				s.endWaitGroup.Wait()
+				close(done)
+			}()
+
+			select {
+			case <-done:
+				slog.Info("All waiting clients notified of end")
+			case <-time.After(2 * time.Second):
+				slog.Info("Timeout waiting for clients, proceeding with shutdown")
+			}
+
+			// Ensure we've been running for at least 100ms to allow response to be sent
+			elapsed := time.Since(startTime)
+			if elapsed < 100*time.Millisecond {
+				time.Sleep(100*time.Millisecond - elapsed)
+			}
+
 			os.Exit(0)
 		}()
 	})
@@ -1023,6 +1058,17 @@
 		}
 	}
 
+	// Check if this client is waiting for end
+	waitForEnd := r.URL.Query().Get("wait_for_end") == "true"
+	if waitForEnd {
+		s.endWaitGroup.Add(1)
+		defer func() {
+			if waitForEnd {
+				s.endWaitGroup.Done()
+			}
+		}()
+	}
+
 	// Ensure 'from' is valid
 	currentCount := s.agent.MessageCount()
 	if fromIndex < 0 {
@@ -1138,6 +1184,12 @@
 			// Get updated state
 			state = s.getState()
 
+			// Check if end feedback is present and this client was waiting for it
+			if waitForEnd && state.End != nil {
+				s.endWaitGroup.Done()
+				waitForEnd = false // Mark that we've handled the end condition
+			}
+
 			// Send updated state after the state transition
 			fmt.Fprintf(w, "event: state\n")
 			fmt.Fprintf(w, "data: ")
@@ -1165,6 +1217,12 @@
 			// Get updated state
 			state = s.getState()
 
+			// Check if end feedback is present and this client was waiting for it
+			if waitForEnd && state.End != nil {
+				s.endWaitGroup.Done()
+				waitForEnd = false // Mark that we've handled the end condition
+			}
+
 			// Send updated state after the message
 			fmt.Fprintf(w, "event: state\n")
 			fmt.Fprintf(w, "data: ")
@@ -1211,6 +1269,7 @@
 		FirstMessageIndex:    s.agent.FirstMessageIndex(),
 		AgentState:           s.agent.CurrentStateName(),
 		TodoContent:          s.agent.CurrentTodoContent(),
+		End:                  s.agent.GetEndFeedback(),
 	}
 }