diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index cba879f053..0adddc6383 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -169,6 +169,9 @@ type Props = { }, options?: { fromKeybinding?: boolean; }) => Promise; + // Optional inline startup UI can consume Enter before prompt suggestions + // or the normal empty-input guard run. Returning true prevents a work turn. + onBeforeSubmit?: (input: string) => boolean; onAgentSubmit?: (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => Promise; isSearchingHistory: boolean; setIsSearchingHistory: (isSearching: boolean) => void; @@ -224,6 +227,7 @@ function PromptInput({ onExit, getToolUseContext, onSubmit: onSubmitProp, + onBeforeSubmit, onAgentSubmit, isSearchingHistory, setIsSearchingHistory, @@ -1010,9 +1014,18 @@ function PromptInput({ return; } - // Check for images early - we need this for suggestion logic below + // Check for images before optional startup UI interception: an image-only + // prompt is work and must always pass through untouched. const hasImages = Object.values(pastedContents).some(c => c.type === 'image'); + if (!hasImages && onBeforeSubmit?.(inputParam)) { + trackAndSetInput(''); + setCursorOffset(0); + clearBuffer(); + resetHistory(); + return; + } + // If input is empty OR matches the suggestion, submit it // But if there are images attached, don't auto-accept the suggestion - // the user wants to submit just the image(s). @@ -1111,7 +1124,7 @@ function PromptInput({ clearBuffer, resetHistory }); - }, [promptSuggestionState, speculation, speculationSessionTimeSavedMs, teamContext, store, footerItems, suggestionsState.suggestions, onSubmitProp, onAgentSubmit, clearBuffer, resetHistory, logOutcomeAtSubmission, setAppState, markAccepted, pastedContents, removeNotification]); + }, [promptSuggestionState, speculation, speculationSessionTimeSavedMs, teamContext, store, footerItems, suggestionsState.suggestions, onSubmitProp, onBeforeSubmit, onAgentSubmit, clearBuffer, resetHistory, logOutcomeAtSubmission, setAppState, markAccepted, pastedContents, removeNotification, trackAndSetInput]); const { suggestions, selectedSuggestion, diff --git a/src/components/VerbooFeedback/VerbooStartupFeedback.tsx b/src/components/VerbooFeedback/VerbooStartupFeedback.tsx new file mode 100644 index 0000000000..48f316fb69 --- /dev/null +++ b/src/components/VerbooFeedback/VerbooStartupFeedback.tsx @@ -0,0 +1,53 @@ +import React, { useCallback } from 'react' + +import { Box, Text } from '../../ink.js' +import { useDebouncedDigitInput } from '../FeedbackSurvey/useDebouncedDigitInput.js' +import type { VerbooStartupFeedbackState } from './useVerbooStartupFeedback.js' + +type Props = Pick & { + inputValue: string + setInputValue: (value: string) => void + locale: 'pt' | 'en' +} + +export function VerbooStartupFeedback({ offer, questionIndex, selectedOptionIds, thanks, handleDigit, inputValue, setInputValue, locale }: Props) { + const question = offer?.questions[questionIndex] + const copy = locale === 'en' + ? { + thanks: 'Thanks for your feedback!', + skip: 'Skip', + multiple: (min: number, max: number) => `Choose ${min} to ${max} options and press Enter to confirm. `, + optional: 'Optional · start typing normally to continue your work.', + } + : { + thanks: 'Obrigado pelo feedback!', + skip: 'Pular', + multiple: (min: number, max: number) => `Escolha de ${min} a ${max} opções e pressione Enter para confirmar. `, + optional: 'Opcional · comece a digitar normalmente para continuar o trabalho.', + } + const isValidDigit = useCallback((digit: string): digit is string => { + if (!question || !/^\d$/.test(digit)) return false + return digit === '0' || question.options.some(option => option.position === Number(digit)) + }, [question]) + + useDebouncedDigitInput({ inputValue, setInputValue, isValidDigit, onDigit: handleDigit, enabled: Boolean(question) }) + + if (thanks) return ✓ {copy.thanks} + if (!offer || !question) return null + + return ( + + {offer.title} + {offer.intro ? {offer.intro} : null} + {question.text} ({questionIndex + 1}/{offer.questions.length}) + + {question.options.map(option => { + const selected = selectedOptionIds.has(option.id) + return [{option.position}] {question.type === 'multiple_choice' ? {selected ? '◉' : '○'} : null}{option.label} + })} + [0] {copy.skip} + + {question.type === 'multiple_choice' ? copy.multiple(question.minSelections, question.maxSelections) : ''}{copy.optional} + + ) +} diff --git a/src/components/VerbooFeedback/useVerbooStartupFeedback.test.ts b/src/components/VerbooFeedback/useVerbooStartupFeedback.test.ts new file mode 100644 index 0000000000..fb6d285c75 --- /dev/null +++ b/src/components/VerbooFeedback/useVerbooStartupFeedback.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test' + +import { isStartupFeedbackEligible, type StartupFeedbackEligibility } from './useVerbooStartupFeedback.js' + +const eligible: StartupFeedbackEligibility = { + enabled: true, + stdinTTY: true, + stdoutTTY: true, + entrypoint: 'cli', + isRemoteSession: false, + hasInitialPrompt: false, + isBare: false, + isCI: false, + telemetryDisabled: false, + policyAllowed: true, + inheritedSurveyDisabled: false, +} + +describe('startup feedback eligibility', () => { + test('allows only a normal interactive CLI startup', () => { + expect(isStartupFeedbackEligible(eligible)).toBe(true) + }) + + test.each([ + ['stdinTTY', false], + ['stdoutTTY', false], + ['entrypoint', 'sdk-cli'], + ['isRemoteSession', true], + ['hasInitialPrompt', true], + ['isBare', true], + ['isCI', true], + ['telemetryDisabled', true], + ['policyAllowed', false], + ['inheritedSurveyDisabled', true], + ] as const)('rejects %s=%s', (key, value) => { + expect(isStartupFeedbackEligible({ ...eligible, [key]: value })).toBe(false) + }) +}) diff --git a/src/components/VerbooFeedback/useVerbooStartupFeedback.ts b/src/components/VerbooFeedback/useVerbooStartupFeedback.ts new file mode 100644 index 0000000000..d9b6dc5f56 --- /dev/null +++ b/src/components/VerbooFeedback/useVerbooStartupFeedback.ts @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { + fetchNextVerbooFeedback, + finalizeVerbooFeedback, + flushVerbooFeedbackOutbox, + hasVerbooFeedbackReceipt, + markVerbooFeedbackViewed, + type VerbooFeedbackAnswer, + type VerbooFeedbackOffer, +} from '../../services/api/verbooFeedback.js' +import { isPolicyAllowed, waitForPolicyLimitsToLoad } from '../../services/policyLimits/index.js' +import { isBareMode, isEnvTruthy } from '../../utils/envUtils.js' +import { isTelemetryDisabled } from '../../utils/privacyLevel.js' + +export type StartupFeedbackEligibility = { + enabled: boolean + stdinTTY: boolean + stdoutTTY: boolean + entrypoint?: string + isRemoteSession: boolean + hasInitialPrompt: boolean + isBare: boolean + isCI: boolean + telemetryDisabled: boolean + policyAllowed: boolean + inheritedSurveyDisabled: boolean +} + +export function isStartupFeedbackEligible(options: StartupFeedbackEligibility): boolean { + return options.enabled && options.stdinTTY && options.stdoutTTY && options.entrypoint === 'cli' && !options.isRemoteSession && !options.hasInitialPrompt && !options.isBare && !options.isCI && !options.telemetryDisabled && options.policyAllowed && !options.inheritedSurveyDisabled +} + +type Props = { + enabled: boolean + isRemoteSession: boolean + hasInitialPrompt: boolean + inputValue: string + setInputValue: (value: string) => void + submitCount: number + locale: 'pt' | 'en' + modelId: string + provider: string +} + +export type VerbooStartupFeedbackState = { + offer: VerbooFeedbackOffer | null + questionIndex: number + selectedOptionIds: ReadonlySet + thanks: boolean + handleDigit: (digit: string) => void + handleSubmit: (input: string) => boolean + dismissForWork: () => void +} + +export function useVerbooStartupFeedback({ + enabled, + isRemoteSession, + hasInitialPrompt, + inputValue, + setInputValue, + submitCount, + locale, + modelId, + provider, +}: Props): VerbooStartupFeedbackState { + const [offer, setOffer] = useState(null) + const [questionIndex, setQuestionIndex] = useState(0) + const [answers, setAnswers] = useState([]) + const [selectedOptionIds, setSelectedOptionIds] = useState>(new Set()) + const [thanks, setThanks] = useState(false) + const initialInput = useRef(inputValue) + const initialSubmitCount = useRef(submitCount) + const inputRef = useRef(inputValue) + const submitCountRef = useRef(submitCount) + const offerRef = useRef(null) + const userActedRef = useRef(inputValue !== '' || submitCount > 0) + const finalizingRef = useRef(false) + const requestStartedRef = useRef(false) + inputRef.current = inputValue + submitCountRef.current = submitCount + offerRef.current = offer + + const eligibleAtMount = useRef(isStartupFeedbackEligible({ + enabled, + stdinTTY: process.stdin.isTTY === true, + stdoutTTY: process.stdout.isTTY === true, + entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, + isRemoteSession, + hasInitialPrompt, + isBare: isBareMode(), + isCI: isEnvTruthy(process.env.CI), + telemetryDisabled: isTelemetryDisabled(), + policyAllowed: isPolicyAllowed('allow_product_feedback'), + inheritedSurveyDisabled: isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY), + })).current + + useEffect(() => { + if (!eligibleAtMount || requestStartedRef.current) return + requestStartedRef.current = true + let cancelled = false + void (async () => { + await waitForPolicyLimitsToLoad() + if (cancelled || isTelemetryDisabled() || !isPolicyAllowed('allow_product_feedback')) return + await flushVerbooFeedbackOutbox() + if (cancelled) return + try { + const next = await fetchNextVerbooFeedback({ + locale, + modelId, + provider: provider.toLowerCase(), + cliVersion: MACRO.VERSION, + platform: process.platform, + architecture: process.arch, + }) + if (!next || cancelled) return + if (hasVerbooFeedbackReceipt(next.campaignId)) { + void finalizeVerbooFeedback(next.campaignId, next.deliveryId, 'skip') + return + } + if (userActedRef.current || inputRef.current !== initialInput.current || submitCountRef.current !== initialSubmitCount.current) { + void finalizeVerbooFeedback(next.campaignId, next.deliveryId, 'skip') + return + } + setOffer(next) + offerRef.current = next + void markVerbooFeedbackViewed(next.deliveryId) + } catch { + // Feedback is optional. Startup and normal work continue silently. + } + })() + return () => { cancelled = true } + }, [eligibleAtMount, locale, modelId, provider]) + + const skip = useCallback(() => { + const current = offerRef.current + if (!current || finalizingRef.current) return + finalizingRef.current = true + setOffer(null) + offerRef.current = null + void finalizeVerbooFeedback(current.campaignId, current.deliveryId, 'skip') + }, []) + + const dismissForWork = useCallback(() => { + userActedRef.current = true + skip() + }, [skip]) + + const finishQuestion = useCallback((optionIds: string[]) => { + const currentOffer = offerRef.current + if (!currentOffer || finalizingRef.current) return + const question = currentOffer.questions[questionIndex] + if (!question) return + const nextAnswers = [...answers, { questionId: question.id, optionIds }] + if (questionIndex < currentOffer.questions.length - 1) { + setAnswers(nextAnswers) + setQuestionIndex(index => index + 1) + setSelectedOptionIds(new Set()) + return + } + finalizingRef.current = true + setOffer(null) + offerRef.current = null + setThanks(true) + void finalizeVerbooFeedback(currentOffer.campaignId, currentOffer.deliveryId, 'response', nextAnswers) + }, [answers, questionIndex]) + + const handleDigit = useCallback((digit: string) => { + const currentOffer = offerRef.current + const question = currentOffer?.questions[questionIndex] + if (!currentOffer || !question || finalizingRef.current) return + if (digit === '0') { + skip() + return + } + const position = Number(digit) + const option = question.options.find(item => item.position === position) + if (!option) return + if (question.type === 'single_choice') { + finishQuestion([option.id]) + return + } + setSelectedOptionIds(current => { + const next = new Set(current) + if (next.has(option.id)) next.delete(option.id) + else if (next.size < question.maxSelections) next.add(option.id) + return next + }) + }, [finishQuestion, questionIndex, skip]) + + const question = offer?.questions[questionIndex] + const validDigit = useCallback((input: string) => { + if (!question || !/^\d$/.test(input)) return false + if (input === '0') return true + return question.options.some(option => option.position === Number(input)) + }, [question]) + + useEffect(() => { + if (submitCount === initialSubmitCount.current) return + userActedRef.current = true + if (offerRef.current) dismissForWork() + }, [dismissForWork, submitCount]) + + useEffect(() => { + if (inputValue === initialInput.current || inputValue === '') return + if (offerRef.current && validDigit(inputValue)) return + userActedRef.current = true + if (offerRef.current) dismissForWork() + }, [dismissForWork, inputValue, validDigit]) + + useEffect(() => { + if (!thanks) return + const timer = setTimeout(setThanks, 2_000, false) + return () => clearTimeout(timer) + }, [thanks]) + + const handleSubmit = useCallback((input: string): boolean => { + const currentOffer = offerRef.current + const currentQuestion = currentOffer?.questions[questionIndex] + if (!currentOffer || !currentQuestion) return false + const trimmed = input.trim() + if (validDigit(trimmed)) { + setInputValue('') + handleDigit(trimmed) + return true + } + if (currentQuestion.type === 'multiple_choice' && trimmed === '') { + if (selectedOptionIds.size >= currentQuestion.minSelections && selectedOptionIds.size <= currentQuestion.maxSelections) { + finishQuestion([...selectedOptionIds]) + return true + } + // With no survey selection, Enter belongs to the normal prompt (for + // example accepting a suggestion). An incomplete selection stays open. + if (selectedOptionIds.size === 0) { + dismissForWork() + return false + } + return true + } + dismissForWork() + return false + }, [dismissForWork, finishQuestion, handleDigit, questionIndex, selectedOptionIds, setInputValue, validDigit]) + + return useMemo(() => ({ offer, questionIndex, selectedOptionIds, thanks, handleDigit, handleSubmit, dismissForWork }), [dismissForWork, handleDigit, handleSubmit, offer, questionIndex, selectedOptionIds, thanks]) +} diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 4975edc8d0..8d1680fd23 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -64,8 +64,6 @@ import type { DirectConnectConfig } from '../server/directConnectManager.js'; import { useSSHSession } from '../hooks/useSSHSession.js'; import { useAssistantHistory } from '../hooks/useAssistantHistory.js'; import type { SSHSession } from '../ssh/createSSHSession.js'; -import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js'; -import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js'; import { useMoreRight } from '../moreright/useMoreRight.js'; import { SpinnerWithVerb, BriefIdleStatus, type SpinnerMode } from '../components/Spinner.js'; import { getSystemPrompt } from '../constants/prompts.js'; @@ -102,13 +100,6 @@ const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useV resetAnchor: () => { } }); const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler : () => null; -// Frustration detection is internal-only (dogfooding). Conditional require so external -// builds eliminate the module entirely (including its two O(n) useMemos that run -// on every messages change, plus the GrowthBook fetch). -const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = "external" === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({ - state: 'closed', - handleTranscriptSelect: () => { } -}); // Ant-only org warning. Conditional require so the org UUID list is // eliminated from external builds (one UUID is on excluded-strings). const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = "external" === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => { }; @@ -230,10 +221,10 @@ const UndercoverAutoCallout = "external" === 'ant' ? require('../components/Unde import { activityManager } from '../utils/activityManager.js'; import { createAbortController } from '../utils/abortController.js'; import { MCPConnectionManager } from 'src/services/mcp/MCPConnectionManager.js'; -import { useFeedbackSurvey } from 'src/components/FeedbackSurvey/useFeedbackSurvey.js'; -import { useMemorySurvey } from 'src/components/FeedbackSurvey/useMemorySurvey.js'; -import { usePostCompactSurvey } from 'src/components/FeedbackSurvey/usePostCompactSurvey.js'; -import { FeedbackSurvey } from 'src/components/FeedbackSurvey/FeedbackSurvey.js'; +import { VerbooStartupFeedback } from 'src/components/VerbooFeedback/VerbooStartupFeedback.js'; +import { useVerbooStartupFeedback } from 'src/components/VerbooFeedback/useVerbooStartupFeedback.js'; +import { getPreferredTermsLocale } from 'src/services/oauth/verbooTerms.js'; +import { getActiveModelIdentity } from 'src/utils/model/activeModelIdentity.js'; import { useInstallMessages } from 'src/hooks/notifs/useInstallMessages.js'; import { useAwaySummary } from 'src/hooks/useAwaySummary.js'; import { useChromeExtensionNotification } from 'src/hooks/useChromeExtensionNotification.js'; @@ -272,7 +263,6 @@ import { useModelMigrationNotifications } from 'src/hooks/notifs/useModelMigrati import { useCanSwitchToExistingSubscription } from 'src/hooks/notifs/useCanSwitchToExistingSubscription.js'; import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateShutdownNotification.js'; import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js'; -import { AutoRunIssueNotification, shouldAutoRunIssue, getAutoRunIssueReasonText, getAutoRunCommand, type AutoRunIssueReason } from '../utils/autoRunIssue.js'; import type { HookProgress } from '../types/hooks.js'; import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js'; /* eslint-disable @typescript-eslint/no-require-imports */ @@ -1438,6 +1428,19 @@ export function REPL({ const activeRemote = sshRemote.isRemoteMode ? sshRemote : directConnect.isRemoteMode ? directConnect : remoteSession; const [pastedContents, setPastedContents] = useState>({}); const [submitCount, setSubmitCount] = useState(0); + const startupFeedbackModel = useMemo(() => getActiveModelIdentity(mainLoopModel), [mainLoopModel]); + const startupFeedbackLocale = getPreferredTermsLocale(); + const verbooStartupFeedback = useVerbooStartupFeedback({ + enabled: !disabled && !directConnectConfig && !sshSession && !taskListId, + isRemoteSession, + hasInitialPrompt: Boolean(initialMessage), + inputValue, + setInputValue, + submitCount, + locale: startupFeedbackLocale, + modelId: startupFeedbackModel.model, + provider: startupFeedbackModel.provider + }); // Defer startup checks until the user has submitted their first message. // A timeout or grace period is insufficient (issue #363): if the user pauses @@ -1776,42 +1779,8 @@ export function REPL({ // but keep it when isBriefOnly suppresses the streaming text display !visibleStreamingText || isBriefOnly); - // Check if any permission or ask question prompt is currently visible - // This is used to prevent the survey from opening while prompts are active - const hasActivePrompt = toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || sandboxPermissionRequestQueue.length > 0 || elicitation.queue.length > 0 || workerSandboxPermissions.queue.length > 0; - const feedbackSurveyOriginal = useFeedbackSurvey(messages, isLoading, submitCount, 'session', hasActivePrompt); - const skillImprovementSurvey = useSkillImprovementSurvey(setMessages); const showIssueFlagBanner = useIssueFlagBanner(messages, submitCount); - // Wrap feedback survey handler to trigger auto-run /issue - const feedbackSurvey = useMemo(() => ({ - ...feedbackSurveyOriginal, - handleSelect: (selected: 'dismissed' | 'bad' | 'fine' | 'good') => { - // Reset the ref when a new survey response comes in - didAutoRunIssueRef.current = false; - const showedTranscriptPrompt = feedbackSurveyOriginal.handleSelect(selected); - // Auto-run /issue for "bad" if transcript prompt wasn't shown - if (selected === 'bad' && !showedTranscriptPrompt && shouldAutoRunIssue('feedback_survey_bad')) { - setAutoRunIssueReason('feedback_survey_bad'); - didAutoRunIssueRef.current = true; - } - } - }), [feedbackSurveyOriginal]); - - // Post-compact survey: shown after compaction if feature gate is enabled - const postCompactSurvey = usePostCompactSurvey(messages, isLoading, hasActivePrompt, { - enabled: !isRemoteSession - }); - - // Memory survey: shown when the assistant mentions memory and a memory file - // was read this conversation - const memorySurvey = useMemorySurvey(messages, isLoading, hasActivePrompt, { - enabled: !isRemoteSession - }); - - // Frustration detection: show transcript sharing prompt after detecting frustrated messages - const frustrationDetection = useFrustrationDetection(messages, isLoading, hasActivePrompt, feedbackSurvey.state !== 'closed' || postCompactSurvey.state !== 'closed' || memorySurvey.state !== 'closed'); - // Initialize IDE integration useIDEIntegration({ autoConnectIdeFlag, @@ -2096,13 +2065,6 @@ export function REPL({ reverify } = useApiKeyVerification(); - // Auto-run /issue state - const [autoRunIssueReason, setAutoRunIssueReason] = useState(null); - // Ref to track if autoRunIssue was triggered this survey cycle, - // so we can suppress the [1] follow-up prompt even after - // autoRunIssueReason is cleared. - const didAutoRunIssueRef = useRef(false); - // State for exit feedback flow const [exitFlow, setExitFlow] = useState(null); const [isExiting, setIsExiting] = useState(false); @@ -3745,22 +3707,6 @@ export function REPL({ helpers.clearBuffer(); }, [setAppState, setInputValue, getToolUseContext, canUseTool, mainLoopModel, addNotification]); - // Handlers for auto-run /issue or /good-claude (defined after onSubmit) - const handleAutoRunIssue = useCallback(() => { - const command = autoRunIssueReason ? getAutoRunCommand(autoRunIssueReason) : '/issue'; - setAutoRunIssueReason(null); // Clear the state - onSubmit(command, { - setCursorOffset: () => { }, - clearBuffer: () => { }, - resetHistory: () => { } - }).catch(err => { - logForDebugging(`Auto-run ${command} failed: ${errorMessage(err)}`); - }); - }, [onSubmit, autoRunIssueReason]); - const handleCancelAutoRunIssue = useCallback(() => { - setAutoRunIssueReason(null); - }, []); - // onSubmit is unstable (deps include `messages` which changes every turn). // `handleOpenRateLimitOptions` is prop-drilled to every MessageRow, and each // MessageRow fiber pins the closure (and transitively the entire REPL render @@ -5040,15 +4986,10 @@ export function REPL({ {mrRender()} {!toolJSX?.shouldHidePromptInput && !focusedInputDialog && !isExiting && !disabled && !cursor && !isShuttingDown() && <> - {autoRunIssueReason && } - {postCompactSurvey.state !== 'closed' ? : memorySurvey.state !== 'closed' ? : } - {/* Frustration-triggered transcript sharing prompt */} - {frustrationDetection.state !== 'closed' && { }} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />} - {/* Skill improvement survey - appears when improvements detected (internal-only) */} - {"external" === 'ant' && skillImprovementSurvey.suggestion && } + {showIssueFlagBanner && } { } - diff --git a/src/services/api/verbooFeedback.ts b/src/services/api/verbooFeedback.ts new file mode 100644 index 0000000000..4744c3e15b --- /dev/null +++ b/src/services/api/verbooFeedback.ts @@ -0,0 +1,171 @@ +import axios from 'axios' +import { randomUUID } from 'crypto' +import { z } from 'zod' + +import { getOauthConfig } from '../../constants/oauth.js' +import { getClaudeAIOAuthTokensAsync } from '../../utils/auth.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { logForDebugging } from '../../utils/debug.js' +import { withOAuth401Retry } from '../../utils/http.js' +import { parseApiEnvelope } from './verbooApiError.js' + +const optionSchema = z.object({ + id: z.string().uuid(), + position: z.number().int().min(1).max(9), + label: z.string().min(1).max(300), +}) + +const questionSchema = z.object({ + id: z.string().uuid(), + position: z.number().int().min(1).max(5), + type: z.enum(['single_choice', 'multiple_choice']), + text: z.string().min(1).max(500), + minSelections: z.number().int().min(1).max(9), + maxSelections: z.number().int().min(1).max(9), + options: z.array(optionSchema).min(2).max(9), +}) + +const offerSchema = z.object({ + deliveryId: z.string().uuid(), + campaignId: z.string().uuid(), + title: z.string().min(1).max(200), + intro: z.string().max(1000), + locale: z.enum(['pt', 'en']), + questions: z.array(questionSchema).min(1).max(5), +}) + +export type VerbooFeedbackOffer = z.infer +export type VerbooFeedbackAnswer = { questionId: string; optionIds: string[] } + +type OutboxEntry = NonNullable['verbooFeedback']>['outbox']>[number] + +async function accessToken(): Promise { + const tokens = await getClaudeAIOAuthTokensAsync() + if (!tokens?.accessToken) throw new Error('Verboo feedback requires OAuth') + return tokens.accessToken +} + +function headers(token: string) { + return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } +} + +export async function fetchNextVerbooFeedback(context: { + locale: 'pt' | 'en' + modelId: string + provider: string + cliVersion: string + platform: string + architecture: string +}): Promise { + return withOAuth401Retry(async () => { + const token = await accessToken() + const response = await axios.post( + `${getOauthConfig().BASE_API_URL}/api/me/feedback/next`, + context, + { headers: headers(token), timeout: 5_000 }, + ) + if (response.status === 204) return null + return parseApiEnvelope(offerSchema, response.data, 'feedback') + }) +} + +async function sendMutation(entry: Pick): Promise { + await withOAuth401Retry(async () => { + const token = await accessToken() + const suffix = entry.action === 'response' ? 'response' : 'skip' + await axios.post( + `${getOauthConfig().BASE_API_URL}/api/me/feedback/deliveries/${entry.deliveryId}/${suffix}`, + entry.action === 'response' ? { answers: entry.answers ?? [] } : undefined, + { headers: headers(token), timeout: 5_000 }, + ) + }) +} + +export async function markVerbooFeedbackViewed(deliveryId: string): Promise { + try { + await withOAuth401Retry(async () => { + const token = await accessToken() + await axios.post( + `${getOauthConfig().BASE_API_URL}/api/me/feedback/deliveries/${deliveryId}/view`, + undefined, + { headers: headers(token), timeout: 5_000 }, + ) + }) + } catch { + // Viewing is an aggregate hint; final skip/response mutations are durable. + } +} + +function remember(campaignId: string, deliveryId: string, status: 'skipped' | 'completed') { + saveGlobalConfig(current => { + const feedback = current.verbooFeedback ?? {} + const receipts = [ + ...(feedback.receipts ?? []).filter(receipt => receipt.campaignId !== campaignId), + { campaignId, deliveryId, status, recordedAt: Date.now() }, + ].slice(-100) + return { ...current, verbooFeedback: { ...feedback, receipts } } + }) +} + +function enqueue(entry: Omit): OutboxEntry { + const queuedEntry: OutboxEntry = { ...entry, id: randomUUID(), attempts: 0, createdAt: Date.now() } + saveGlobalConfig(current => { + const feedback = current.verbooFeedback ?? {} + const outbox = [ + ...(feedback.outbox ?? []).filter(item => !(item.deliveryId === entry.deliveryId && item.action === entry.action)), + queuedEntry, + ].slice(-20) + return { ...current, verbooFeedback: { ...feedback, outbox } } + }) + return queuedEntry +} + +function removeFromOutbox(id: string) { + saveGlobalConfig(current => { + const feedback = current.verbooFeedback ?? {} + return { ...current, verbooFeedback: { ...feedback, outbox: (feedback.outbox ?? []).filter(item => item.id !== id) } } + }) +} + +export async function finalizeVerbooFeedback( + campaignId: string, + deliveryId: string, + action: 'skip' | 'response', + answers?: VerbooFeedbackAnswer[], +): Promise { + remember(campaignId, deliveryId, action === 'response' ? 'completed' : 'skipped') + // Persist before attempting the request so an immediate process exit cannot + // lose a response. The backend mutation is idempotent, making retries safe. + const entry = enqueue({ campaignId, deliveryId, action, answers }) + try { + await sendMutation(entry) + removeFromOutbox(entry.id) + } catch { + logForDebugging('[Verboo feedback] final mutation queued for retry') + } +} + +export function hasVerbooFeedbackReceipt(campaignId: string): boolean { + return getGlobalConfig().verbooFeedback?.receipts?.some(receipt => receipt.campaignId === campaignId) ?? false +} + +export async function flushVerbooFeedbackOutbox(): Promise { + const entries = getGlobalConfig().verbooFeedback?.outbox ?? [] + for (const entry of entries) { + try { + await sendMutation(entry) + removeFromOutbox(entry.id) + } catch { + saveGlobalConfig(current => { + const feedback = current.verbooFeedback ?? {} + const outbox = (feedback.outbox ?? []).flatMap(item => { + if (item.id !== entry.id) return [item] + if (item.attempts >= 4) return [] + return [{ ...item, attempts: item.attempts + 1 }] + }) + return { ...current, verbooFeedback: { ...feedback, outbox } } + }) + logForDebugging('[Verboo feedback] final mutation remains queued') + } + } +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 01990acca2..8651f1488d 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -306,6 +306,26 @@ export type GlobalConfig = { // Transcript share prompt tracking ("Don't ask again") transcriptShareDismissed?: boolean + // Optional startup feedback. Contains only campaign/delivery identifiers and + // selected option identifiers; never prompts, code, paths, or transcripts. + verbooFeedback?: { + receipts?: Array<{ + campaignId: string + deliveryId: string + status: 'skipped' | 'completed' + recordedAt: number + }> + outbox?: Array<{ + id: string + deliveryId: string + campaignId: string + action: 'skip' | 'response' + answers?: Array<{ questionId: string; optionIds: string[] }> + attempts: number + createdAt: number + }> + } + // Memory usage tracking memoryUsageCount: number // Number of times user has added to memory