diff --git a/dembrane/frontend/src/components/analysis/hooks/index.test.tsx b/dembrane/frontend/src/components/analysis/hooks/index.test.tsx index 671543bdf..9b1e8923c 100644 --- a/dembrane/frontend/src/components/analysis/hooks/index.test.tsx +++ b/dembrane/frontend/src/components/analysis/hooks/index.test.tsx @@ -1,14 +1,24 @@ // @vitest-environment jsdom +import { i18n } from "@lingui/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "@/components/common/Toaster"; import { bff } from "@/lib/bff"; -import { RESULTS_PAGE, useResultsList, useResultsVisit } from "./index"; +import { + RESULTS_PAGE, + useRequestAnalysisRun, + useResultsList, + useResultsVisit, +} from "./index"; vi.mock("@/lib/bff", () => ({ bff: { get: vi.fn(), post: vi.fn(), put: vi.fn() }, })); +vi.mock("@/components/common/Toaster", () => ({ + toast: { error: vi.fn() }, +})); vi.mock("posthog-js", () => ({ default: { capture: vi.fn() } })); function wrapper({ children }: { children: ReactNode }) { @@ -141,3 +151,42 @@ describe("a visit to the list", () => { ); }); }); + +describe("requesting a run", () => { + beforeEach(() => i18n.loadAndActivate({ locale: "en-US", messages: {} })); + const refusal = (limit: string) => ({ + response: { + data: { detail: { error: "FREE_TIER_LIMIT", limit } }, + status: 402, + }, + }); + + it.each(["map", "popcorn"])( + "leaves a used-up free %s to the page's wall", + async (limit) => { + vi.mocked(bff.post).mockRejectedValue(refusal(limit)); + const run = renderHook(() => useRequestAnalysisRun("project-1"), { + wrapper, + }); + await act(async () => { + await run.result.current + .mutateAsync({ recipe_id: "arguments" }) + .catch(() => null); + }); + expect(toast.error).not.toHaveBeenCalled(); + }, + ); + + it("says so when the run could not start for another reason", async () => { + vi.mocked(bff.post).mockRejectedValue(new Error("Network Error")); + const run = renderHook(() => useRequestAnalysisRun("project-1"), { + wrapper, + }); + await act(async () => { + await run.result.current + .mutateAsync({ recipe_id: "arguments" }) + .catch(() => null); + }); + expect(toast.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/dembrane/frontend/src/components/analysis/hooks/index.ts b/dembrane/frontend/src/components/analysis/hooks/index.ts index 85d4ac30f..7297edfce 100644 --- a/dembrane/frontend/src/components/analysis/hooks/index.ts +++ b/dembrane/frontend/src/components/analysis/hooks/index.ts @@ -9,8 +9,10 @@ import posthog from "posthog-js"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "@/components/common/Toaster"; import { API_BASE_URL } from "@/config"; +import { invalidateFreeAllowance } from "@/hooks/useFreeAllowance"; import { useServerEvents } from "@/hooks/useServerEvents"; import { bff } from "@/lib/bff"; +import { isFreeTierLimitError } from "@/lib/freeTier"; export type AnalysisRecipe = { id: string; @@ -544,7 +546,15 @@ export function useRequestAnalysisRun(projectId: string) { ...body, idempotency_key: crypto.randomUUID(), }), - onError: () => toast.error(t`Could not start this run. Try again.`), + onError: (error) => { + // The page opens the wall for a used-up month. + const limit = isFreeTierLimitError(error); + if (limit === "map" || limit === "popcorn") { + void invalidateFreeAllowance(queryClient); + return; + } + toast.error(t`Could not start this run. Try again.`); + }, onSuccess: (_data, body) => { // Ids and kinds only: no scope key, no parameters, no participant text. posthog.capture("analysis_run_requested", { @@ -555,6 +565,8 @@ export function useRequestAnalysisRun(projectId: string) { void queryClient.invalidateQueries({ queryKey: analysisKeys.runs(projectId), }); + // A free map or analysis may have been spent. + void invalidateFreeAllowance(queryClient); }, }); } diff --git a/dembrane/frontend/src/components/chat/AgenticChatPanel.test.tsx b/dembrane/frontend/src/components/chat/AgenticChatPanel.test.tsx index f3bf2caf1..6fe6a020e 100644 --- a/dembrane/frontend/src/components/chat/AgenticChatPanel.test.tsx +++ b/dembrane/frontend/src/components/chat/AgenticChatPanel.test.tsx @@ -38,6 +38,7 @@ const RUN_ID = "run-1"; // vi.mock factories are hoisted above every top-level binding, so the state // they close over has to be hoisted with them. const { + appendAgenticRunMessageMock, createAgenticRunMock, runState, stopAgenticRunMock, @@ -45,6 +46,7 @@ const { transcribeMock, usageState, } = vi.hoisted(() => ({ + appendAgenticRunMessageMock: vi.fn(), createAgenticRunMock: vi.fn(), runState: { events: [], status: "running" } as { events: AgenticRunEvent[]; @@ -67,15 +69,15 @@ const { note: "", transcript: "what I said out loud", })), - // Mutable so a test can put the workspace over its recording cap. - usageState: { uploadsLocked: false }, + // Mutable so a test can put the workspace over its recording cap or on free. + usageState: { free: false, uploadsLocked: false }, })); vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - appendAgenticRunMessage: vi.fn(), + appendAgenticRunMessage: appendAgenticRunMessageMock, createAgentInsight: vi.fn(), createAgenticRun: createAgenticRunMock, dismissAgentInsight: vi.fn(), @@ -180,6 +182,24 @@ vi.mock("@/hooks/useWorkspaceUsage", async (importOriginal) => { }); // Children that carry their own data layer and nothing to do with run state. +vi.mock("@/hooks/useFreeAllowance", async (importOriginal) => ({ + ...(await importOriginal()), + useFreeAllowance: () => ({ data: { free: usageState.free } }), +})); + +vi.mock("./FreeTierChatGate", async (importOriginal) => ({ + ...(await importOriginal()), + ChatUpgradeModal: ({ + opened, + reason, + }: { + opened: boolean; + reason: string; + }) => + opened ? ( +
+ ) : null, +})); vi.mock("./ChatAccordion", () => ({ ChatAccordionItemMenu: () => null })); vi.mock("./ChatTemplatesMenuConnected", () => ({ ChatTemplatesMenuConnected: () => null, @@ -512,6 +532,7 @@ describe("AgenticChatPanel, voice input", () => { beforeEach(() => { clock = 0; usageState.uploadsLocked = false; + usageState.free = false; vi.spyOn(Date, "now").mockImplementation(() => clock); vi.stubGlobal("MediaRecorder", FakeMediaRecorder); Object.defineProperty(globalThis.navigator, "mediaDevices", { @@ -635,6 +656,83 @@ describe("AgenticChatPanel, voice input", () => { ).toBeTruthy(); }); + it("locks the microphone on a free project and offers the upgrade", async () => { + usageState.free = true; + runState.status = "completed"; + renderPanel(); + + const mic = await screen.findByTestId("chat-voice-record-button"); + expect(mic.hasAttribute("data-disabled")).toBe(true); + await act(async () => { + fireEvent.click(mic); + }); + + expect( + globalThis.navigator.mediaDevices.getUserMedia, + ).not.toHaveBeenCalled(); + expect(transcribeMock).not.toHaveBeenCalled(); + expect( + await screen.findByText(/You were trying to use your voice in chat/), + ).toBeTruthy(); + }); + + it("opens the upgrade wall when the server refuses a free voice note", async () => { + transcribeMock.mockRejectedValueOnce( + Object.assign(new Error("402"), { + isAxiosError: true, + response: { + data: { + code: "billing.tier_limit", + detail: { error: "FREE_TIER_LIMIT", limit: "audio_month" }, + params: { limit: "audio_month" }, + }, + status: 402, + }, + }), + ); + runState.status = "completed"; + renderPanel(); + await startRecording(); + + clock = 4000; + await act(async () => { + fireEvent.click(screen.getByTestId("chat-voice-stop-button")); + }); + + expect( + await screen.findByText(/You were trying to use your voice in chat/), + ).toBeTruthy(); + expect(screen.queryByTestId("chat-voice-error")).toBeNull(); + }); + + it("shows any other 402 as a voice error, not the free wall", async () => { + transcribeMock.mockRejectedValueOnce( + Object.assign(new Error("402"), { + isAxiosError: true, + response: { + data: { code: "conversation.locked", detail: "locked" }, + status: 402, + }, + }), + ); + runState.status = "completed"; + renderPanel(); + await startRecording(); + + clock = 4000; + await act(async () => { + fireEvent.click(screen.getByTestId("chat-voice-stop-button")); + }); + + const alert = await screen.findByTestId("chat-voice-error"); + expect(alert.textContent).toContain( + "Something went wrong while transcribing", + ); + expect( + screen.queryByText(/You were trying to use your voice in chat/), + ).toBeNull(); + }); + it("says the microphone is blocked rather than failing quietly", async () => { Object.defineProperty(globalThis.navigator, "mediaDevices", { configurable: true, @@ -655,6 +753,93 @@ describe("AgenticChatPanel, voice input", () => { }); }); +describe("AgenticChatPanel, free turn limit", () => { + const TURN_CARD = "You've reached the free plan limit for this chat."; + + const tree = (chatId: string) => ( + + + + + + + + + + ); + + it("forgets a refused turn when the chat changes", async () => { + runState.events = FINISHED_TURN; + runState.status = "completed"; + appendAgenticRunMessageMock.mockRejectedValueOnce({ + response: { + data: { detail: { error: "FREE_TIER_LIMIT", limit: "chat_turns" } }, + status: 402, + }, + }); + const { rerender } = render(tree("chat-1")); + + await screen.findByText("three themes came up."); + const input = await screen.findByTestId("chat-input-textarea"); + fireEvent.change(input, { target: { value: "one more?" } }); + await act(async () => { + fireEvent.click(screen.getByTestId("chat-send-button")); + }); + await waitFor(() => + expect(appendAgenticRunMessageMock).toHaveBeenCalledTimes(1), + ); + await waitFor(() => expect(document.body.textContent).toContain(TURN_CARD)); + + rerender(tree("chat-2")); + await waitFor(() => + expect(document.body.textContent).not.toContain(TURN_CARD), + ); + }); + + it("shows the monthly chat limit, not an error, when the month's chats are used", async () => { + runState.events = FINISHED_TURN; + runState.status = "completed"; + appendAgenticRunMessageMock.mockRejectedValueOnce({ + response: { + data: { + detail: { + cap: 2, + error: "FREE_TIER_LIMIT", + limit: "chats", + used: 2, + }, + }, + status: 402, + }, + }); + render(tree("chat-1")); + + await screen.findByText("three themes came up."); + const input = await screen.findByTestId("chat-input-textarea"); + fireEvent.change(input, { target: { value: "one more?" } }); + await act(async () => { + fireEvent.click(screen.getByTestId("chat-send-button")); + }); + await waitFor(() => + expect(document.body.textContent).toContain( + "You have used this month's free chats.", + ), + ); + expect( + screen.getByTestId("chat-upgrade-modal").getAttribute("data-reason"), + ).toBe("chats"); + expect(document.body.textContent).not.toContain(TURN_CARD); + expect(screen.queryByTestId("chat-error-alert")).toBeNull(); + expect(document.body.textContent).not.toContain("[object Object]"); + }); +}); + describe("enrichAgenticContent, footnote citations", () => { const CONVERSATION_ID = "0aa78d5a-1111-2222-3333-444455556666"; diff --git a/dembrane/frontend/src/components/chat/AgenticChatPanel.tsx b/dembrane/frontend/src/components/chat/AgenticChatPanel.tsx index f0479bc69..b962354e4 100644 --- a/dembrane/frontend/src/components/chat/AgenticChatPanel.tsx +++ b/dembrane/frontend/src/components/chat/AgenticChatPanel.tsx @@ -66,6 +66,10 @@ import { VoiceInputError } from "@/components/voice/VoiceInputError"; import { VoiceRecordingBar } from "@/components/voice/VoiceRecordingBar"; import { mergeTranscriptIntoDraft } from "@/components/voice/voiceInput"; import { trackProcess } from "@/features/processes/store"; +import { + invalidateFreeAllowance, + useFreeAllowance, +} from "@/hooks/useFreeAllowance"; import { useLanguage } from "@/hooks/useLanguage"; import { useWorkspace } from "@/hooks/useWorkspace"; import { useWorkspaceUsage } from "@/hooks/useWorkspaceUsage"; @@ -89,7 +93,8 @@ import { streamAgenticRun, } from "@/lib/api"; import { - FREE_TIER_MAX_CHAT_USER_TURNS, + chatTurnGate, + freeLimitDetails, isFreeTierLimitError, } from "@/lib/freeTier"; import { emitFrozenFeatureAttempt } from "@/lib/frozenFeatureAttempt"; @@ -860,18 +865,16 @@ export const AgenticChatPanel = ({ const projectTranscriptHintsQuery = useProjectById({ projectId, query: { - fields: ["id", "default_conversation_transcript_prompt", "is_sample"], + fields: ["id", "default_conversation_transcript_prompt"], }, }); - const isSample = - ( - projectTranscriptHintsQuery.data as - | { is_sample?: boolean | null } - | undefined - )?.is_sample === true; - // A sample project's chats share their own allowance, which only the server - // counts; its 402 sets this. - const [sampleTurnsSpent, setSampleTurnsSpent] = useState(false); + // A refused turn (402 chat_turns or chats); sample chats only learn their limit this way. + const [turnRefusal, setTurnRefusal] = useState<{ + frozen: boolean; + monthly: boolean; + } | null>(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: reset on chat switch + useEffect(() => setTurnRefusal(null), [chatId]); const voiceHotwords = ( projectTranscriptHintsQuery.data as @@ -1162,24 +1165,26 @@ export const AgenticChatPanel = ({ if (landed) setPendingUserMessage(null); }, [timeline, pendingUserMessage]); - // Free tier: max 3 user turns per chat. The 4th routes to upgrade. - const { freeTier, usageGates } = useWorkspaceUsage(workspace?.id); + const { usageGates } = useWorkspaceUsage(workspace?.id); const [upgradeOpened, upgradeHandlers] = useDisclosure(false); // A voice note bills audio hours, so the gate that locks uploads locks the mic. const voiceCapReached = Boolean(usageGates?.uploads_locked); const [voiceCapOpened, voiceCapHandlers] = useDisclosure(false); - const userTurnCount = useMemo( - () => - timeline.filter((item) => item.kind === "message" && item.role === "user") - .length, - [timeline], - ); - const atTurnLimit = Boolean( - freeTier?.active && - (isSample - ? sampleTurnsSpent - : userTurnCount >= FREE_TIER_MAX_CHAT_USER_TURNS), - ); + // Free projects have no voice input (the server refuses it with a 402). + const voiceFree = Boolean(useFreeAllowance(workspace?.id).data?.free); + const [voiceFreeRefused, setVoiceFreeRefused] = useState(false); + const voiceLocked = voiceFree || voiceFreeRefused || voiceCapReached; + // biome-ignore lint/correctness/useExhaustiveDependencies: react to a refusal only + useEffect(() => { + if (voice.errorKind !== "free_limit") return; + voice.dismissError(); + setVoiceFreeRefused(true); + voiceCapHandlers.open(); + }, [voice.errorKind]); + // Free tier: the server's turn meter for this chat. + const turnGate = chatTurnGate(chatQuery.data?.free_turns); + const atTurnLimit = turnGate.atLimit || turnRefusal !== null; + const turnsFrozen = turnGate.frozen || Boolean(turnRefusal?.frozen); const historyMessages = useMemo( () => @@ -1611,6 +1616,7 @@ export const AgenticChatPanel = ({ void queryClient.invalidateQueries({ queryKey: ["projects", projectId, "chats"], }); + void invalidateFreeAllowance(queryClient); }, [chatId, projectId, queryClient]); const handleSubmit = async (overrideMessage?: string) => { @@ -1677,8 +1683,12 @@ export const AgenticChatPanel = ({ // the composer so it isn't lost (unless they've already typed a new one). setInput((current) => (current.length === 0 ? message : current)); // Backend safety net: free-tier turn cap returns 402. - if (isFreeTierLimitError(submitError) === "chat_turns") { - if (isSample) setSampleTurnsSpent(true); + const refused = isFreeTierLimitError(submitError); + if (refused === "chat_turns" || refused === "chats") { + setTurnRefusal({ + frozen: freeLimitDetails(submitError)?.reason === "frozen", + monthly: refused === "chats", + }); upgradeHandlers.open(); } else { const nextError = @@ -2172,7 +2182,11 @@ export const AgenticChatPanel = ({ /> {atTurnLimit && ( - + )} {voice.errorKind && ( { emitFrozenFeatureAttempt(); voiceCapHandlers.open(); @@ -2272,9 +2286,13 @@ export const AgenticChatPanel = ({ } testId="chat-voice-record-button" tooltip={ - voiceCapReached && !atTurnLimit - ? t`This workspace has used its recording hours. Upgrade to keep using voice.` - : undefined + atTurnLimit + ? undefined + : voiceFree || voiceFreeRefused + ? t`Voice input comes with a paid plan.` + : voiceCapReached + ? t`This workspace has used its recording hours. Upgrade to keep using voice.` + : undefined } /> + ) : recordingPaused(recording.data) ? ( + ) : ( )} diff --git a/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.test.tsx b/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.test.tsx new file mode 100644 index 000000000..9db7c9b91 --- /dev/null +++ b/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.test.tsx @@ -0,0 +1,208 @@ +// @vitest-environment jsdom +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { MantineProvider } from "@mantine/core"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import { UploadFailedError } from "@/lib/api"; +import { UploadConversationDropzone } from "./UploadConversationDropzone"; + +vi.mock("@/lib/api", () => ({ + UploadFailedError: class UploadFailedError extends Error { + failed: { name: string; error: Error }[]; + constructor(failed: { name: string; error: Error }[]) { + super("failed"); + this.failed = failed; + } + }, +})); + +const uploader = vi.hoisted(() => ({ + error: null as Error | null, + isError: false, + isPending: false, + isSuccess: false, + isUploading: false, + resetUpload: () => {}, + uploadErrors: {} as Record, + uploadFiles: (() => {}) as (...args: unknown[]) => void, + uploadProgress: {} as Record, +})); +const recording = vi.hoisted(() => ({ + data: { + audio_seconds_left: 0, + file_limit_seconds: 3600, + free: true, + } as Record | undefined, +})); + +vi.mock("./hooks", () => ({ useConversationUploader: () => uploader })); +vi.mock("./Dropzone", () => ({ + CommonDropzone: ({ onDrop }: { onDrop: (files: File[]) => void }) => ( + + ), +})); +vi.mock("@/components/project/hooks", () => ({ + useProjectById: () => ({ isLoading: false }), +})); +vi.mock("@/hooks/useProjectRecording", () => ({ + useProjectRecording: () => recording, +})); +vi.mock("@/lib/mediaDuration", () => ({ readMediaSeconds: async () => 20 })); +vi.mock("@/hooks/useI18nNavigate", () => ({ useI18nNavigate: () => vi.fn() })); +vi.mock("@posthog/react", () => ({ usePostHog: () => null })); +vi.mock("react-router", () => ({ + useLocation: () => ({ pathname: "/w/w1/projects/p1/overview" }), + useParams: () => ({ workspaceId: "w1" }), +})); +vi.mock("../common/Toaster", () => ({ + toast: { error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +const tierLimit = (limit: string) => + Object.assign(new Error("402"), { + response: { + data: { + code: "billing.tier_limit", + detail: { error: "FREE_TIER_LIMIT", limit }, + params: { limit }, + }, + status: 402, + }, + }); + +beforeAll(() => { + i18n.loadAndActivate({ locale: "en-US", messages: {} }); + window.matchMedia = + window.matchMedia || + ((query: string) => ({ + addEventListener: () => {}, + addListener: () => {}, + dispatchEvent: () => false, + matches: false, + media: query, + onchange: null, + removeEventListener: () => {}, + removeListener: () => {}, + })); + globalThis.ResizeObserver = + globalThis.ResizeObserver || + class { + observe() {} + unobserve() {} + disconnect() {} + }; +}); + +beforeEach(() => { + Object.assign(uploader, { + error: null, + isError: false, + isPending: false, + isSuccess: false, + isUploading: false, + uploadErrors: {}, + uploadFiles: vi.fn(), + uploadProgress: {}, + }); + recording.data = { + audio_seconds_left: 0, + file_limit_seconds: 3600, + free: true, + }; +}); + +afterEach(cleanup); + +const renderDropzone = () => + render( + + + + + , + ); + +const openAndPick = async () => { + fireEvent.click(screen.getByTestId("conversation-upload-button")); + fireEvent.click(await screen.findByText("drop")); + return screen.findByTestId("conversation-upload-files-button"); +}; + +it("disables Upload files once every selected file is refused", async () => { + renderDropzone(); + const button = await openAndPick(); + expect((button as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(button); + await screen.findByText(/You have 0 s of recording left this month/); + await waitFor(() => + expect( + ( + screen.getByTestId( + "conversation-upload-files-button", + ) as HTMLButtonElement + ).disabled, + ).toBe(true), + ); + expect(uploader.uploadFiles).not.toHaveBeenCalled(); +}); + +it("keeps Upload files enabled when some files can still upload", async () => { + recording.data = { + audio_seconds_left: 3600, + file_limit_seconds: 3600, + free: true, + }; + renderDropzone(); + const button = await openAndPick(); + fireEvent.click(button); + await waitFor(() => expect(uploader.uploadFiles).toHaveBeenCalled()); + expect((button as HTMLButtonElement).disabled).toBe(false); +}); + +const renderFailed = (error: Error) => { + Object.assign(uploader, { error, isError: true, isUploading: true }); + renderDropzone(); + fireEvent.click(screen.getByTestId("conversation-upload-button")); +}; + +it("hides Try again when every failure is a free limit refusal", async () => { + renderFailed( + new UploadFailedError( + [{ error: tierLimit("audio_month"), name: "a.mp3" }], + 0, + ), + ); + await screen.findByTestId("conversation-upload-back-button"); + expect(screen.queryByTestId("conversation-upload-retry-button")).toBeNull(); +}); + +it("keeps Try again when a failure is not a free limit refusal", async () => { + renderFailed( + new UploadFailedError( + [ + { error: tierLimit("audio_month"), name: "a.mp3" }, + { error: new Error("network"), name: "b.mp3" }, + ], + 0, + ), + ); + expect( + await screen.findByTestId("conversation-upload-retry-button"), + ).toBeTruthy(); +}); diff --git a/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.tsx b/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.tsx index ff8a3ca2c..55ea0c3f2 100644 --- a/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.tsx +++ b/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.tsx @@ -36,7 +36,12 @@ import { import { useLocation, useParams } from "react-router"; import { useProjectById } from "@/components/project/hooks"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; +import { useProjectRecording } from "@/hooks/useProjectRecording"; +import { UploadFailedError } from "@/lib/api"; +import { isFreeTierLimitError } from "@/lib/freeTier"; +import { readMediaSeconds } from "@/lib/mediaDuration"; import { testId } from "@/lib/testUtils"; +import { precheckRefusalMessage, precheckUploads } from "@/lib/uploadPrecheck"; import { toast } from "../common/Toaster"; import { CommonDropzone } from "./Dropzone"; import { useConversationUploader } from "./hooks"; @@ -266,6 +271,9 @@ export const UploadConversationDropzone = ( const [uploadStatus, setUploadStatus] = useState([]); const [globalError, setGlobalError] = useState(null); const [uploadStarted, setUploadStarted] = useState(false); + // Files the browser refused before upload, by name. + const [refusals, setRefusals] = useState>({}); + const [checking, setChecking] = useState(false); // Use our custom uploader hook const uploader = useConversationUploader(); @@ -275,9 +283,11 @@ export const UploadConversationDropzone = ( fields: ["id"], }, }); + const recordingQuery = useProjectRecording(props.projectId); // Handle file rename with the custom hook const handleFileRename = (_oldFile: File, newFile: File, index: number) => { + setRefusals({}); // Update selected files setSelectedFiles((prev) => prev.map((file, idx) => (idx === index ? newFile : file)), @@ -302,7 +312,7 @@ export const UploadConversationDropzone = ( const newUploadStatus = selectedFiles.map((file) => { const fileName = file.name; const progress = uploader.uploadProgress[fileName] || 0; - const error = uploader.uploadErrors[fileName]; + const error = refusals[fileName] ?? uploader.uploadErrors[fileName]; let status: "pending" | "uploading" | "complete" | "error" = "pending"; if (error) { @@ -331,6 +341,7 @@ export const UploadConversationDropzone = ( uploader.isUploading, uploader.uploadProgress, uploader.uploadErrors, + refusals, selectedFiles, uploadStatus, ]); @@ -364,6 +375,7 @@ export const UploadConversationDropzone = ( setGlobalError(null); setUploadStatus([]); setSelectedFiles([]); + setRefusals({}); setUploadStarted(false); // This should come last as it might trigger other state changes @@ -391,15 +403,33 @@ export const UploadConversationDropzone = ( navigate, ]); + const hasRefusals = Object.keys(refusals).length > 0; + const allRefused = + selectedFiles.length > 0 && + selectedFiles.every((file) => refusals[file.name]); + // A retry can only fail again when every failure is a free limit. + const failures = + uploader.error instanceof UploadFailedError + ? uploader.error.failed.map((f) => f.error) + : uploader.error + ? [uploader.error] + : []; + const canRetry = failures.some((e) => isFreeTierLimitError(e) == null); + // Automatically close the modal after 1.5 seconds on successful upload if we are on the upload page useEffect(() => { - if (uploader.isSuccess && location.pathname.endsWith("/upload") && opened) { + if ( + uploader.isSuccess && + !hasRefusals && + location.pathname.endsWith("/upload") && + opened + ) { const timer = setTimeout(() => { close(); }, 1500); return () => clearTimeout(timer); } - }, [uploader.isSuccess, location.pathname, opened, close]); + }, [uploader.isSuccess, hasRefusals, location.pathname, opened, close]); // Handle file selection - make this a useCallback to ensure stability const handleFileSelection = useCallback( @@ -463,6 +493,7 @@ export const UploadConversationDropzone = ( const updatedFiles = [...selectedFiles, ...newFiles]; setSelectedFiles(updatedFiles); + setRefusals({}); // Create status entries for the new files setUploadStatus((prevStatus) => { @@ -494,6 +525,7 @@ export const UploadConversationDropzone = ( // Remove file from selection const handleRemoveFile = useCallback((indexToRemove: number) => { + setRefusals({}); setSelectedFiles((prev) => prev.filter((_, index) => index !== indexToRemove), ); @@ -503,25 +535,62 @@ export const UploadConversationDropzone = ( }, []); // Start the upload process - const handleUpload = useCallback(() => { - if (selectedFiles.length === 0) return; + const handleUpload = useCallback(async () => { + if (selectedFiles.length === 0 || checking) return; + + // Refuse only files the browser knows are too long; the server decides the rest. + const status = recordingQuery.data; + let refused: Record = {}; + if (status?.free) { + setChecking(true); + try { + const seconds = await Promise.all( + selectedFiles.map((file) => readMediaSeconds(file)), + ); + const found = precheckUploads( + selectedFiles.map((file, i) => ({ + name: file.name, + seconds: seconds[i], + })), + { + fileLimitSeconds: status.file_limit_seconds, + secondsLeft: status.audio_seconds_left, + }, + ); + refused = Object.fromEntries( + found.map((r) => [r.name, precheckRefusalMessage(r)]), + ); + } finally { + setChecking(false); + } + } + setRefusals(refused); + const toUpload = selectedFiles.filter((file) => !refused[file.name]); + if (toUpload.length === 0) return; posthog?.capture("conversation_upload_started", { - file_count: selectedFiles.length, + file_count: toUpload.length, project_id: props.projectId, }); // Start the upload process using our uploader hook uploader.uploadFiles({ - chunks: selectedFiles, + chunks: toUpload, namePrefix: "", pin: "", projectId: props.projectId, source: "DASHBOARD_UPLOAD", tagIdList: [], - timestamps: selectedFiles.map(() => new Date()), + timestamps: toUpload.map(() => new Date()), }); - }, [selectedFiles, props.projectId, uploader, posthog]); + }, [ + selectedFiles, + checking, + recordingQuery.data, + props.projectId, + uploader, + posthog, + ]); // The button holds its place while the project loads; it opens nothing // until the project is there. @@ -620,96 +689,110 @@ export const UploadConversationDropzone = ( {selectedFiles.map((file, index) => ( - - - - {fileEditor.editingIndex === index ? ( - - fileEditor.setEditValue(e.target.value) - } - onKeyDown={fileEditor.handleKeyDown} - style={{ flex: 1 }} - placeholder={t`Enter filename (without extension)`} - rightSection={ + + + + + {fileEditor.editingIndex === index ? ( + + fileEditor.setEditValue(e.target.value) + } + onKeyDown={fileEditor.handleKeyDown} + style={{ flex: 1 }} + placeholder={t`Enter filename (without extension)`} + rightSection={ + + + + } + {...testId( + `conversation-upload-file-input-${index}`, + )} + /> + ) : ( + <> + + + {truncateFileName(file.name)} + + + + ({formatFileSize(file.size)}) + + + )} + + + {fileEditor.editingIndex !== index && ( + + fileEditor.startEditing(index) + } + disabled={ + fileEditor.editingIndex !== null + } {...testId( - `conversation-upload-file-save-${index}`, + `conversation-upload-file-edit-${index}`, )} > - + - } - {...testId( - `conversation-upload-file-input-${index}`, - )} - /> - ) : ( - <> - - - {truncateFileName(file.name)} - - - ({formatFileSize(file.size)}) - - - )} - - - {fileEditor.editingIndex !== index && ( - + )} + - fileEditor.startEditing(index) - } - disabled={fileEditor.editingIndex !== null} + onClick={() => handleRemoveFile(index)} + disabled={fileEditor.editingIndex === index} {...testId( - `conversation-upload-file-edit-${index}`, + `conversation-upload-file-remove-${index}`, )} > - + - )} - - handleRemoveFile(index)} - disabled={fileEditor.editingIndex === index} - {...testId( - `conversation-upload-file-remove-${index}`, - )} - > - - - + - + {refusals[file.name] && ( + + {refusals[file.name]} + + )} + ))} @@ -734,8 +817,9 @@ export const UploadConversationDropzone = (