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();