From 21834c6ec80c93be80c777769d9770cdcdbe0f84 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 27 Jul 2026 19:26:12 +0200 Subject: [PATCH 1/5] feat(chat): add welcome view when no LM provider is connected The LM chat panel used to show a single gray line ('No messages yet.') when the user had no connected provider. With no provider, sending a message would fail with a generic LLM error and there was no obvious next step. The new component replaces the empty state in two cases: - the user has never set up a provider - the user had providers and disconnected them all It explains what the chat can do, then routes the user to the provider settings dialog with a single CTA. A small disclaimer under the button makes it clear that, once a provider IS connected, the video's transcript will be sent to it. The composer at the bottom is also disabled in this state (textarea + send button + placeholder swapped) so the user does not type a message that will be rejected. A guard in send() still bounces the user to the settings modal in case the disabled state is bypassed (Enter via shortcut, etc.). Localized in all 13 supported locales (en, fr, es, it, pt-BR, de via zh family, ru, tr, ar, vi, ko-KR, ja-JP, zh-CN, zh-TW) under chat.welcome.* and chat.composerDisabledNoProvider. --- .../ai-edition/ChatWelcome.test.tsx | 91 ++++++++++++++ src/components/ai-edition/ChatWelcome.tsx | 69 +++++++++++ src/components/ai-edition/LeftPanel.tsx | 29 ++++- .../ai-edition/NewEditorShell.module.css | 113 ++++++++++++++++++ src/i18n/locales/ar/editor.json | 10 ++ src/i18n/locales/en/editor.json | 10 ++ src/i18n/locales/es/editor.json | 10 ++ src/i18n/locales/fr/editor.json | 10 ++ src/i18n/locales/it/editor.json | 10 ++ src/i18n/locales/ja-JP/editor.json | 10 ++ src/i18n/locales/ko-KR/editor.json | 10 ++ src/i18n/locales/pt-BR/editor.json | 10 ++ src/i18n/locales/ru/editor.json | 10 ++ src/i18n/locales/tr/editor.json | 10 ++ src/i18n/locales/vi/editor.json | 10 ++ src/i18n/locales/zh-CN/editor.json | 10 ++ src/i18n/locales/zh-TW/editor.json | 10 ++ 17 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 src/components/ai-edition/ChatWelcome.test.tsx create mode 100644 src/components/ai-edition/ChatWelcome.tsx diff --git a/src/components/ai-edition/ChatWelcome.test.tsx b/src/components/ai-edition/ChatWelcome.test.tsx new file mode 100644 index 000000000..5902d6c47 --- /dev/null +++ b/src/components/ai-edition/ChatWelcome.test.tsx @@ -0,0 +1,91 @@ +// ChatWelcome guards the "no provider connected" empty state. +// +// Two things matter here: +// 1. the welcome card is fully localized — every locale must render the +// CTA and the disclaimer, not fall back to a key like "chat.welcome.cta" +// 2. the CTA opens the provider settings dialog (whatever the parent +// decided to do, it just has to fire the callback) + +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { LOCALE_STORAGE_KEY } from "@/i18n/config"; +import { ChatWelcome } from "./ChatWelcome"; + +function renderIn(locale: string, ui: ReactElement) { + localStorage.setItem(LOCALE_STORAGE_KEY, locale); + return render({ui}); +} + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe("ChatWelcome", () => { + it("renders the English welcome card with the CTA and disclaimer", () => { + const onOpen = vi.fn(); + renderIn("en", ); + + expect(screen.getByRole("heading", { name: /bring your own ai/i })).toBeInTheDocument(); + expect(screen.getByText(/talk.*language model/i)).toBeInTheDocument(); + // The 3 feature lines are inside a
    ; query them by text so we know + // they actually reach the DOM, not just an unused i18n key. + expect(screen.getByText(/cut silences/i)).toBeInTheDocument(); + expect(screen.getByText(/add captions/i)).toBeInTheDocument(); + expect(screen.getByText(/rewrite a section/i)).toBeInTheDocument(); + expect(screen.getByText(/transcript will be sent/i)).toBeInTheDocument(); + }); + + it("invokes the onOpenProviderSettings callback when the CTA is clicked", () => { + const onOpen = vi.fn(); + renderIn("en", ); + + const cta = screen.getByTestId("chat-welcome-cta"); + expect(cta).toBeInTheDocument(); + fireEvent.click(cta); + + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it("renders the French welcome card with translated copy", () => { + renderIn("fr", ); + + expect(screen.getByRole("heading", { name: /apportez votre ia/i })).toBeInTheDocument(); + expect(screen.getByText(/configurer un fournisseur/i)).toBeInTheDocument(); + // Disclaimer must NOT be the English fallback + expect(screen.queryByText(/transcript will be sent/i)).not.toBeInTheDocument(); + expect(screen.getByText(/transcription de votre vidéo/i)).toBeInTheDocument(); + }); + + it("renders the Spanish welcome card with translated copy", () => { + renderIn("es", ); + + expect(screen.getByText(/configurar un proveedor/i)).toBeInTheDocument(); + expect(screen.getByText(/transcripción de tu vídeo/i)).toBeInTheDocument(); + }); + + it("renders the Japanese welcome card with translated copy", () => { + renderIn("ja-JP", ); + + expect(screen.getByText(/プロバイダーを設定/)).toBeInTheDocument(); + // The Japanese disclaimer uses 「動画」 — guard against the English + // fallback by asserting the locale-specific substring is present. + expect(screen.getByText(/動画の文字起こし/)).toBeInTheDocument(); + }); + + it("exposes the welcome region to assistive tech", () => { + renderIn("en", ); + + // The component marks itself as a region with the title as its + // accessible name — screen readers announce the title on focus. + const region = screen.getByRole("region", { name: /bring your own ai/i }); + expect(region).toBeInTheDocument(); + }); +}); diff --git a/src/components/ai-edition/ChatWelcome.tsx b/src/components/ai-edition/ChatWelcome.tsx new file mode 100644 index 000000000..3012b01c5 --- /dev/null +++ b/src/components/ai-edition/ChatWelcome.tsx @@ -0,0 +1,69 @@ +// Welcome view for the LM chat panel. +// +// Shown in the chat body when no LLM provider is connected (whether the user +// has just installed the app, or used to have a provider and removed all +// credentials). It explains what the chat can do, then routes the user to the +// provider settings dialog with a single CTA. A small disclaimer under the +// button makes it clear that, once a provider IS connected, the video's +// transcript will be sent to it. +// +// We deliberately do NOT keep the prior "no messages yet" placeholder in this +// branch — without a provider there is no way to send a first message, so the +// hint would be a dead end. The regular `chat.emptyState` line still appears +// for the brief window where a provider is connected but no messages have +// been exchanged yet. + +import { ArrowRight, Info, Sparkles } from "lucide-react"; +import { useScopedT } from "@/contexts/I18nContext"; +import styles from "./NewEditorShell.module.css"; + +interface ChatWelcomeProps { + /** Open the provider settings modal so the user can pick + connect one. */ + onOpenProviderSettings: () => void; +} + +export function ChatWelcome({ onOpenProviderSettings }: ChatWelcomeProps) { + const t = useScopedT("editor"); + + return ( +
    +
    + +

    {t("chat.welcome.title")}

    +

    {t("chat.welcome.subtitle")}

    +
    + +
      +
    • +
    • +
    • +
    • +
    • +
    • +
    + + + +

    +

    +
    + ); +} diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 4812c3968..0884f29f4 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -21,6 +21,7 @@ import { PROVIDER_DEFINITIONS, type ReasoningEffort, } from "../../../electron/ai-edition/provider-registry"; +import { ChatWelcome } from "./ChatWelcome"; import { computeBudget } from "./chatBudget"; import { ChatHistoryModal, SourceTranscriptModal } from "./Modals"; import styles from "./NewEditorShell.module.css"; @@ -770,6 +771,15 @@ function ChatStripPanel() { const send = async (overrideText?: string) => { const text = (overrideText ?? input).trim(); if (!projectId || !text || busy) return; + // ponytail: no connected provider = nothing to talk to. Bounce the user + // to the settings modal instead of sending a doomed request and + // surfacing a generic LLM error. The composer is also disabled in this + // state, but Enter-to-send could still slip through (e.g. focus + // restored via shortcut), so the check lives here too. + if (connectedProviders.length === 0) { + setSettingsOpen(true); + return; + } setInput(""); setBusy(true); // ponytail: pre-seed the user message so the rewind ↩ button is @@ -1405,7 +1415,9 @@ function ChatStripPanel() {
    - {messages.length === 0 ? ( + {connectedProviders.length === 0 ? ( + setSettingsOpen(true)} /> + ) : messages.length === 0 ? (