From 1e0afe4839bb846aee0df15c1bc348c9b0920750 Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Tue, 25 Aug 2026 14:51:36 -0400 Subject: [PATCH 1/3] Add distribution-owned session feedback survey Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fbabe-6c0e-70ab-8559-31ba601d4897 --- src-tauri/src/commands/feedback.rs | 1 + src-tauri/src/commands/feedback_survey.rs | 171 ++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/runtime_config.rs | 8 + src-tauri/src/lib.rs | 6 + .../SessionFeedbackSurvey.test.tsx | 101 ++++++++ .../SessionFeedbackSurvey.tsx | 110 +++++++++ .../response-feedback/feedbackSurveyEvents.ts | 6 +- .../response-feedback/feedbackSurveySink.ts | 26 +- .../sessionFeedbackSurveyState.test.ts | 149 ++++++++++++ .../sessionFeedbackSurveyState.ts | 223 ++++++++++++++++++ .../useSessionFeedbackSurvey.test.tsx | 94 ++++++++ .../useSessionFeedbackSurvey.ts | 127 ++++++++++ .../chat/ui/ChatTranscriptSurface.tsx | 16 ++ src/features/chat/ui/ChatView.tsx | 11 + src/features/chat/ui/MessageBubble.tsx | 15 ++ src/features/chat/ui/MessageTimeline.tsx | 11 + .../chat/ui/VirtualMessageTimeline.tsx | 11 + src/features/chat/ui/VirtualTranscriptRow.tsx | 6 + src/shared/api/feedbackSurvey.ts | 15 ++ src/shared/i18n/locales/en/chat.json | 5 + src/shared/i18n/locales/es/chat.json | 5 + src/shared/runtime-config/schema.test.ts | 12 +- src/shared/runtime-config/schema.ts | 6 + 24 files changed, 1128 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/commands/feedback_survey.rs create mode 100644 src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx create mode 100644 src/features/chat/response-feedback/SessionFeedbackSurvey.tsx create mode 100644 src/features/chat/response-feedback/sessionFeedbackSurveyState.test.ts create mode 100644 src/features/chat/response-feedback/sessionFeedbackSurveyState.ts create mode 100644 src/features/chat/response-feedback/useSessionFeedbackSurvey.test.tsx create mode 100644 src/features/chat/response-feedback/useSessionFeedbackSurvey.ts create mode 100644 src/shared/api/feedbackSurvey.ts diff --git a/src-tauri/src/commands/feedback.rs b/src-tauri/src/commands/feedback.rs index 6149eca02..6eec21e20 100644 --- a/src-tauri/src/commands/feedback.rs +++ b/src-tauri/src/commands/feedback.rs @@ -541,6 +541,7 @@ mod tests { enabled: Some(false), project_key: Some("CUSTOM".to_string()), response_rating_enabled: None, + session_survey_sampling_rate_basis_points: None, })); assert!(!feedback_enabled(&disabled)); assert_eq!(feedback_project_key(&disabled), "CUSTOM"); diff --git a/src-tauri/src/commands/feedback_survey.rs b/src-tauri/src/commands/feedback_survey.rs new file mode 100644 index 000000000..5e1b473ff --- /dev/null +++ b/src-tauri/src/commands/feedback_survey.rs @@ -0,0 +1,171 @@ +use std::{fs, io::Write, path::PathBuf, sync::Mutex, time::SystemTime}; + +use serde::Deserialize; + +const SESSION_SURVEY_COOLDOWN_FILE: &str = "session-feedback-survey-cooldown-v1"; +const SESSION_SURVEY_COOLDOWN_MINIMUM_MS: u64 = 27 * 60 * 60 * 1_000; +const SESSION_SURVEY_COOLDOWN_JITTER_MS: u64 = 2 * 60 * 60 * 1_000; + +pub struct SessionFeedbackSurveyCooldownState { + path: PathBuf, + next_eligible_at_ms: Mutex, +} + +impl SessionFeedbackSurveyCooldownState { + pub fn new(app_data_dir: PathBuf) -> Self { + let path = app_data_dir.join(SESSION_SURVEY_COOLDOWN_FILE); + let next_eligible_at_ms = fs::read_to_string(&path) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(0); + Self { + path, + next_eligible_at_ms: Mutex::new(next_eligible_at_ms), + } + } + + fn claim( + &self, + input: SessionFeedbackSurveyCooldownInput, + now_ms: u64, + ) -> Result { + if input.sampling_rate_basis_points > 10_000 + || !input.random.is_finite() + || !(0.0..=1.0).contains(&input.random) + || !input.cooldown_random.is_finite() + || !(0.0..=1.0).contains(&input.cooldown_random) + { + return Err("invalid session feedback survey cooldown input".to_string()); + } + let mut next_eligible_at_ms = self + .next_eligible_at_ms + .lock() + .map_err(|_| "session feedback survey cooldown lock poisoned".to_string())?; + if input.sampling_rate_basis_points == 0 + || now_ms < *next_eligible_at_ms + || input.random * 10_000.0 >= f64::from(input.sampling_rate_basis_points) + { + return Ok(false); + } + + let jitter_ms = (input.cooldown_random * SESSION_SURVEY_COOLDOWN_JITTER_MS as f64) as u64; + let claimed_until = now_ms + .saturating_add(SESSION_SURVEY_COOLDOWN_MINIMUM_MS) + .saturating_add(jitter_ms); + let parent = self + .path + .parent() + .ok_or_else(|| "survey cooldown path has no parent".to_string())?; + let mut part_file = tempfile::NamedTempFile::new_in(parent) + .map_err(|error| format!("failed to persist survey cooldown: {error}"))?; + part_file + .write_all(claimed_until.to_string().as_bytes()) + .map_err(|error| format!("failed to persist survey cooldown: {error}"))?; + part_file + .persist(&self.path) + .map_err(|error| format!("failed to finalize survey cooldown: {error}"))?; + *next_eligible_at_ms = claimed_until; + Ok(true) + } +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SessionFeedbackSurveyCooldownInput { + sampling_rate_basis_points: u16, + random: f64, + cooldown_random: f64, +} + +#[tauri::command] +pub fn claim_session_feedback_survey_cooldown( + state: tauri::State<'_, SessionFeedbackSurveyCooldownState>, + input: SessionFeedbackSurveyCooldownInput, +) -> Result { + let now_ms = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|error| format!("system clock is before Unix epoch: {error}"))? + .as_millis() + .try_into() + .map_err(|_| "system time does not fit in milliseconds".to_string())?; + state.claim(input, now_ms) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Barrier}; + + use super::*; + + #[test] + fn cooldown_claim_is_atomic_and_persistent() { + let dir = tempfile::tempdir().unwrap(); + let state = Arc::new(SessionFeedbackSurveyCooldownState::new( + dir.path().to_path_buf(), + )); + let barrier = Arc::new(Barrier::new(3)); + let input = SessionFeedbackSurveyCooldownInput { + sampling_rate_basis_points: 250, + random: 0.0, + cooldown_random: 0.0, + }; + let handles: Vec<_> = (0..2) + .map(|_| { + let state = Arc::clone(&state); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + state.claim(input, 1_000).unwrap() + }) + }) + .collect(); + barrier.wait(); + let selected = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .filter(|selected| *selected) + .count(); + assert_eq!(selected, 1); + + let reloaded = SessionFeedbackSurveyCooldownState::new(dir.path().to_path_buf()); + assert!(!reloaded + .claim(input, 1_000 + SESSION_SURVEY_COOLDOWN_MINIMUM_MS - 1) + .unwrap()); + assert!(reloaded + .claim(input, 1_000 + SESSION_SURVEY_COOLDOWN_MINIMUM_MS) + .unwrap()); + } + + #[test] + fn cooldown_applies_jitter_and_validates_input() { + let dir = tempfile::tempdir().unwrap(); + let state = SessionFeedbackSurveyCooldownState::new(dir.path().to_path_buf()); + let input = SessionFeedbackSurveyCooldownInput { + sampling_rate_basis_points: 10_000, + random: 0.0, + cooldown_random: 1.0, + }; + assert!(state.claim(input, 1_000).unwrap()); + assert!(!state + .claim( + input, + 1_000 + SESSION_SURVEY_COOLDOWN_MINIMUM_MS + SESSION_SURVEY_COOLDOWN_JITTER_MS - 1, + ) + .unwrap()); + assert!(state + .claim( + input, + 1_000 + SESSION_SURVEY_COOLDOWN_MINIMUM_MS + SESSION_SURVEY_COOLDOWN_JITTER_MS, + ) + .unwrap()); + assert!(state + .claim( + SessionFeedbackSurveyCooldownInput { + random: f64::NAN, + ..input + }, + u64::MAX, + ) + .is_err()); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e1ce74dd7..735693fb2 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ pub mod distro; pub mod doctor; #[cfg(feature = "block-feedback")] pub mod feedback; +pub mod feedback_survey; pub mod git; pub mod git_changes; pub mod global_shortcut; diff --git a/src-tauri/src/commands/runtime_config.rs b/src-tauri/src/commands/runtime_config.rs index 8746c2390..4e803a5c0 100644 --- a/src-tauri/src/commands/runtime_config.rs +++ b/src-tauri/src/commands/runtime_config.rs @@ -164,6 +164,8 @@ pub struct RuntimeFeedbackConfig { pub project_key: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub response_rating_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub session_survey_sampling_rate_basis_points: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -773,6 +775,11 @@ fn validate_runtime_config(config: &RuntimeConfig) -> Result<(), String> { } if let Some(feedback) = &config.feedback { validate_optional_non_empty(feedback.project_key.as_deref(), "feedback.projectKey")?; + if feedback.session_survey_sampling_rate_basis_points > Some(10_000) { + return Err( + "feedback.sessionSurveySamplingRateBasisPoints must be at most 10000".to_string(), + ); + } } if let Some(kgoose) = &config.kgoose { validate_kgoose(kgoose)?; @@ -1170,6 +1177,7 @@ mod tests { enabled: Some(true), project_key: Some("BOT".to_string()), response_rating_enabled: Some(true), + session_survey_sampling_rate_basis_points: Some(250), }), kgoose: Some(RuntimeKgooseConfig { base_url: Some("https://kgoose.example.test".to_string()), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a16f8664f..5480e6e46 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -201,6 +201,11 @@ pub fn run() { app_data_dir.clone(), bundled_runtime_config_path, )); + app.manage( + commands::feedback_survey::SessionFeedbackSurveyCooldownState::new( + app_data_dir.clone(), + ), + ); // Construct and register the distro bundle up front (goose serve and // runtime-config readiness both depend on it). Seeding its bundled // skills/agents is filesystem work and is deferred below. @@ -535,6 +540,7 @@ pub fn run() { commands::doctor::run_doctor_fix, #[cfg(feature = "block-feedback")] commands::feedback::submit_feedback_issue, + commands::feedback_survey::claim_session_feedback_survey_cooldown, commands::git::get_git_state, commands::git_changes::get_changed_files, commands::git::git_switch_branch, diff --git a/src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx b/src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx new file mode 100644 index 000000000..ccd5c3c00 --- /dev/null +++ b/src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx @@ -0,0 +1,101 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionFeedbackSurvey } from "./SessionFeedbackSurvey"; +import { + markSessionFeedbackSurveyAppeared, + recordSessionFeedbackSurveyResponse, +} from "./sessionFeedbackSurveyState"; + +vi.mock("./sessionFeedbackSurveyState", () => ({ + isSessionFeedbackSurveyActive: vi.fn(() => true), + markSessionFeedbackSurveyAppeared: vi.fn(), + recordSessionFeedbackSurveyResponse: vi.fn(), +})); + +class MockIntersectionObserver { + static isIntersecting = true; + + constructor(private callback: IntersectionObserverCallback) {} + observe() { + this.callback( + [ + { + isIntersecting: MockIntersectionObserver.isIntersecting, + } as IntersectionObserverEntry, + ], + this as never, + ); + } + disconnect() {} +} + +describe("SessionFeedbackSurvey", () => { + beforeEach(() => { + MockIntersectionObserver.isIntersecting = true; + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("defaults focus to dismiss and records its appearance", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Dismiss" })).toHaveFocus(); + expect(markSessionFeedbackSurveyAppeared).toHaveBeenCalledWith( + "session", + "appearance", + ); + }); + + it("treats Escape as dismiss", () => { + render( + , + ); + fireEvent.keyDown(window, { key: "Escape" }); + + expect(recordSessionFeedbackSurveyResponse).toHaveBeenCalledWith( + "session", + "appearance", + "dismissed", + ); + }); + + it("ignores Escape outside the viewport", () => { + MockIntersectionObserver.isIntersecting = false; + render( + , + ); + fireEvent.keyDown(window, { key: "Escape" }); + expect(recordSessionFeedbackSurveyResponse).not.toHaveBeenCalled(); + }); + + it("ignores Escape after focus leaves", () => { + render( + , + ); + const otherButton = document.createElement("button"); + document.body.append(otherButton); + otherButton.focus(); + fireEvent.keyDown(window, { key: "Escape" }); + otherButton.remove(); + expect(recordSessionFeedbackSurveyResponse).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/chat/response-feedback/SessionFeedbackSurvey.tsx b/src/features/chat/response-feedback/SessionFeedbackSurvey.tsx new file mode 100644 index 000000000..179960a18 --- /dev/null +++ b/src/features/chat/response-feedback/SessionFeedbackSurvey.tsx @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/ui/button"; +import { + type ActiveSessionFeedbackSurvey, + isSessionFeedbackSurveyActive, + markSessionFeedbackSurveyAppeared, + recordSessionFeedbackSurveyResponse, + type SessionFeedbackSurveyResponse, +} from "./sessionFeedbackSurveyState"; + +export function SessionFeedbackSurvey({ + sessionId, + survey, +}: { + sessionId: string; + survey: ActiveSessionFeedbackSurvey; +}) { + const { t } = useTranslation("chat"); + const targetRef = useRef(null); + const dismissRef = useRef(null); + const intersectingRef = useRef(false); + const focusedRef = useRef(false); + const [visible, setVisible] = useState(() => + isSessionFeedbackSurveyActive(sessionId, survey.appearanceId), + ); + + useEffect(() => { + const target = targetRef.current; + if (!target || typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver((entries) => { + const isIntersecting = entries.some((entry) => entry.isIntersecting); + intersectingRef.current = isIntersecting; + if (isIntersecting) { + markSessionFeedbackSurveyAppeared(sessionId, survey.appearanceId); + if (!focusedRef.current) { + dismissRef.current?.focus({ preventScroll: true }); + focusedRef.current = true; + } + } + }); + observer.observe(target); + return () => observer.disconnect(); + }, [sessionId, survey.appearanceId]); + + const respond = useCallback( + (response: SessionFeedbackSurveyResponse) => { + recordSessionFeedbackSurveyResponse( + sessionId, + survey.appearanceId, + response, + ); + setVisible(false); + }, + [sessionId, survey.appearanceId], + ); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.key === "Escape" && + !event.defaultPrevented && + intersectingRef.current && + targetRef.current?.contains(document.activeElement) + ) { + respond("dismissed"); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [respond]); + + if (!visible) return null; + return ( +
+ + {t("message.sessionFeedbackQuestion")} + +
+ {( + [ + ["good", t("message.sessionFeedbackGood")], + ["fine", t("message.sessionFeedbackFine")], + ["bad", t("message.sessionFeedbackBad")], + ] as const + ).map(([response, label]) => ( + + ))} + +
+
+ ); +} diff --git a/src/features/chat/response-feedback/feedbackSurveyEvents.ts b/src/features/chat/response-feedback/feedbackSurveyEvents.ts index 7d2f85693..a71c742de 100644 --- a/src/features/chat/response-feedback/feedbackSurveyEvents.ts +++ b/src/features/chat/response-feedback/feedbackSurveyEvents.ts @@ -3,7 +3,11 @@ import { feedbackSurveySink, } from "./feedbackSurveySink"; -export type FeedbackSurveyEventInput = Omit< +type DistributiveOmit = T extends unknown + ? Omit + : never; + +export type FeedbackSurveyEventInput = DistributiveOmit< FeedbackSurveySinkEvent, "eventSequence" >; diff --git a/src/features/chat/response-feedback/feedbackSurveySink.ts b/src/features/chat/response-feedback/feedbackSurveySink.ts index 87079a953..43b5574f2 100644 --- a/src/features/chat/response-feedback/feedbackSurveySink.ts +++ b/src/features/chat/response-feedback/feedbackSurveySink.ts @@ -1,15 +1,31 @@ export type FeedbackSurveyEventType = "appeared" | "responded"; -export type FeedbackSurveyResponse = "good" | "bad" | "cleared"; +export type ResponseFeedbackSurveyResponse = "good" | "bad" | "cleared"; +export type SessionFeedbackSurveyResponse = + | "good" + | "fine" + | "bad" + | "dismissed"; -export interface FeedbackSurveySinkEvent { +interface FeedbackSurveySinkEventBase { sessionId: string; - messageId: string; appearanceId: string; - surveyType: "response"; eventSequence: number; eventType: FeedbackSurveyEventType; - response?: FeedbackSurveyResponse; } +export type FeedbackSurveySinkEvent = FeedbackSurveySinkEventBase & + ( + | { + messageId: string; + surveyType: "response"; + response?: ResponseFeedbackSurveyResponse; + } + | { + messageId?: never; + surveyType: "session"; + response?: SessionFeedbackSurveyResponse; + } + ); + /** Distribution-owned transport seam; stock Berd intentionally sends nothing. */ export function feedbackSurveySink(_event: FeedbackSurveySinkEvent): void {} diff --git a/src/features/chat/response-feedback/sessionFeedbackSurveyState.test.ts b/src/features/chat/response-feedback/sessionFeedbackSurveyState.test.ts new file mode 100644 index 000000000..92640009d --- /dev/null +++ b/src/features/chat/response-feedback/sessionFeedbackSurveyState.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { claimSessionFeedbackSurveyCooldown } from "@/shared/api/feedbackSurvey"; +import { feedbackSurveySink } from "./feedbackSurveySink"; +import { + claimSessionFeedbackSurvey, + isSessionFeedbackSurveyActive, + markSessionFeedbackSurveyAppeared, + recordSessionFeedbackSurveyResponse, + SESSION_SURVEY_MINIMUM_AGE_MS, +} from "./sessionFeedbackSurveyState"; + +vi.mock("@/shared/api/feedbackSurvey", () => ({ + claimSessionFeedbackSurveyCooldown: vi.fn().mockResolvedValue(true), +})); +vi.mock("./feedbackSurveySink", () => ({ feedbackSurveySink: vi.fn() })); + +const claimCooldown = vi.mocked(claimSessionFeedbackSurveyCooldown); +const sendEvent = vi.mocked(feedbackSurveySink); +const NOW = Date.parse("2026-08-25T12:00:00.000Z"); + +function claim( + sessionId: string, + overrides: Partial[0]> = {}, +) { + return claimSessionFeedbackSurvey({ + sessionId, + messageId: "assistant-1", + currentMessageIds: new Set(["assistant-1"]), + sessionCreatedAt: new Date( + NOW - SESSION_SURVEY_MINIMUM_AGE_MS, + ).toISOString(), + userTurnCount: 5, + samplingRateBasisPoints: 250, + now: NOW, + random: 0, + cooldownRandom: 0, + ...overrides, + }); +} + +describe("sessionFeedbackSurveyState", () => { + beforeEach(() => { + localStorage.clear(); + claimCooldown.mockReset().mockResolvedValue(true); + sendEvent.mockClear(); + }); + + it("fails closed until all eligibility requirements are met", async () => { + await expect( + claim("rate-off", { samplingRateBasisPoints: 0 }), + ).resolves.toBeNull(); + await expect(claim("too-short", { userTurnCount: 4 })).resolves.toBeNull(); + await expect( + claim("too-new", { + sessionCreatedAt: new Date( + NOW - SESSION_SURVEY_MINIMUM_AGE_MS + 1, + ).toISOString(), + }), + ).resolves.toBeNull(); + expect(claimCooldown).not.toHaveBeenCalled(); + }); + + it("samples each eligible completion once", async () => { + claimCooldown.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + await expect(claim("not-selected")).resolves.toBeNull(); + await expect(claim("not-selected")).resolves.toBeNull(); + await expect( + claim("not-selected", { + messageId: "assistant-2", + currentMessageIds: new Set(["assistant-2"]), + }), + ).resolves.toEqual(expect.objectContaining({ messageId: "assistant-2" })); + expect(claimCooldown).toHaveBeenCalledTimes(2); + }); + + it("does not prompt the same session after an appearance", async () => { + const survey = await claim("appeared-once"); + expect(survey).not.toBeNull(); + if (!survey) throw new Error("expected survey to be selected"); + markSessionFeedbackSurveyAppeared("appeared-once", survey.appearanceId); + + await expect( + claim("appeared-once", { + messageId: "assistant-2", + currentMessageIds: new Set(["assistant-2"]), + }), + ).resolves.toBeNull(); + expect(claimCooldown).toHaveBeenCalledTimes(1); + }); + + it("serializes duplicate claims for one session", async () => { + let resolveCooldown: ((selected: boolean) => void) | undefined; + claimCooldown.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCooldown = resolve; + }), + ); + + const first = claim("concurrent"); + const second = claim("concurrent"); + await Promise.resolve(); + expect(claimCooldown).toHaveBeenCalledTimes(1); + resolveCooldown?.(true); + + const [firstSurvey, secondSurvey] = await Promise.all([first, second]); + expect(firstSurvey).not.toBeNull(); + expect(secondSurvey).toEqual(firstSurvey); + expect(claimCooldown).toHaveBeenCalledTimes(1); + }); + + it("emits one appearance and one compatible response event", async () => { + const survey = await claim("responded"); + expect(survey).not.toBeNull(); + if (!survey) throw new Error("expected survey to be selected"); + markSessionFeedbackSurveyAppeared("responded", survey.appearanceId); + markSessionFeedbackSurveyAppeared("responded", survey.appearanceId); + recordSessionFeedbackSurveyResponse( + "responded", + survey.appearanceId, + "fine", + ); + recordSessionFeedbackSurveyResponse( + "responded", + survey.appearanceId, + "bad", + ); + + expect(sendEvent).toHaveBeenCalledTimes(2); + expect(sendEvent.mock.calls.map(([event]) => event)).toEqual([ + expect.objectContaining({ + sessionId: "responded", + surveyType: "session", + eventType: "appeared", + eventSequence: 1, + }), + expect.objectContaining({ + sessionId: "responded", + surveyType: "session", + eventType: "responded", + response: "fine", + eventSequence: 2, + }), + ]); + expect( + isSessionFeedbackSurveyActive("responded", survey.appearanceId), + ).toBe(false); + }); +}); diff --git a/src/features/chat/response-feedback/sessionFeedbackSurveyState.ts b/src/features/chat/response-feedback/sessionFeedbackSurveyState.ts new file mode 100644 index 000000000..2d7517371 --- /dev/null +++ b/src/features/chat/response-feedback/sessionFeedbackSurveyState.ts @@ -0,0 +1,223 @@ +import { claimSessionFeedbackSurveyCooldown } from "@/shared/api/feedbackSurvey"; +import { sendFeedbackSurveyEvent } from "./feedbackSurveyEvents"; + +export type SessionFeedbackSurveyResponse = + | "good" + | "fine" + | "bad" + | "dismissed"; + +export interface ActiveSessionFeedbackSurvey { + appearanceId: string; + messageId: string; +} + +interface StoredSessionFeedbackSurvey { + version: 1; + lastEvaluatedMessageId: string; + active: ActiveSessionFeedbackSurvey | null; + appeared: boolean; + response: SessionFeedbackSurveyResponse | null; +} + +const SESSION_SURVEY_STORAGE_PREFIX = "berd:session-feedback-survey:v1:"; +export const SESSION_SURVEY_MINIMUM_USER_TURNS = 5; +export const SESSION_SURVEY_MINIMUM_AGE_MS = 10 * 60 * 1_000; +const sessionClaimQueues = new Map>(); +const volatileSessionRecords = new Map(); +const volatileOnlySessionIds = new Set(); + +interface SessionFeedbackSurveyClaimInput { + sessionId: string; + messageId: string; + currentMessageIds: ReadonlySet; + sessionCreatedAt: string; + userTurnCount: number; + samplingRateBasisPoints: number; + now?: number; + random?: number; + cooldownRandom?: number; +} + +function sessionStorageKey(sessionId: string): string { + return `${SESSION_SURVEY_STORAGE_PREFIX}${sessionId}`; +} + +function readSessionRecord( + sessionId: string, +): StoredSessionFeedbackSurvey | null { + if (volatileOnlySessionIds.has(sessionId)) { + return volatileSessionRecords.get(sessionId) ?? null; + } + try { + const raw = localStorage.getItem(sessionStorageKey(sessionId)); + if (!raw) return null; + const value = JSON.parse(raw) as Partial; + if ( + value.version !== 1 || + typeof value.lastEvaluatedMessageId !== "string" || + typeof value.appeared !== "boolean" || + (value.response !== null && + value.response !== "good" && + value.response !== "fine" && + value.response !== "bad" && + value.response !== "dismissed") || + (value.active !== null && + (typeof value.active !== "object" || + typeof value.active.appearanceId !== "string" || + typeof value.active.messageId !== "string")) + ) { + return null; + } + const record = value as StoredSessionFeedbackSurvey; + volatileSessionRecords.set(sessionId, record); + return record; + } catch { + return volatileSessionRecords.get(sessionId) ?? null; + } +} + +function writeSessionRecord( + sessionId: string, + record: StoredSessionFeedbackSurvey, +): void { + volatileSessionRecords.set(sessionId, record); + try { + localStorage.setItem(sessionStorageKey(sessionId), JSON.stringify(record)); + volatileOnlySessionIds.delete(sessionId); + } catch { + volatileOnlySessionIds.add(sessionId); + } +} + +function emitSessionSurvey( + sessionId: string, + appearanceId: string, + event: + | { eventType: "appeared" } + | { eventType: "responded"; response: SessionFeedbackSurveyResponse }, +): void { + sendFeedbackSurveyEvent({ + sessionId, + appearanceId, + surveyType: "session", + ...event, + }); +} + +async function claimSessionFeedbackSurveyUnqueued({ + sessionId, + messageId, + currentMessageIds, + sessionCreatedAt, + userTurnCount, + samplingRateBasisPoints, + now = Date.now(), + random = Math.random(), + cooldownRandom = Math.random(), +}: SessionFeedbackSurveyClaimInput): Promise { + let existing = readSessionRecord(sessionId); + const createdAt = Date.parse(sessionCreatedAt); + if (existing?.active && currentMessageIds.has(existing.active.messageId)) { + return existing.active; + } + if (existing?.active) { + existing = { ...existing, active: null }; + writeSessionRecord(sessionId, existing); + } + if ( + existing?.appeared || + existing?.response || + existing?.lastEvaluatedMessageId === messageId || + userTurnCount < SESSION_SURVEY_MINIMUM_USER_TURNS || + !Number.isFinite(createdAt) || + now - createdAt < SESSION_SURVEY_MINIMUM_AGE_MS + ) { + return null; + } + + const rate = Math.min(10_000, Math.max(0, samplingRateBasisPoints)); + if (rate === 0) return null; + const selected = await claimSessionFeedbackSurveyCooldown({ + samplingRateBasisPoints: rate, + random, + cooldownRandom, + }).catch(() => false); + const active = selected + ? { appearanceId: crypto.randomUUID(), messageId } + : null; + writeSessionRecord(sessionId, { + version: 1, + lastEvaluatedMessageId: messageId, + active, + appeared: false, + response: null, + }); + return active; +} + +export function claimSessionFeedbackSurvey( + input: SessionFeedbackSurveyClaimInput, +): Promise { + const previous = sessionClaimQueues.get(input.sessionId) ?? Promise.resolve(); + const claim = previous.then( + () => claimSessionFeedbackSurveyUnqueued(input), + () => claimSessionFeedbackSurveyUnqueued(input), + ); + const settled = claim.then( + () => undefined, + () => undefined, + ); + sessionClaimQueues.set(input.sessionId, settled); + void settled.finally(() => { + if (sessionClaimQueues.get(input.sessionId) === settled) { + sessionClaimQueues.delete(input.sessionId); + } + }); + return claim; +} + +export function isSessionFeedbackSurveyActive( + sessionId: string, + appearanceId: string, +): boolean { + return readSessionRecord(sessionId)?.active?.appearanceId === appearanceId; +} + +export function markSessionFeedbackSurveyAppeared( + sessionId: string, + appearanceId: string, +): void { + const record = readSessionRecord(sessionId); + if ( + !record?.active || + record.active.appearanceId !== appearanceId || + record.appeared + ) { + return; + } + writeSessionRecord(sessionId, { ...record, appeared: true }); + emitSessionSurvey(sessionId, appearanceId, { eventType: "appeared" }); +} + +export function recordSessionFeedbackSurveyResponse( + sessionId: string, + appearanceId: string, + response: SessionFeedbackSurveyResponse, +): void { + const record = readSessionRecord(sessionId); + if (!record?.active || record.active.appearanceId !== appearanceId) return; + if (!record.appeared) { + emitSessionSurvey(sessionId, appearanceId, { eventType: "appeared" }); + } + writeSessionRecord(sessionId, { + ...record, + active: null, + appeared: true, + response, + }); + emitSessionSurvey(sessionId, appearanceId, { + eventType: "responded", + response, + }); +} diff --git a/src/features/chat/response-feedback/useSessionFeedbackSurvey.test.tsx b/src/features/chat/response-feedback/useSessionFeedbackSurvey.test.tsx new file mode 100644 index 000000000..5a0ec6817 --- /dev/null +++ b/src/features/chat/response-feedback/useSessionFeedbackSurvey.test.tsx @@ -0,0 +1,94 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Message } from "@/shared/types/messages"; +import { claimSessionFeedbackSurvey } from "./sessionFeedbackSurveyState"; +import { useSessionFeedbackSurvey } from "./useSessionFeedbackSurvey"; + +vi.mock("./sessionFeedbackSurveyState", () => ({ + claimSessionFeedbackSurvey: vi.fn(), + SESSION_SURVEY_MINIMUM_AGE_MS: 10 * 60 * 1_000, +})); + +const claimSurvey = vi.mocked(claimSessionFeedbackSurvey); + +function message(id: string, role: "user" | "assistant"): Message { + return { + id, + role, + created: Date.now(), + content: [{ type: "text", text: id }], + }; +} + +const previousMessages = [ + message("user-1", "user"), + message("assistant-1", "assistant"), + message("user-2", "user"), + message("assistant-2", "assistant"), + message("user-3", "user"), + message("assistant-3", "assistant"), + message("user-4", "user"), + message("assistant-4", "assistant"), +]; + +describe("useSessionFeedbackSurvey", () => { + beforeEach(() => { + claimSurvey.mockReset().mockResolvedValue(null); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("waits for the requested response to complete before claiming", async () => { + const props = { + sessionId: "session", + sessionCreatedAt: "2026-08-25T00:00:00.000Z", + messages: [...previousMessages, message("user-5", "user")], + streamingMessageId: null, + responsePending: true, + samplingRateBasisPoints: 250, + }; + const { rerender } = renderHook( + (currentProps: typeof props) => useSessionFeedbackSurvey(currentProps), + { initialProps: props }, + ); + + expect(claimSurvey).not.toHaveBeenCalled(); + rerender({ + ...props, + messages: [...props.messages, message("assistant-5", "assistant")], + responsePending: false, + }); + + await waitFor(() => expect(claimSurvey).toHaveBeenCalledTimes(1)); + expect(claimSurvey).toHaveBeenCalledWith( + expect.objectContaining({ messageId: "assistant-5", userTurnCount: 5 }), + ); + }); + + it("re-evaluates when the session reaches the minimum age", () => { + vi.useFakeTimers(); + claimSurvey.mockReturnValue(new Promise(() => {})); + const now = Date.parse("2026-08-25T12:00:00.000Z"); + vi.setSystemTime(now); + renderHook(() => + useSessionFeedbackSurvey({ + sessionId: "aging-session", + sessionCreatedAt: new Date(now - 10 * 60 * 1_000 + 1_000).toISOString(), + messages: [ + ...previousMessages, + message("user-5", "user"), + message("assistant-5", "assistant"), + ], + streamingMessageId: null, + responsePending: false, + samplingRateBasisPoints: 250, + }), + ); + + expect(claimSurvey).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(1_000)); + expect(claimSurvey).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/features/chat/response-feedback/useSessionFeedbackSurvey.ts b/src/features/chat/response-feedback/useSessionFeedbackSurvey.ts new file mode 100644 index 000000000..5db9572fd --- /dev/null +++ b/src/features/chat/response-feedback/useSessionFeedbackSurvey.ts @@ -0,0 +1,127 @@ +import { useEffect, useMemo, useState } from "react"; +import { getUserVisibleMessageContent } from "@/features/chat/transcript/projection"; +import type { Message } from "@/shared/types/messages"; +import { isResponseFeedbackEligible } from "./responseFeedbackState"; +import { + type ActiveSessionFeedbackSurvey, + claimSessionFeedbackSurvey, + SESSION_SURVEY_MINIMUM_AGE_MS, +} from "./sessionFeedbackSurveyState"; + +export function useSessionFeedbackSurvey({ + sessionId, + sessionCreatedAt, + messages, + streamingMessageId, + responsePending, + samplingRateBasisPoints, +}: { + sessionId: string; + sessionCreatedAt?: string; + messages: readonly Message[]; + streamingMessageId?: string | null; + responsePending: boolean; + samplingRateBasisPoints: number; +}): ActiveSessionFeedbackSurvey | null { + const [ageThresholdEvaluation, setAgeThresholdEvaluation] = useState<{ + sessionCreatedAt: string; + now: number; + } | null>(null); + useEffect(() => { + if (!sessionCreatedAt) return; + const remaining = + Date.parse(sessionCreatedAt) + SESSION_SURVEY_MINIMUM_AGE_MS - Date.now(); + if (!Number.isFinite(remaining) || remaining <= 0) return; + const timeout = window.setTimeout( + () => setAgeThresholdEvaluation({ sessionCreatedAt, now: Date.now() }), + remaining, + ); + return () => window.clearTimeout(timeout); + }, [sessionCreatedAt]); + + const candidate = useMemo(() => { + if (!sessionCreatedAt || samplingRateBasisPoints <= 0) { + return null; + } + let userTurnCount = 0; + let message: Message | null = null; + for (const current of messages) { + if ( + current.role === "user" && + current.metadata?.userVisible !== false && + getUserVisibleMessageContent(current.content).some( + (content) => content.type !== "toolResponse", + ) + ) { + userTurnCount += 1; + } + if ( + isResponseFeedbackEligible({ + message: current, + content: current.content, + isStreaming: current.id === streamingMessageId, + }) + ) { + message = current; + } + } + return message + ? { + messageId: message.id, + userTurnCount, + currentMessageIds: new Set(messages.map((current) => current.id)), + now: + ageThresholdEvaluation?.sessionCreatedAt === sessionCreatedAt + ? ageThresholdEvaluation.now + : Date.now(), + } + : null; + }, [ + ageThresholdEvaluation, + messages, + samplingRateBasisPoints, + sessionCreatedAt, + streamingMessageId, + ]); + const [surveyState, setSurveyState] = useState<{ + sessionId: string; + survey: ActiveSessionFeedbackSurvey | null; + }>({ sessionId, survey: null }); + + useEffect(() => { + let cancelled = false; + if (responsePending) { + return () => { + cancelled = true; + }; + } + if (!candidate || !sessionCreatedAt) { + setSurveyState({ sessionId, survey: null }); + return () => { + cancelled = true; + }; + } + void claimSessionFeedbackSurvey({ + sessionId, + messageId: candidate.messageId, + currentMessageIds: candidate.currentMessageIds, + sessionCreatedAt, + userTurnCount: candidate.userTurnCount, + samplingRateBasisPoints, + now: candidate.now, + }).then((survey) => { + if (!cancelled) setSurveyState({ sessionId, survey }); + }); + return () => { + cancelled = true; + }; + }, [ + candidate, + responsePending, + samplingRateBasisPoints, + sessionCreatedAt, + sessionId, + ]); + + return surveyState.sessionId === sessionId ? surveyState.survey : null; +} diff --git a/src/features/chat/ui/ChatTranscriptSurface.tsx b/src/features/chat/ui/ChatTranscriptSurface.tsx index a2f23657c..23959a017 100644 --- a/src/features/chat/ui/ChatTranscriptSurface.tsx +++ b/src/features/chat/ui/ChatTranscriptSurface.tsx @@ -13,6 +13,7 @@ import { scheduleAfterNextPaint } from "@/app/lib/scheduleAfterNextPaint"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { ArtifactPolicyProvider } from "@/features/chat/hooks/ArtifactPolicyContext"; import type { TranscriptSearchBackend } from "@/features/chat/lib/transcriptSearchBackend"; +import { useSessionFeedbackSurvey } from "../response-feedback/useSessionFeedbackSurvey"; import { ChatLoadingSkeleton } from "./ChatLoadingSkeleton"; import { ConversationEmptyAvatar } from "./ConversationEmptyAvatar"; import { @@ -33,7 +34,10 @@ type TimelineCallbacks = Pick< export interface ChatTranscriptSurfaceProps extends TimelineCallbacks { sessionId: string; messages: Message[]; + sessionCreatedAt?: string; + sessionSurveySamplingRateBasisPoints?: number; streamingMessageId?: string | null; + responsePending?: boolean; isLoadingHistory: boolean; selectedPersona?: Persona | null; sessionCwd?: string | null; @@ -64,7 +68,10 @@ function shouldStageInitialTranscript( export function ChatTranscriptSurface({ sessionId, messages, + sessionCreatedAt, + sessionSurveySamplingRateBasisPoints = 0, streamingMessageId, + responsePending = false, isLoadingHistory, selectedPersona, sessionCwd, @@ -93,6 +100,14 @@ export function ChatTranscriptSurface({ initialGate.sessionId === sessionId ? initialGate.pending : shouldStage; const showLoading = isLoadingHistory || isPreparing; const timelineMessages = isPreparing ? [] : messages; + const sessionFeedbackSurvey = useSessionFeedbackSurvey({ + sessionId, + sessionCreatedAt, + messages: timelineMessages, + streamingMessageId, + responsePending, + samplingRateBasisPoints: sessionSurveySamplingRateBasisPoints, + }); useEffect( () => retainMountedTranscript(sessionId), @@ -157,6 +172,7 @@ export function ChatTranscriptSurface({ sessionId={sessionId} messages={timelineMessages} streamingMessageId={streamingMessageId} + sessionFeedbackSurvey={sessionFeedbackSurvey} scrollTargetMessageId={scrollTargetMessageId} scrollTargetQuery={scrollTargetQuery} onScrollTargetHandled={onScrollTargetHandled} diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 3b49cd56a..099230dc7 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -28,6 +28,7 @@ import { useFocusRegion } from "@/app/focus/FocusRegionProvider"; import { perfLog } from "@/shared/lib/perfLog"; import { Badge } from "@/shared/ui/badge"; import { cn } from "@/shared/lib/cn"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; import type { WorkspaceNameRequest } from "../hooks/useChatSessionController"; import { ConversationComposerCapability, @@ -224,6 +225,9 @@ export function ChatView({ const { fallbackCwd: terminalFallbackCwd } = useTerminalFallbackCwdPreference(); const capabilities = useProfileCapabilities(); + const sessionSurveySamplingRateBasisPoints = useRuntimeConfigStore( + (state) => state.config.feedback?.sessionSurveySamplingRateBasisPoints ?? 0, + ); const pocketVoiceSetup = usePocketVoiceSetup(capabilities.voiceConversation); const macSpeechSetup = useMacSpeechSetup(capabilities.voiceConversation); const voiceInput = useVoiceInputPreference( @@ -689,7 +693,14 @@ export function ChatView({ void; onRetryMessage?: (messageId: string) => void; @@ -702,6 +705,7 @@ export const MessageBubble = memo(function MessageBubble({ contentContext, actionMessageId = message.id, feedbackSessionId, + sessionFeedbackSurvey, fragmentRole, onRetryMessage, onEditMessage, @@ -1133,6 +1137,17 @@ export const MessageBubble = memo(function MessageBubble({ )} + {feedbackSessionId && + sessionFeedbackSurvey && + (!fragmentRole || + fragmentRole === "single" || + fragmentRole === "end") ? ( + + ) : null} + {showMessageActions ? (
void; @@ -982,6 +984,7 @@ function VirtualMessageTimelineSession({ sessionId, messages, streamingMessageId, + sessionFeedbackSurvey, scrollTargetMessageId, scrollTargetQuery, onScrollTargetHandled, @@ -3530,6 +3533,14 @@ function VirtualMessageTimelineSession({ feedbackSessionId={ responseFeedbackRowIds.has(row.rowId) ? sessionId : undefined } + sessionFeedbackSurvey={ + sessionFeedbackSurvey && + responseFeedbackRowIds.has(row.rowId) && + (row.responseStartMessageId ?? row.messageId) === + sessionFeedbackSurvey.messageId + ? sessionFeedbackSurvey + : undefined + } showJumpToResponseStartHint={ row.messageId === responseStartHintMessageId && responseStartHintIsActive diff --git a/src/features/chat/ui/VirtualTranscriptRow.tsx b/src/features/chat/ui/VirtualTranscriptRow.tsx index adaacccdc..ae8c693ea 100644 --- a/src/features/chat/ui/VirtualTranscriptRow.tsx +++ b/src/features/chat/ui/VirtualTranscriptRow.tsx @@ -8,6 +8,7 @@ import { } from "react"; import { cn } from "@/shared/lib/cn"; import type { Message } from "@/shared/types/messages"; +import type { ActiveSessionFeedbackSurvey } from "../response-feedback/sessionFeedbackSurveyState"; import { VIRTUAL_ROW_LAYOUT_PENDING_ATTRIBUTE, VIRTUAL_ROW_RESERVED_BLOCK_SIZE_ATTRIBUTE, @@ -46,6 +47,7 @@ interface VirtualTranscriptRowProps { actionsAlwaysVisible?: boolean; showJumpToResponseStartHint?: boolean; feedbackSessionId?: string; + sessionFeedbackSurvey?: ActiveSessionFeedbackSurvey; isPulsing?: boolean; rowStateProvider?: TranscriptVirtualRowStateProviderConfig; bubbleCallbacks?: MessageBubbleCallbacks; @@ -72,6 +74,7 @@ export const VirtualTranscriptRow = memo(function VirtualTranscriptRow({ actionsAlwaysVisible, showJumpToResponseStartHint, feedbackSessionId, + sessionFeedbackSurvey, isPulsing, rowStateProvider, bubbleCallbacks, @@ -307,6 +310,7 @@ export const VirtualTranscriptRow = memo(function VirtualTranscriptRow({ isStreaming={row.fragment.isStreamingTail && isStreaming} actionsAlwaysVisible={actionsAlwaysVisible} feedbackSessionId={feedbackSessionId} + sessionFeedbackSurvey={sessionFeedbackSurvey} showJumpToResponseStartHint={showJumpToResponseStartHint} onRetryMessage={ row.fragment.role === "end" || row.fragment.role === "single" @@ -388,6 +392,7 @@ export const VirtualTranscriptRow = memo(function VirtualTranscriptRow({ isStreaming={isStreaming} actionsAlwaysVisible={actionsAlwaysVisible} feedbackSessionId={feedbackSessionId} + sessionFeedbackSurvey={sessionFeedbackSurvey} showJumpToResponseStartHint={showJumpToResponseStartHint} onRetryMessage={ message.role === "assistant" ? onRetryMessage : undefined @@ -453,6 +458,7 @@ function areVirtualTranscriptRowPropsEqual( previous.actionsAlwaysVisible === next.actionsAlwaysVisible && previous.showJumpToResponseStartHint === next.showJumpToResponseStartHint && previous.feedbackSessionId === next.feedbackSessionId && + previous.sessionFeedbackSurvey === next.sessionFeedbackSurvey && previous.isPulsing === next.isPulsing && previous.rowStateProvider === next.rowStateProvider && previous.bubbleCallbacks === next.bubbleCallbacks && diff --git a/src/shared/api/feedbackSurvey.ts b/src/shared/api/feedbackSurvey.ts new file mode 100644 index 000000000..756904435 --- /dev/null +++ b/src/shared/api/feedbackSurvey.ts @@ -0,0 +1,15 @@ +import { invoke } from "@tauri-apps/api/core"; + +export async function claimSessionFeedbackSurveyCooldown({ + samplingRateBasisPoints, + random, + cooldownRandom, +}: { + samplingRateBasisPoints: number; + random: number; + cooldownRandom: number; +}): Promise { + return invoke("claim_session_feedback_survey_cooldown", { + input: { samplingRateBasisPoints, random, cooldownRandom }, + }); +} diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index a7df07e09..2248d7522 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -404,6 +404,11 @@ "redactedThinking": "(thinking redacted)", "responseFeedbackGood": "Good response", "responseFeedbackBad": "Bad response", + "sessionFeedbackQuestion": "How is your Berd session going?", + "sessionFeedbackGood": "Good", + "sessionFeedbackFine": "Fine", + "sessionFeedbackBad": "Bad", + "sessionFeedbackDismiss": "Dismiss", "providerError": { "anthropicThinkingHistory": "This chat can't continue with a Claude model because its earlier reasoning history is no longer in a form Claude will accept. Start a new chat, or switch this chat to a non-Claude model to keep going." }, diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 499815629..284e87c3b 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -403,6 +403,11 @@ "redactedThinking": "(pensamiento redactado)", "responseFeedbackGood": "Buena respuesta", "responseFeedbackBad": "Mala respuesta", + "sessionFeedbackQuestion": "¿Cómo va tu sesión de Berd?", + "sessionFeedbackGood": "Bien", + "sessionFeedbackFine": "Regular", + "sessionFeedbackBad": "Mal", + "sessionFeedbackDismiss": "Descartar", "providerError": { "anthropicThinkingHistory": "Este chat no puede continuar con un modelo Claude porque su historial de razonamiento previo ya no tiene una forma que Claude acepte. Inicia un chat nuevo o cambia este chat a un modelo que no sea Claude para continuar." }, diff --git a/src/shared/runtime-config/schema.test.ts b/src/shared/runtime-config/schema.test.ts index ad0e61616..47ad53e32 100644 --- a/src/shared/runtime-config/schema.test.ts +++ b/src/shared/runtime-config/schema.test.ts @@ -102,9 +102,17 @@ describe("runtimeConfigSchema", () => { expect( runtimeConfigSchema.parse({ ...DEFAULT_RUNTIME_CONFIG, - feedback: { enabled: true, responseRatingEnabled: true }, + feedback: { + enabled: true, + responseRatingEnabled: true, + sessionSurveySamplingRateBasisPoints: 250, + }, }).feedback, - ).toEqual({ enabled: true, responseRatingEnabled: true }); + ).toEqual({ + enabled: true, + responseRatingEnabled: true, + sessionSurveySamplingRateBasisPoints: 250, + }); }); it("accepts an empty managed-provider list as unrestricted policy", () => { diff --git a/src/shared/runtime-config/schema.ts b/src/shared/runtime-config/schema.ts index d08aaa3ca..3efdd7585 100644 --- a/src/shared/runtime-config/schema.ts +++ b/src/shared/runtime-config/schema.ts @@ -262,6 +262,12 @@ export const runtimeFeedbackConfigSchema = z enabled: z.boolean().optional(), projectKey: nonEmptyString("feedback projectKey").optional(), responseRatingEnabled: z.boolean().optional(), + sessionSurveySamplingRateBasisPoints: z + .number() + .int() + .min(0) + .max(10_000) + .optional(), }) .strict(); From 112bfcf655d41d61ad48d72005464b4d4abc2bc2 Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Tue, 25 Aug 2026 15:46:55 -0400 Subject: [PATCH 2/3] Measure offscreen session surveys Amp-Thread-ID: https://ampcode.com/threads/T-019fbabe-6c0e-70ab-8559-31ba601d4897 Co-authored-by: Amp --- .../SessionFeedbackSurvey.test.tsx | 19 ++++++++++++++++++ .../SessionFeedbackSurvey.tsx | 14 +++++++++---- src/features/chat/ui/MessageBubble.tsx | 3 +++ .../chat/ui/VirtualMessageTimeline.tsx | 20 +++++++++++-------- src/features/chat/ui/VirtualTranscriptRow.tsx | 2 ++ 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx b/src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx index ccd5c3c00..c14877630 100644 --- a/src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx +++ b/src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx @@ -56,6 +56,25 @@ describe("SessionFeedbackSurvey", () => { ); }); + it("renders without interaction while measuring offscreen", () => { + render( + , + ); + + const dismiss = screen.getByRole("button", { name: "Dismiss" }); + expect(dismiss).not.toHaveFocus(); + expect(dismiss).toHaveAttribute("tabindex", "-1"); + expect(markSessionFeedbackSurveyAppeared).not.toHaveBeenCalled(); + + fireEvent.click(dismiss); + fireEvent.keyDown(window, { key: "Escape" }); + expect(recordSessionFeedbackSurveyResponse).not.toHaveBeenCalled(); + }); + it("treats Escape as dismiss", () => { render( (null); @@ -26,6 +28,7 @@ export function SessionFeedbackSurvey({ ); useEffect(() => { + if (measurementOnly) return; const target = targetRef.current; if (!target || typeof IntersectionObserver === "undefined") return; const observer = new IntersectionObserver((entries) => { @@ -41,7 +44,7 @@ export function SessionFeedbackSurvey({ }); observer.observe(target); return () => observer.disconnect(); - }, [sessionId, survey.appearanceId]); + }, [measurementOnly, sessionId, survey.appearanceId]); const respond = useCallback( (response: SessionFeedbackSurveyResponse) => { @@ -56,6 +59,7 @@ export function SessionFeedbackSurvey({ ); useEffect(() => { + if (measurementOnly) return; const onKeyDown = (event: KeyboardEvent) => { if ( event.key === "Escape" && @@ -68,7 +72,7 @@ export function SessionFeedbackSurvey({ }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [respond]); + }, [measurementOnly, respond]); if (!visible) return null; return ( @@ -91,7 +95,8 @@ export function SessionFeedbackSurvey({ key={response} size="sm" variant="outline" - onClick={() => respond(response)} + tabIndex={measurementOnly ? -1 : undefined} + onClick={measurementOnly ? undefined : () => respond(response)} > {label} @@ -100,7 +105,8 @@ export function SessionFeedbackSurvey({ ref={dismissRef} size="sm" variant="ghost" - onClick={() => respond("dismissed")} + tabIndex={measurementOnly ? -1 : undefined} + onClick={measurementOnly ? undefined : () => respond("dismissed")} > {t("message.sessionFeedbackDismiss")} diff --git a/src/features/chat/ui/MessageBubble.tsx b/src/features/chat/ui/MessageBubble.tsx index 00db482c9..c520c64e0 100644 --- a/src/features/chat/ui/MessageBubble.tsx +++ b/src/features/chat/ui/MessageBubble.tsx @@ -349,6 +349,7 @@ interface MessageBubbleProps { actionMessageId?: string; feedbackSessionId?: string; sessionFeedbackSurvey?: ActiveSessionFeedbackSurvey; + sessionFeedbackSurveyMeasurementOnly?: boolean; fragmentRole?: "single" | "start" | "middle" | "end"; onCopy?: () => void; onRetryMessage?: (messageId: string) => void; @@ -706,6 +707,7 @@ export const MessageBubble = memo(function MessageBubble({ actionMessageId = message.id, feedbackSessionId, sessionFeedbackSurvey, + sessionFeedbackSurveyMeasurementOnly = false, fragmentRole, onRetryMessage, onEditMessage, @@ -1145,6 +1147,7 @@ export const MessageBubble = memo(function MessageBubble({ ) : null} diff --git a/src/features/chat/ui/VirtualMessageTimeline.tsx b/src/features/chat/ui/VirtualMessageTimeline.tsx index e620190b5..0c824d3f8 100644 --- a/src/features/chat/ui/VirtualMessageTimeline.tsx +++ b/src/features/chat/ui/VirtualMessageTimeline.tsx @@ -3498,6 +3498,13 @@ function VirtualMessageTimelineSession({ ], ); + const sessionFeedbackSurveyForRow = (row: TranscriptRowDescriptor) => + sessionFeedbackSurvey && + responseFeedbackRowIds.has(row.rowId) && + (row.responseStartMessageId ?? row.messageId) === + sessionFeedbackSurvey.messageId + ? sessionFeedbackSurvey + : undefined; const renderRow = ( row: TranscriptRowDescriptor, index: number, @@ -3533,14 +3540,7 @@ function VirtualMessageTimelineSession({ feedbackSessionId={ responseFeedbackRowIds.has(row.rowId) ? sessionId : undefined } - sessionFeedbackSurvey={ - sessionFeedbackSurvey && - responseFeedbackRowIds.has(row.rowId) && - (row.responseStartMessageId ?? row.messageId) === - sessionFeedbackSurvey.messageId - ? sessionFeedbackSurvey - : undefined - } + sessionFeedbackSurvey={sessionFeedbackSurveyForRow(row)} showJumpToResponseStartHint={ row.messageId === responseStartHintMessageId && responseStartHintIsActive @@ -3574,6 +3574,10 @@ function VirtualMessageTimelineSession({ })} message={row.messageId ? stableMessageByRowId.get(row.rowId) : undefined} isStreaming={false} + feedbackSessionId={ + responseFeedbackRowIds.has(row.rowId) ? sessionId : undefined + } + sessionFeedbackSurvey={sessionFeedbackSurveyForRow(row)} rowStateProvider={ virtualTimeline.rowStateProvider ? { diff --git a/src/features/chat/ui/VirtualTranscriptRow.tsx b/src/features/chat/ui/VirtualTranscriptRow.tsx index ae8c693ea..ef66d9e5d 100644 --- a/src/features/chat/ui/VirtualTranscriptRow.tsx +++ b/src/features/chat/ui/VirtualTranscriptRow.tsx @@ -311,6 +311,7 @@ export const VirtualTranscriptRow = memo(function VirtualTranscriptRow({ actionsAlwaysVisible={actionsAlwaysVisible} feedbackSessionId={feedbackSessionId} sessionFeedbackSurvey={sessionFeedbackSurvey} + sessionFeedbackSurveyMeasurementOnly={isOffscreenRealMeasurement} showJumpToResponseStartHint={showJumpToResponseStartHint} onRetryMessage={ row.fragment.role === "end" || row.fragment.role === "single" @@ -393,6 +394,7 @@ export const VirtualTranscriptRow = memo(function VirtualTranscriptRow({ actionsAlwaysVisible={actionsAlwaysVisible} feedbackSessionId={feedbackSessionId} sessionFeedbackSurvey={sessionFeedbackSurvey} + sessionFeedbackSurveyMeasurementOnly={isOffscreenRealMeasurement} showJumpToResponseStartHint={showJumpToResponseStartHint} onRetryMessage={ message.role === "assistant" ? onRetryMessage : undefined From f202dab895b2c6ebb52492d7abd490bd4e5edc2d Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Wed, 26 Aug 2026 23:16:18 -0400 Subject: [PATCH 3/3] Harden session survey lifecycle Amp-Thread-ID: https://ampcode.com/threads/T-019fbabe-6c0e-70ab-8559-31ba601d4897 Co-authored-by: Amp --- src-tauri/src/commands/feedback_survey.rs | 5 +- src-tauri/src/commands/runtime_config.rs | 1 + .../SessionFeedbackSurvey.test.tsx | 49 ++++++++++- .../SessionFeedbackSurvey.tsx | 19 +++- .../sessionFeedbackSurveyState.test.ts | 60 +++++++++++-- .../sessionFeedbackSurveyState.ts | 35 ++++++-- src/features/chat/ui/ChatView.tsx | 6 +- .../chat/ui/VirtualMessageTimeline.tsx | 52 +++++++++-- .../ui/__tests__/ChatView.mcpApp.test.tsx | 31 +++++++ .../__tests__/VirtualMessageTimeline.test.tsx | 86 ++++++++++++++++++- src/shared/runtime-config/schema.ts | 2 + 11 files changed, 317 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/commands/feedback_survey.rs b/src-tauri/src/commands/feedback_survey.rs index 5e1b473ff..5e7d09176 100644 --- a/src-tauri/src/commands/feedback_survey.rs +++ b/src-tauri/src/commands/feedback_survey.rs @@ -6,6 +6,9 @@ const SESSION_SURVEY_COOLDOWN_FILE: &str = "session-feedback-survey-cooldown-v1" const SESSION_SURVEY_COOLDOWN_MINIMUM_MS: u64 = 27 * 60 * 60 * 1_000; const SESSION_SURVEY_COOLDOWN_JITTER_MS: u64 = 2 * 60 * 60 * 1_000; +/// Coordinates survey cooldown claims within one app process and restores the +/// persisted deadline after restart. Independent app processes may race; this +/// feedback path is intentionally best-effort. pub struct SessionFeedbackSurveyCooldownState { path: PathBuf, next_eligible_at_ms: Mutex, @@ -98,7 +101,7 @@ mod tests { use super::*; #[test] - fn cooldown_claim_is_atomic_and_persistent() { + fn cooldown_claim_is_process_atomic_and_persists_across_restart() { let dir = tempfile::tempdir().unwrap(); let state = Arc::new(SessionFeedbackSurveyCooldownState::new( dir.path().to_path_buf(), diff --git a/src-tauri/src/commands/runtime_config.rs b/src-tauri/src/commands/runtime_config.rs index 4e803a5c0..ad8880942 100644 --- a/src-tauri/src/commands/runtime_config.rs +++ b/src-tauri/src/commands/runtime_config.rs @@ -164,6 +164,7 @@ pub struct RuntimeFeedbackConfig { pub project_key: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub response_rating_enabled: Option, + /// Per-eligible-completion opportunity hazard, not a session allocation. #[serde(skip_serializing_if = "Option::is_none", default)] pub session_survey_sampling_rate_basis_points: Option, } diff --git a/src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx b/src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx index c14877630..05d02910b 100644 --- a/src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx +++ b/src/features/chat/response-feedback/SessionFeedbackSurvey.test.tsx @@ -7,7 +7,7 @@ import { } from "./sessionFeedbackSurveyState"; vi.mock("./sessionFeedbackSurveyState", () => ({ - isSessionFeedbackSurveyActive: vi.fn(() => true), + isSessionFeedbackSurveyPresentable: vi.fn(() => true), markSessionFeedbackSurveyAppeared: vi.fn(), recordSessionFeedbackSurveyResponse: vi.fn(), })); @@ -76,6 +76,9 @@ describe("SessionFeedbackSurvey", () => { }); it("treats Escape as dismiss", () => { + const priorFocus = document.createElement("button"); + document.body.append(priorFocus); + priorFocus.focus(); render( { "appearance", "dismissed", ); + expect(priorFocus).toHaveFocus(); + priorFocus.remove(); + }); + + it("restores prior focus after a button response", () => { + const priorFocus = document.createElement("button"); + document.body.append(priorFocus); + priorFocus.focus(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Good" })); + + expect(recordSessionFeedbackSurveyResponse).toHaveBeenCalledWith( + "session", + "appearance", + "good", + ); + expect(priorFocus).toHaveFocus(); + priorFocus.remove(); + }); + + it("falls back to the composer when prior focus disconnected", () => { + const priorFocus = document.createElement("button"); + const composer = document.createElement("textarea"); + composer.dataset.testid = "chat-composer"; + document.body.append(priorFocus, composer); + priorFocus.focus(); + render( + , + ); + priorFocus.remove(); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(composer).toHaveFocus(); + composer.remove(); }); it("ignores Escape outside the viewport", () => { diff --git a/src/features/chat/response-feedback/SessionFeedbackSurvey.tsx b/src/features/chat/response-feedback/SessionFeedbackSurvey.tsx index 72c57d1a2..f70ae0ac2 100644 --- a/src/features/chat/response-feedback/SessionFeedbackSurvey.tsx +++ b/src/features/chat/response-feedback/SessionFeedbackSurvey.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/shared/ui/button"; import { type ActiveSessionFeedbackSurvey, - isSessionFeedbackSurveyActive, + isSessionFeedbackSurveyPresentable, markSessionFeedbackSurveyAppeared, recordSessionFeedbackSurveyResponse, type SessionFeedbackSurveyResponse, @@ -21,10 +21,11 @@ export function SessionFeedbackSurvey({ const { t } = useTranslation("chat"); const targetRef = useRef(null); const dismissRef = useRef(null); + const restoreFocusRef = useRef(null); const intersectingRef = useRef(false); const focusedRef = useRef(false); const [visible, setVisible] = useState(() => - isSessionFeedbackSurveyActive(sessionId, survey.appearanceId), + isSessionFeedbackSurveyPresentable(sessionId, survey.appearanceId), ); useEffect(() => { @@ -37,6 +38,13 @@ export function SessionFeedbackSurvey({ if (isIntersecting) { markSessionFeedbackSurveyAppeared(sessionId, survey.appearanceId); if (!focusedRef.current) { + const activeElement = document.activeElement; + restoreFocusRef.current = + activeElement instanceof HTMLElement && + activeElement !== document.body && + !target.contains(activeElement) + ? activeElement + : null; dismissRef.current?.focus({ preventScroll: true }); focusedRef.current = true; } @@ -54,6 +62,13 @@ export function SessionFeedbackSurvey({ response, ); setVisible(false); + const restoreFocus = restoreFocusRef.current?.isConnected + ? restoreFocusRef.current + : document.querySelector( + "[data-testid='chat-composer']:not(:disabled)", + ); + restoreFocus?.focus({ preventScroll: true }); + restoreFocusRef.current = null; }, [sessionId, survey.appearanceId], ); diff --git a/src/features/chat/response-feedback/sessionFeedbackSurveyState.test.ts b/src/features/chat/response-feedback/sessionFeedbackSurveyState.test.ts index 92640009d..1dc648280 100644 --- a/src/features/chat/response-feedback/sessionFeedbackSurveyState.test.ts +++ b/src/features/chat/response-feedback/sessionFeedbackSurveyState.test.ts @@ -3,7 +3,7 @@ import { claimSessionFeedbackSurveyCooldown } from "@/shared/api/feedbackSurvey" import { feedbackSurveySink } from "./feedbackSurveySink"; import { claimSessionFeedbackSurvey, - isSessionFeedbackSurveyActive, + isSessionFeedbackSurveyPresentable, markSessionFeedbackSurveyAppeared, recordSessionFeedbackSurveyResponse, SESSION_SURVEY_MINIMUM_AGE_MS, @@ -60,7 +60,7 @@ describe("sessionFeedbackSurveyState", () => { expect(claimCooldown).not.toHaveBeenCalled(); }); - it("samples each eligible completion once", async () => { + it("applies the basis-point hazard once per eligible completion", async () => { claimCooldown.mockResolvedValueOnce(false).mockResolvedValueOnce(true); await expect(claim("not-selected")).resolves.toBeNull(); await expect(claim("not-selected")).resolves.toBeNull(); @@ -73,12 +73,24 @@ describe("sessionFeedbackSurveyState", () => { expect(claimCooldown).toHaveBeenCalledTimes(2); }); - it("does not prompt the same session after an appearance", async () => { + it("blocks remount presentation while the appeared owner can respond", async () => { const survey = await claim("appeared-once"); expect(survey).not.toBeNull(); if (!survey) throw new Error("expected survey to be selected"); markSessionFeedbackSurveyAppeared("appeared-once", survey.appearanceId); + expect( + isSessionFeedbackSurveyPresentable("appeared-once", survey.appearanceId), + ).toBe(false); + recordSessionFeedbackSurveyResponse( + "appeared-once", + survey.appearanceId, + "good", + ); + expect(sendEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ eventType: "responded", response: "good" }), + ); + await expect( claim("appeared-once", { messageId: "assistant-2", @@ -88,6 +100,44 @@ describe("sessionFeedbackSurveyState", () => { expect(claimCooldown).toHaveBeenCalledTimes(1); }); + it("preserves another renderer's winner when a losing claim settles later", async () => { + let resolveCooldown: ((selected: boolean) => void) | undefined; + claimCooldown.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCooldown = resolve; + }), + ); + + const losingClaim = claim("cross-renderer"); + await vi.waitFor(() => expect(claimCooldown).toHaveBeenCalledTimes(1)); + + const winner = { + appearanceId: "winning-appearance", + messageId: "assistant-from-winning-renderer", + }; + localStorage.setItem( + "berd:session-feedback-survey:v1:cross-renderer", + JSON.stringify({ + version: 1, + lastEvaluatedMessageId: winner.messageId, + active: winner, + appeared: false, + response: null, + }), + ); + resolveCooldown?.(false); + + await expect(losingClaim).resolves.toEqual(winner); + expect( + JSON.parse( + localStorage.getItem( + "berd:session-feedback-survey:v1:cross-renderer", + ) ?? "null", + ), + ).toMatchObject({ active: winner }); + }); + it("serializes duplicate claims for one session", async () => { let resolveCooldown: ((selected: boolean) => void) | undefined; claimCooldown.mockImplementationOnce( @@ -132,18 +182,16 @@ describe("sessionFeedbackSurveyState", () => { sessionId: "responded", surveyType: "session", eventType: "appeared", - eventSequence: 1, }), expect.objectContaining({ sessionId: "responded", surveyType: "session", eventType: "responded", response: "fine", - eventSequence: 2, }), ]); expect( - isSessionFeedbackSurveyActive("responded", survey.appearanceId), + isSessionFeedbackSurveyPresentable("responded", survey.appearanceId), ).toBe(false); }); }); diff --git a/src/features/chat/response-feedback/sessionFeedbackSurveyState.ts b/src/features/chat/response-feedback/sessionFeedbackSurveyState.ts index 2d7517371..64c3e0958 100644 --- a/src/features/chat/response-feedback/sessionFeedbackSurveyState.ts +++ b/src/features/chat/response-feedback/sessionFeedbackSurveyState.ts @@ -118,6 +118,10 @@ async function claimSessionFeedbackSurveyUnqueued({ }: SessionFeedbackSurveyClaimInput): Promise { let existing = readSessionRecord(sessionId); const createdAt = Date.parse(sessionCreatedAt); + if (existing?.appeared || existing?.response) { + return null; + } + if (existing?.active && currentMessageIds.has(existing.active.messageId)) { return existing.active; } @@ -125,9 +129,8 @@ async function claimSessionFeedbackSurveyUnqueued({ existing = { ...existing, active: null }; writeSessionRecord(sessionId, existing); } + if ( - existing?.appeared || - existing?.response || existing?.lastEvaluatedMessageId === messageId || userTurnCount < SESSION_SURVEY_MINIMUM_USER_TURNS || !Number.isFinite(createdAt) || @@ -136,17 +139,30 @@ async function claimSessionFeedbackSurveyUnqueued({ return null; } - const rate = Math.min(10_000, Math.max(0, samplingRateBasisPoints)); - if (rate === 0) return null; + const opportunityRateBasisPoints = Math.min( + 10_000, + Math.max(0, samplingRateBasisPoints), + ); + if (opportunityRateBasisPoints === 0) return null; const selected = await claimSessionFeedbackSurveyCooldown({ - samplingRateBasisPoints: rate, + samplingRateBasisPoints: opportunityRateBasisPoints, random, cooldownRandom, }).catch(() => false); + + const latest = readSessionRecord(sessionId); + if (latest?.appeared || latest?.response) { + return null; + } + if (latest?.active) { + return latest.active; + } + const active = selected ? { appearanceId: crypto.randomUUID(), messageId } : null; writeSessionRecord(sessionId, { + ...latest, version: 1, lastEvaluatedMessageId: messageId, active, @@ -177,11 +193,16 @@ export function claimSessionFeedbackSurvey( return claim; } -export function isSessionFeedbackSurveyActive( +export function isSessionFeedbackSurveyPresentable( sessionId: string, appearanceId: string, ): boolean { - return readSessionRecord(sessionId)?.active?.appearanceId === appearanceId; + const record = readSessionRecord(sessionId); + return ( + record?.active?.appearanceId === appearanceId && + !record.appeared && + !record.response + ); } export function markSessionFeedbackSurveyAppeared( diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 099230dc7..4ac10a0bb 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -225,7 +225,7 @@ export function ChatView({ const { fallbackCwd: terminalFallbackCwd } = useTerminalFallbackCwdPreference(); const capabilities = useProfileCapabilities(); - const sessionSurveySamplingRateBasisPoints = useRuntimeConfigStore( + const sessionSurveyOpportunityRateBasisPoints = useRuntimeConfigStore( (state) => state.config.feedback?.sessionSurveySamplingRateBasisPoints ?? 0, ); const pocketVoiceSetup = usePocketVoiceSetup(capabilities.voiceConversation); @@ -695,9 +695,9 @@ export function ChatView({ messages={controller.messages} sessionCreatedAt={effectiveSession?.createdAt} sessionSurveySamplingRateBasisPoints={ - isReadOnly || !capabilities.feedback + isReadOnly || !capabilities.feedbackSurveys ? 0 - : sessionSurveySamplingRateBasisPoints + : sessionSurveyOpportunityRateBasisPoints } streamingMessageId={controller.streamingMessageId} responsePending={shouldShowLoadingIndicator} diff --git a/src/features/chat/ui/VirtualMessageTimeline.tsx b/src/features/chat/ui/VirtualMessageTimeline.tsx index 0c824d3f8..e1ff6270d 100644 --- a/src/features/chat/ui/VirtualMessageTimeline.tsx +++ b/src/features/chat/ui/VirtualMessageTimeline.tsx @@ -275,6 +275,18 @@ interface LiveStreamingTailSplit { startIndex: number; } +function rowOwnsSessionFeedbackSurvey( + row: TranscriptRowDescriptor, + responseFeedbackRowIds: ReadonlySet, + survey: ActiveSessionFeedbackSurvey | null | undefined, +): boolean { + return Boolean( + survey && + responseFeedbackRowIds.has(row.rowId) && + (row.responseStartMessageId ?? row.messageId) === survey.messageId, + ); +} + function formatDateSeparator( snapshot: TranscriptProjectionSnapshot, rowIndex: number, @@ -1115,11 +1127,34 @@ function VirtualMessageTimelineSession({ sessionEpoch, ], ); - const stableRows = useStableTranscriptRows(snapshot.rows); + const projectedRows = useStableTranscriptRows(snapshot.rows); const responseFeedbackRowIds = useMemo( - () => selectResponseFeedbackRowIds(stableRows), - [stableRows], + () => selectResponseFeedbackRowIds(projectedRows), + [projectedRows], ); + const stableRows = useMemo(() => { + if (!sessionFeedbackSurvey) { + return projectedRows; + } + + return projectedRows.map((row) => + rowOwnsSessionFeedbackSurvey( + row, + responseFeedbackRowIds, + sessionFeedbackSurvey, + ) + ? { + ...row, + heightRevision: `${row.heightRevision}:session-survey:${sessionFeedbackSurvey.appearanceId}:${localeKey}`, + measurementPolicy: "measure-real" as const, + capabilities: { + ...row.capabilities, + canOffscreenRenderReal: true, + }, + } + : row, + ); + }, [localeKey, projectedRows, responseFeedbackRowIds, sessionFeedbackSurvey]); const [settlingAgentWorkMessageId, setSettlingAgentWorkMessageId] = useState< string | null >(null); @@ -3499,11 +3534,12 @@ function VirtualMessageTimelineSession({ ); const sessionFeedbackSurveyForRow = (row: TranscriptRowDescriptor) => - sessionFeedbackSurvey && - responseFeedbackRowIds.has(row.rowId) && - (row.responseStartMessageId ?? row.messageId) === - sessionFeedbackSurvey.messageId - ? sessionFeedbackSurvey + rowOwnsSessionFeedbackSurvey( + row, + responseFeedbackRowIds, + sessionFeedbackSurvey, + ) + ? (sessionFeedbackSurvey ?? undefined) : undefined; const renderRow = ( row: TranscriptRowDescriptor, diff --git a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx index 98ae3cac7..5b34991a5 100644 --- a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx +++ b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx @@ -16,6 +16,8 @@ import { import { TERMINAL_FALLBACK_CWD_STORAGE_KEY } from "@/features/terminal/lib/terminalCwdPreference"; import type { ChatSession } from "../../stores/chatSessionStore"; import { useSecurityConfirmationStore } from "@/features/security/stores/securityConfirmationStore"; +import { DEFAULT_RUNTIME_CONFIG } from "@/shared/runtime-config/schema"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; import { ChatView } from "../ChatView"; const mocks = vi.hoisted(() => ({ @@ -23,6 +25,7 @@ const mocks = vi.hoisted(() => ({ chatInputSpy: vi.fn(), chatRightRailSpy: vi.fn(), voiceControllerSpy: vi.fn(), + sessionFeedbackSurveyHook: vi.fn((_options: unknown) => null), setRightRailOpen: vi.fn(), patchSession: vi.fn(), handleSend: vi.fn(() => true), @@ -113,6 +116,11 @@ vi.mock("@/shared/artifacts/useResolvedArtifactRoot", () => ({ useResolvedArtifactRoot: () => null, })); +vi.mock("@/features/chat/response-feedback/useSessionFeedbackSurvey", () => ({ + useSessionFeedbackSurvey: (options: unknown) => + mocks.sessionFeedbackSurveyHook(options), +})); + // Deterministic find-shortcut modifier across dev machines and CI. vi.mock("@/shared/lib/platform", () => ({ getPlatform: () => "linux", @@ -392,6 +400,7 @@ function chatSessionWithWorkingDir(workingDir: string): ChatSession { describe("ChatView MCP app messaging", () => { afterEach(() => { act(() => cleanup()); + vi.unstubAllEnvs(); }); beforeEach(() => { @@ -399,6 +408,7 @@ describe("ChatView MCP app messaging", () => { mocks.chatInputSpy.mockClear(); mocks.chatRightRailSpy.mockClear(); mocks.voiceControllerSpy.mockClear(); + mocks.sessionFeedbackSurveyHook.mockClear(); mocks.setRightRailOpen.mockClear(); mocks.patchSession.mockClear(); mocks.handleSend.mockClear(); @@ -418,6 +428,7 @@ describe("ChatView MCP app messaging", () => { mountedSurfaceCountBySessionId: {}, }); window.localStorage.clear(); + useRuntimeConfigStore.setState({ config: DEFAULT_RUNTIME_CONFIG }); mockMatchMedia(false); mocks.useChatSessionController.mockReturnValue({ messages: [ @@ -511,6 +522,26 @@ describe("ChatView MCP app messaging", () => { }); }); + it("gates session surveys through the dedicated build capability", () => { + vi.stubEnv("VITE_FEEDBACK", "0"); + vi.stubEnv("VITE_FEEDBACK_SURVEYS", "1"); + useRuntimeConfigStore.setState({ + config: { + ...DEFAULT_RUNTIME_CONFIG, + feedback: { + ...DEFAULT_RUNTIME_CONFIG.feedback, + sessionSurveySamplingRateBasisPoints: 250, + }, + }, + }); + + render(); + + expect(mocks.sessionFeedbackSurveyHook).toHaveBeenLastCalledWith( + expect.objectContaining({ samplingRateBasisPoints: 250 }), + ); + }); + it("keeps full chat automatic and passes the complete transcript", () => { const completeMessages = Array.from({ length: 12 }, (_, index) => ({ id: `user-${index + 1}`, diff --git a/src/features/chat/ui/__tests__/VirtualMessageTimeline.test.tsx b/src/features/chat/ui/__tests__/VirtualMessageTimeline.test.tsx index 5b32945f0..c766e6ab5 100644 --- a/src/features/chat/ui/__tests__/VirtualMessageTimeline.test.tsx +++ b/src/features/chat/ui/__tests__/VirtualMessageTimeline.test.tsx @@ -63,6 +63,7 @@ vi.mock("../MessageBubble", async () => { fragmentRole, actionsAlwaysVisible, showJumpToResponseStartHint, + sessionFeedbackSurvey, onEditProject, onRunShellCommand, }: { @@ -72,6 +73,10 @@ vi.mock("../MessageBubble", async () => { fragmentRole?: string; actionsAlwaysVisible?: boolean; showJumpToResponseStartHint?: boolean; + sessionFeedbackSurvey?: { + appearanceId: string; + messageId: string; + }; onEditProject?: (projectId: string) => void; onRunShellCommand?: ( command: string, @@ -96,7 +101,9 @@ vi.mock("../MessageBubble", async () => { data-response-start-hint={ showJumpToResponseStartHint ? "true" : "false" } - data-mock-row-height={heightMatch?.[1] ?? "144"} + data-mock-row-height={ + sessionFeedbackSurvey ? "240" : (heightMatch?.[1] ?? "144") + } tabIndex={-1} {...rowRootAttributes} {...(isPending @@ -2561,6 +2568,83 @@ describe("VirtualMessageTimeline", () => { ); }); + it("real-measures an offscreen row before mounting its localized survey", async () => { + mockTranscriptElementMeasurements(); + const messages = Array.from({ length: 80 }, (_, index) => + textMessage( + `message-${index}`, + index % 2 === 0 ? "user" : "assistant", + `Message ${index}`, + ), + ); + const { rerender } = renderWithProviders( + , + ); + const list = screen.getByTestId("virtual-message-timeline-list"); + await waitFor(() => + expect(list).toHaveAttribute( + "data-virtual-render-mode", + "bounded-controller", + ), + ); + + const shellRows = screen + .getByTestId("virtual-offscreen-measurement-host") + .querySelectorAll( + "[data-virtual-row-offscreen-shell-id^='message:message-']", + ); + const targetShellRow = [...shellRows].reverse().find((row) => { + const messageId = row + .getAttribute("data-virtual-row-offscreen-shell-id") + ?.replace("message:", ""); + const index = Number(messageId?.replace("message-", "")); + return index % 2 === 1; + }); + expect(targetShellRow).toBeDefined(); + const targetMessageId = targetShellRow + ?.getAttribute("data-virtual-row-offscreen-shell-id") + ?.replace("message:", ""); + const initialHeightRevision = targetShellRow?.getAttribute( + "data-virtual-row-height-revision", + ); + expect(targetMessageId).toBeTruthy(); + + rerender( + , + ); + + const realRow = await waitFor(() => { + const row = screen + .getByTestId("virtual-offscreen-real-measurement-host") + .querySelector( + `[data-virtual-row-offscreen-real-id='message:${targetMessageId}']`, + ); + expect(row).not.toBeNull(); + return row as HTMLElement; + }); + expect(realRow).toHaveAttribute( + "data-virtual-row-measurement-policy", + "measure-real", + ); + expect(realRow.getAttribute("data-virtual-row-height-revision")).not.toBe( + initialHeightRevision, + ); + expect(realRow.getAttribute("data-virtual-row-height-revision")).toContain( + "session-survey:appearance-localized:en", + ); + expect(realRow.querySelector("[data-mock-row-height]"))?.toHaveAttribute( + "data-mock-row-height", + "240", + ); + }); + it("inspects a blank viewport after ordinary scroll ownership expires without a range change", async () => { let now = 0; let realRowsOffscreen = false; diff --git a/src/shared/runtime-config/schema.ts b/src/shared/runtime-config/schema.ts index 3efdd7585..92c87f82e 100644 --- a/src/shared/runtime-config/schema.ts +++ b/src/shared/runtime-config/schema.ts @@ -262,6 +262,8 @@ export const runtimeFeedbackConfigSchema = z enabled: z.boolean().optional(), projectKey: nonEmptyString("feedback projectKey").optional(), responseRatingEnabled: z.boolean().optional(), + // This is a per-eligible-completion opportunity hazard, not a percentage + // allocation of sessions. sessionSurveySamplingRateBasisPoints: z .number() .int()