diff --git a/e2e/wiki-compose.spec.ts b/e2e/wiki-compose.spec.ts index b9fd897d..a1a3d7f9 100644 --- a/e2e/wiki-compose.spec.ts +++ b/e2e/wiki-compose.spec.ts @@ -274,9 +274,7 @@ test.describe("Wiki Compose P2 happy path", () => { await page.goto(`/notes/${NOTE_ID}/${PAGE_ID}/compose`); - await page.getByTestId("compose-start").click(); - - // Brief interrupt — question card appears. + // Brief interrupt — question card appears (compose auto-starts from AI settings). const briefCard = page.getByTestId(`brief-card-${BRIEF_QUESTION_ID}`); await expect(briefCard).toBeVisible(); await expect(page.getByText("What's the audience for this article?")).toBeVisible(); diff --git a/src/components/wikiCompose/ComposeBackendSelector.tsx b/src/components/wikiCompose/ComposeBackendSelector.tsx deleted file mode 100644 index 814c58d5..00000000 --- a/src/components/wikiCompose/ComposeBackendSelector.tsx +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Execution backend picker for Wiki Compose (#951). - * Wiki Compose 用の実行 backend 選択 UI。 - */ -import React, { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Label, RadioGroup, RadioGroupItem } from "@zedi/ui"; -import { - COMPOSE_BACKEND_META, - type ComposeExecutionBackend, - usesZediCu, -} from "@/lib/wikiCompose/backends"; -import { - fetchUserAiCredentialsStatus, - type UserAiCredentialProvider, -} from "@/lib/userAiCredentials"; - -export interface ComposeBackendSelectorProps { - value: ComposeExecutionBackend; - onChange: (backend: ComposeExecutionBackend) => void; - disabled?: boolean; -} - -/** - * Renders backend options; grays out BYOK choices without a stored credential. - */ -export const ComposeBackendSelector: React.FC = ({ - value, - onChange, - disabled = false, -}) => { - const { t } = useTranslation(); - const [configuredProviders, setConfiguredProviders] = useState>( - new Set(), - ); - const [storageEnabled, setStorageEnabled] = useState(false); - - useEffect(() => { - let cancelled = false; - void fetchUserAiCredentialsStatus() - .then((status) => { - if (cancelled) return; - setStorageEnabled(status.storageEnabled); - const set = new Set(); - for (const p of status.providers) { - if (p.configured) set.add(p.provider); - } - setConfiguredProviders(set); - }) - .catch(() => { - if (!cancelled) { - setStorageEnabled(false); - setConfiguredProviders(new Set()); - } - }); - return () => { - cancelled = true; - }; - }, []); - - const availability = useMemo(() => { - const map = new Map(); - for (const meta of COMPOSE_BACKEND_META) { - if (meta.provider === null) { - map.set(meta.id, true); - } else { - map.set(meta.id, storageEnabled && configuredProviders.has(meta.provider)); - } - } - return map; - }, [configuredProviders, storageEnabled]); - - return ( -
- - onChange(v as ComposeExecutionBackend)} - className="flex flex-col gap-2" - disabled={disabled} - > - {COMPOSE_BACKEND_META.map((meta) => { - const available = availability.get(meta.id) ?? false; - const itemDisabled = disabled || !available; - return ( -
- - -
- ); - })} -
-
- ); -}; diff --git a/src/hooks/useInitialComposeBackend.test.ts b/src/hooks/useInitialComposeBackend.test.ts new file mode 100644 index 00000000..1fb94c16 --- /dev/null +++ b/src/hooks/useInitialComposeBackend.test.ts @@ -0,0 +1,88 @@ +/** + * `useInitialComposeBackend` unit tests (#951). + */ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + loadAISettings: vi.fn(), + fetchUserAiCredentialsStatus: vi.fn(), +})); + +vi.mock("@/lib/aiSettings", async () => { + const actual = await vi.importActual("@/lib/aiSettings"); + return { + ...actual, + loadAISettings: mocks.loadAISettings, + AI_SETTINGS_CHANGED_EVENT: "zedi-ai-settings-changed", + }; +}); + +vi.mock("@/lib/userAiCredentials", () => ({ + fetchUserAiCredentialsStatus: mocks.fetchUserAiCredentialsStatus, +})); + +import { AI_SETTINGS_CHANGED_EVENT } from "@/lib/aiSettings"; +import { DEFAULT_AI_SETTINGS } from "@/types/ai"; +import { useInitialComposeBackend } from "./useInitialComposeBackend"; + +const CREDENTIALS_NONE = { + storageEnabled: false, + providers: [ + { provider: "anthropic" as const, configured: false }, + { provider: "openai" as const, configured: false }, + { provider: "google" as const, configured: false }, + ], +}; + +describe("useInitialComposeBackend", () => { + beforeEach(() => { + mocks.loadAISettings.mockReset(); + mocks.fetchUserAiCredentialsStatus.mockReset(); + mocks.loadAISettings.mockResolvedValue(DEFAULT_AI_SETTINGS); + mocks.fetchUserAiCredentialsStatus.mockResolvedValue(CREDENTIALS_NONE); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("resolves backend from AI settings on mount", async () => { + const { result } = renderHook(() => useInitialComposeBackend()); + + await waitFor(() => expect(result.current.isResolved).toBe(true)); + expect(result.current.backend).toBe("zedi_managed"); + }); + + it("marks resolved when a settings-changed load finishes after the initial load", async () => { + let resolveInitial: (value: typeof DEFAULT_AI_SETTINGS) => void = () => undefined; + mocks.loadAISettings.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveInitial = resolve; + }), + ); + + const { result } = renderHook(() => useInitialComposeBackend()); + expect(result.current.isResolved).toBe(false); + + await act(async () => { + window.dispatchEvent(new CustomEvent(AI_SETTINGS_CHANGED_EVENT)); + }); + + await act(async () => { + resolveInitial(DEFAULT_AI_SETTINGS); + await Promise.resolve(); + }); + + await waitFor(() => expect(result.current.isResolved).toBe(true)); + expect(result.current.backend).toBe("zedi_managed"); + }); + + it("skips loading when disabled", async () => { + const { result } = renderHook(() => useInitialComposeBackend({ enabled: false })); + + expect(result.current.isResolved).toBe(true); + expect(mocks.loadAISettings).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useInitialComposeBackend.ts b/src/hooks/useInitialComposeBackend.ts index d2cf5450..35d34c22 100644 --- a/src/hooks/useInitialComposeBackend.ts +++ b/src/hooks/useInitialComposeBackend.ts @@ -2,7 +2,7 @@ * Initializes Wiki Compose backend from AI settings (#951). * Wiki Compose の backend を設定画面の AI 設定から初期化する。 */ -import { useEffect, useState, type Dispatch, type SetStateAction } from "react"; +import { useEffect, useState } from "react"; import { loadAISettings, AI_SETTINGS_CHANGED_EVENT } from "@/lib/aiSettings"; import { fetchUserAiCredentialsStatus } from "@/lib/userAiCredentials"; import { resolveComposeBackendFromAiSettings } from "@/lib/wikiCompose/resolveComposeBackend"; @@ -35,45 +35,46 @@ export interface UseInitialComposeBackendOptions { export interface UseInitialComposeBackendResult { backend: ComposeExecutionBackend; - setBackend: Dispatch>; /** False until the first settings-based resolution finishes (when `enabled`). */ isResolved: boolean; } /** - * Returns backend state synced from AI settings until the user changes it or `enabled` is false. - * AI 設定と同期した backend。ユーザーが変更するか `enabled` が false になるまで追従する。 + * Returns backend state synced from AI settings while `enabled` is true. + * `enabled` が true の間、AI 設定と同期した backend を返す。 */ export function useInitialComposeBackend( options: UseInitialComposeBackendOptions = {}, ): UseInitialComposeBackendResult { const { enabled = true } = options; const [backend, setBackend] = useState("zedi_managed"); - const [userOverrode, setUserOverrode] = useState(false); - const [settingsSynced, setSettingsSynced] = useState(!enabled); - - const isResolved = !enabled || userOverrode || settingsSynced; + const [isResolved, setIsResolved] = useState(!enabled); useEffect(() => { - if (!enabled || userOverrode) return; + if (!enabled) { + setIsResolved(true); + return; + } let cancelled = false; let loadGeneration = 0; - const applyFromSettings = (markSynced: boolean) => { + const applyFromSettings = () => { const generation = ++loadGeneration; void loadComposeBackendFromSettings().then((resolved) => { if (cancelled || generation !== loadGeneration) return; setBackend(resolved); - if (markSynced) setSettingsSynced(true); + // Always unblock compose auto-start on the latest successful load. + // 最新の読み込みが成功したら常に resolved にする(設定変更イベントとの競合対策)。 + setIsResolved(true); }); }; - applyFromSettings(true); + setIsResolved(false); + applyFromSettings(); const onSettingsChanged = () => { - if (userOverrode) return; - applyFromSettings(false); + applyFromSettings(); }; window.addEventListener(AI_SETTINGS_CHANGED_EVENT, onSettingsChanged); @@ -82,13 +83,7 @@ export function useInitialComposeBackend( loadGeneration += 1; window.removeEventListener(AI_SETTINGS_CHANGED_EVENT, onSettingsChanged); }; - }, [enabled, userOverrode]); - - const setBackendWithOverride: Dispatch> = (value) => { - setUserOverrode(true); - setSettingsSynced(true); - setBackend(value); - }; + }, [enabled]); - return { backend, setBackend: setBackendWithOverride, isResolved }; + return { backend, isResolved }; } diff --git a/src/i18n/locales/en/wikiCompose.json b/src/i18n/locales/en/wikiCompose.json index 5f65de13..62596f1b 100644 --- a/src/i18n/locales/en/wikiCompose.json +++ b/src/i18n/locales/en/wikiCompose.json @@ -6,8 +6,7 @@ "back": "Back", "sessionPrefix": "session:", "close": "Close", - "cancel": "Cancel", - "startCompose": "Start compose" + "cancel": "Cancel" }, "phase": { "brief": "Brief", diff --git a/src/i18n/locales/ja/wikiCompose.json b/src/i18n/locales/ja/wikiCompose.json index 6398fbec..87ce605f 100644 --- a/src/i18n/locales/ja/wikiCompose.json +++ b/src/i18n/locales/ja/wikiCompose.json @@ -6,8 +6,7 @@ "back": "戻る", "sessionPrefix": "セッション:", "close": "閉じる", - "cancel": "キャンセル", - "startCompose": "Compose を開始" + "cancel": "キャンセル" }, "phase": { "brief": "ブリーフ", diff --git a/src/lib/wikiCompose/backends.ts b/src/lib/wikiCompose/backends.ts index d5b4f6dd..6344ec15 100644 --- a/src/lib/wikiCompose/backends.ts +++ b/src/lib/wikiCompose/backends.ts @@ -16,49 +16,8 @@ export const COMPOSE_BACKEND_OPTIONS: readonly ComposeExecutionBackend[] = [ "user_google", ] as const; -export type ComposeBackendProvider = "anthropic" | "openai" | "google"; - -/** UI metadata for each backend option. */ -export interface ComposeBackendOptionMeta { - id: ComposeExecutionBackend; - provider: ComposeBackendProvider | null; - labelKey: string; - descriptionKey: string; -} - -export const COMPOSE_BACKEND_META: readonly ComposeBackendOptionMeta[] = [ - { - id: "zedi_managed", - provider: null, - labelKey: "wikiCompose.backend.zediManaged", - descriptionKey: "wikiCompose.backend.zediManagedDesc", - }, - { - id: "user_anthropic", - provider: "anthropic", - labelKey: "wikiCompose.backend.userAnthropic", - descriptionKey: "wikiCompose.backend.userAnthropicDesc", - }, - { - id: "user_openai", - provider: "openai", - labelKey: "wikiCompose.backend.userOpenai", - descriptionKey: "wikiCompose.backend.userOpenaiDesc", - }, - { - id: "user_google", - provider: "google", - labelKey: "wikiCompose.backend.userGoogle", - descriptionKey: "wikiCompose.backend.userGoogleDesc", - }, -]; - export function isUserByokComposeBackend( backend: ComposeExecutionBackend, ): backend is Exclude { return backend !== "zedi_managed"; } - -export function usesZediCu(backend: ComposeExecutionBackend): boolean { - return backend === "zedi_managed"; -} diff --git a/src/pages/WikiComposePage.tsx b/src/pages/WikiComposePage.tsx index 44312022..96b3e22a 100644 --- a/src/pages/WikiComposePage.tsx +++ b/src/pages/WikiComposePage.tsx @@ -10,7 +10,7 @@ * Compose UI shell. The page reads the `useWikiComposeSession` hook for state * and routes user submissions back through the hook's mutator methods. */ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { ArrowLeft, X } from "lucide-react"; @@ -29,7 +29,6 @@ import { COMPOSE_SEED_STATE_KEY, type ComposeNavigationSeed } from "@/lib/wikiCo import type { DraftedSection } from "@/lib/wikiCompose/types"; import { EditorPane } from "@/components/wikiCompose/EditorPane"; import { ComposePanel } from "@/components/wikiCompose/ComposePanel"; -import { ComposeBackendSelector } from "@/components/wikiCompose/ComposeBackendSelector"; import { useInitialComposeBackend } from "@/hooks/useInitialComposeBackend"; /** Map drafted section list to a quick lookup. */ @@ -61,13 +60,10 @@ const WikiComposePage: React.FC = () => { return s; }); - const { - backend: composeBackend, - setBackend: setComposeBackend, - isResolved: isComposeBackendResolved, - } = useInitialComposeBackend({ - enabled: !sessionId, - }); + const { backend: composeBackend, isResolved: isComposeBackendResolved } = + useInitialComposeBackend({ + enabled: !sessionId, + }); const initialInput = useMemo( () => @@ -87,7 +83,7 @@ const WikiComposePage: React.FC = () => { const session = useWikiComposeSession({ pageId, sessionId, - // Fresh compose: user picks backend then clicks Start (#951). + // Resume existing session on mount; fresh compose starts after backend resolves. autoStart: Boolean(sessionId && pageId), composeSeed, initialInput, @@ -96,7 +92,18 @@ const WikiComposePage: React.FC = () => { const awaitingComposeStart = !sessionId && session.status === "idle" && !session.session && !session.isStreaming; - const showBackendSelector = awaitingComposeStart; + const autoStartRequestedRef = useRef(false); + + // Fresh compose: start automatically once AI settings yield a backend (#951). + useEffect(() => { + if (sessionId || !isComposeBackendResolved || !awaitingComposeStart) return; + if (autoStartRequestedRef.current) return; + autoStartRequestedRef.current = true; + void session.start().catch(() => { + // Allow retry after transient create/run failures (manual Start button removed). + autoStartRequestedRef.current = false; + }); + }, [sessionId, isComposeBackendResolved, awaitingComposeStart, session.start]); // Clear history seed only after the session row left `pending` (first run claimed). // `pending` のまま state を消すと失敗時リロードで chatSeed が届かなくなる (#950)。 @@ -250,24 +257,6 @@ const WikiComposePage: React.FC = () => { {session.error ? (
{session.error}
) : null} - {showBackendSelector ? ( -
- - -
- ) : null}