Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/components/ai-edition/ChatWelcome.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// ChatWelcome guards the "no provider connected" empty state: the copy reaches
// the DOM, the CTA fires, and a non-English locale is really translated rather
// than falling back to English. localeParity.test.ts covers key presence for
// the other locales; only the fallback check needs a rendered card.

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(<I18nProvider>{ui}</I18nProvider>);
}

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", <ChatWelcome onOpenProviderSettings={onOpen} />);

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 <ul>; 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", <ChatWelcome onOpenProviderSettings={onOpen} />);

fireEvent.click(screen.getByRole("button", { name: /set up a provider/i }));

expect(onOpen).toHaveBeenCalledTimes(1);
});

it("renders the French welcome card with translated copy", () => {
renderIn("fr", <ChatWelcome onOpenProviderSettings={vi.fn()} />);

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();
});
});
47 changes: 47 additions & 0 deletions src/components/ai-edition/ChatWelcome.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Welcome view for the LM chat panel.
//
// Shown in the chat body when the chat has nothing it can talk to — see
// canSendChat() in chatAvailability.ts. It replaces the `chat.emptyState` hint
// (a dead end with no provider) only while the conversation is empty, so a user
// who disconnects mid-project keeps their history with the composer disabled.

import { ArrowRight, Info, Sparkles } from "lucide-react";
import { useScopedT } from "@/contexts/I18nContext";
import styles from "./NewEditorShell.module.css";

const FEATURE_KEYS = ["feature1", "feature2", "feature3"] as const;

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 (
<div className={styles.chatWelcome}>
<header className={styles.chatWelcomeHero}>
<Sparkles size={20} className={styles.chatWelcomeIcon} aria-hidden="true" />
<h2 className={styles.chatWelcomeTitle}>{t("chat.welcome.title")}</h2>
<p className={styles.chatWelcomeSubtitle}>{t("chat.welcome.subtitle")}</p>
</header>

<ul className={styles.chatWelcomeFeatures}>
{FEATURE_KEYS.map((key) => (
<li key={key}>{t(`chat.welcome.${key}`)}</li>
))}
</ul>

<button type="button" className={styles.chatWelcomeCta} onClick={onOpenProviderSettings}>
{t("chat.welcome.cta")}
<ArrowRight size={14} />
</button>

<p className={styles.chatWelcomeDisclaimer}>
<Info size={12} className={styles.chatWelcomeDisclaimerIcon} aria-hidden="true" />
<span>{t("chat.welcome.disclaimer")}</span>
</p>
</div>
);
}
40 changes: 33 additions & 7 deletions src/components/ai-edition/LeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
PROVIDER_DEFINITIONS,
type ReasoningEffort,
} from "../../../electron/ai-edition/provider-registry";
import { ChatWelcome } from "./ChatWelcome";
import { canSendChat } from "./chatAvailability";
import { computeBudget } from "./chatBudget";
import { ChatHistoryModal, SourceTranscriptModal } from "./Modals";
import styles from "./NewEditorShell.module.css";
Expand Down Expand Up @@ -645,6 +647,9 @@ function ModelQuickPopover({
function ChatStripPanel() {
const t = useScopedT("editor");
const tc = useScopedT("common");
// The Auto-enhance confirmation is timeline-owned copy, fired from here —
// see the prompt-bus effect below.
const tTimeline = useScopedT("timeline");
const projectId = useProjectStore((s) => s.projectId);
const [messages, setMessages] = useState<ChatDisplayMessage[]>([]);
const [input, setInput] = useState("");
Expand Down Expand Up @@ -672,7 +677,10 @@ function ChatStripPanel() {
bottom: number;
} | null>(null);
const [reasoningBusy, setReasoningBusy] = useState(false);
const [connectedProviders, setConnectedProviders] = useState<string[]>([]);
// null until the first llmGetSnapshot() lands: "unknown", not "none".
const [connectedProviders, setConnectedProviders] = useState<string[] | null>(null);
// unknown ≠ none; see chatAvailability.ts.
const canChat = canSendChat(llmConfig, connectedProviders);
const [modelPopoverOpen, setModelPopoverOpen] = useState(false);
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
const [modelPopoverRect, setModelPopoverRect] = useState<{
Expand Down Expand Up @@ -770,6 +778,14 @@ function ChatStripPanel() {
const send = async (overrideText?: string) => {
const text = (overrideText ?? input).trim();
if (!projectId || !text || busy) return;
// ponytail: nothing to talk to. Bounce to the settings modal instead of
// firing a doomed request. The composer is disabled in this state too,
// but Auto-enhance calls send() directly and Enter can slip through.
if (!canChat) {
toast.error(t("chat.composerDisabledNoProvider"));
setSettingsOpen(true);
return;
}
setInput("");
setBusy(true);
// ponytail: pre-seed the user message so the rewind ↩ button is
Expand Down Expand Up @@ -843,14 +859,19 @@ function ChatStripPanel() {
// timeline's Auto-enhance → "Smart zooms + cuts with AI"). Routes through
// the normal send() so sessions/checkpoints/rewind all keep working; the
// message shows in the composer's history exactly as if typed.
// The confirmation toast lives here because only this side knows the prompt
// was taken — send() bounces it to the settings modal with no provider.
// ponytail: one producer today, so the toast copy is assumed to be its own.
// A second producer needs the bus to carry its confirmation string.
const pendingPrompt = useChatPromptBus((s) => s.pending);
const consumePrompt = useChatPromptBus((s) => s.consume);
// biome-ignore lint/correctness/useExhaustiveDependencies: send() is intentionally not a dep (recreated each render); consume() clears `pending` so this fires once per queued prompt.
useEffect(() => {
if (!pendingPrompt || !projectId || busy) return;
consumePrompt();
if (canChat) toast.success(tTimeline("toolbar.aiEnhanceRequested"));
void send(pendingPrompt);
}, [pendingPrompt, projectId, busy, consumePrompt]);
}, [pendingPrompt, projectId, busy, consumePrompt, canChat, tTimeline]);

// ponytail: per-user-message rewind. Pops a confirmation popover, then
// asks the main process to roll the session + document back to the
Expand Down Expand Up @@ -1405,7 +1426,9 @@ function ChatStripPanel() {
</div>

<div className={styles.panelBody} ref={scrollRef}>
{messages.length === 0 ? (
{!canChat && messages.length === 0 ? (
<ChatWelcome onOpenProviderSettings={() => setSettingsOpen(true)} />
) : messages.length === 0 ? (
<p
style={{
font: "400 12px var(--font-body)",
Expand Down Expand Up @@ -1568,8 +1591,11 @@ function ChatStripPanel() {

<div className={styles.chatInput}>
<textarea
placeholder={t("chat.composerPlaceholder")}
placeholder={
canChat ? t("chat.composerPlaceholder") : t("chat.composerDisabledNoProvider")
}
value={input}
disabled={!canChat}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
Expand Down Expand Up @@ -1672,7 +1698,7 @@ function ChatStripPanel() {
<ModelQuickPopover
anchorRect={modelPopoverRect}
llmConfig={llmConfig}
connectedProviders={connectedProviders}
connectedProviders={connectedProviders ?? []}
onClose={() => setModelPopoverOpen(false)}
onConfigChange={() => void refreshLlm()}
onOpenFullSettings={() => setSettingsOpen(true)}
Expand All @@ -1681,10 +1707,10 @@ function ChatStripPanel() {
<button
type="button"
className={styles.sendBtn}
title={t("chat.sendTitle")}
title={canChat ? t("chat.sendTitle") : t("chat.composerDisabledNoProvider")}
aria-label={t("chat.send")}
onClick={() => void send()}
disabled={busy || !input.trim()}
disabled={busy || !input.trim() || !canChat}
>
<svg
width={14}
Expand Down
93 changes: 93 additions & 0 deletions src/components/ai-edition/NewEditorShell.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2117,3 +2117,96 @@
.backBtn:hover { background: var(--surface-3); }
.backBtn:disabled { opacity: 0.5; cursor: not-allowed; }

/* ─── chat welcome (no provider connected) ──────────────────────── */
/* .panelBody supplies the horizontal inset (var(--sp-3) var(--sp-4)); only the
top breathing room is ours, or the card sits twice as far in as the messages. */
.chatWelcome {
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--sp-3);
padding: var(--sp-2) 0 0;
text-align: left;
}

.chatWelcomeHero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 6px;
padding: 4px 0 0;
}

.chatWelcomeIcon {
color: var(--accent);
margin-bottom: 4px;
}

.chatWelcomeTitle {
margin: 0;
font: 600 14px/1.3 var(--font-body);
color: var(--fg);
letter-spacing: -0.01em;
}

.chatWelcomeSubtitle {
margin: 0;
font: 400 12px/1.5 var(--font-body);
color: var(--muted);
max-width: 32ch;
}

/* Native list markers — no flex on the ul/li, or the items stop being
list-items and the markers vanish. padding-inline-start keeps them inside
the card in both writing directions. */
.chatWelcomeFeatures {
/* explicit: the global reset sets list-style: none on every ul */
list-style: disc;
margin: 0;
padding: 10px 12px;
padding-inline-start: 26px;
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: var(--r-md);
font: 400 12px/1.45 var(--font-body);
color: var(--fg-2);
}
.chatWelcomeFeatures li + li { margin-top: 6px; }
.chatWelcomeFeatures li::marker { color: var(--accent); }

.chatWelcomeCta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 34px;
border-radius: var(--r-md);
background: var(--accent);
color: var(--accent-on);
border: 1px solid var(--accent);
font: 600 12.5px var(--font-body);
cursor: pointer;
transition: filter 120ms ease;
}
.chatWelcomeCta:hover { filter: brightness(0.95); }
.chatWelcomeCta:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}

.chatWelcomeDisclaimer {
margin: 0;
display: flex;
align-items: flex-start;
gap: 6px;
font: 400 10.5px/1.45 var(--font-body);
color: var(--muted);
}

.chatWelcomeDisclaimerIcon {
flex: 0 0 auto;
margin-top: 2px;
opacity: 0.8;
}
46 changes: 46 additions & 0 deletions src/components/ai-edition/chatAvailability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import type { AiEditionLlmConfig } from "@/native/contracts";
import { canSendChat } from "./chatAvailability";

function cfg(provider: string, model = "gpt-4o-mini"): AiEditionLlmConfig {
return { provider, model };
}

describe("canSendChat", () => {
it("stays optimistic while the snapshot is still in flight", () => {
// null connectedProviders = not loaded yet. Returning false here would
// flash the welcome view on every mount, and leave it up for good when
// llmGetSnapshot fails (refreshLlm swallows the error).
expect(canSendChat(null, null)).toBe(true);
expect(canSendChat(cfg("openai"), null)).toBe(true);
});

it("returns false when there is no config at all", () => {
expect(canSendChat(null, [])).toBe(false);
});

it("returns false when the config is the post-disconnect reset (provider: '')", () => {
// The disconnect flow in aiEditionService.llmDisconnect writes an
// empty-string provider to the active config. We treat that the same
// as "nothing selected" so the welcome view shows immediately.
expect(canSendChat(cfg(""), ["minimax"])).toBe(false);
});

it("returns false when the active provider has no credentials", () => {
// The user selected MiniMax-M3 then disconnected it. `connectedProviders`
// is empty but the stale config still references minimax.
expect(canSendChat(cfg("minimax"), [])).toBe(false);
});

it("returns false when other providers are connected but not the active one", () => {
// Active config points at openai; only anthropic has credentials.
// The user needs to switch the active provider via the model picker
// (or reconnect openai) before they can chat.
expect(canSendChat(cfg("openai"), ["anthropic"])).toBe(false);
});

it("returns true when the active provider is in the connected list", () => {
expect(canSendChat(cfg("minimax"), ["minimax"])).toBe(true);
expect(canSendChat(cfg("openai"), ["openai", "anthropic"])).toBe(true);
});
});
19 changes: 19 additions & 0 deletions src/components/ai-edition/chatAvailability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// "Can the user actually send a chat message right now?"
//
// Mirrors runChat's preflight (electron/ai-edition/chat-service.ts), so the
// composer is disabled exactly when a send would have failed.

import type { AiEditionLlmConfig } from "@/native/contracts";

export function canSendChat(
llmConfig: AiEditionLlmConfig | null,
connectedProviders: string[] | null,
): boolean {
// Snapshot not landed yet: unknown, not none. refreshLlm() swallows its
// errors, so pessimism here would strand the panel behind the welcome view.
if (connectedProviders === null) return true;
if (llmConfig === null) return false;
// llmDisconnect resets the active config to provider: "".
if (llmConfig.provider === "") return false;
return connectedProviders.includes(llmConfig.provider);
}
Loading
Loading