diff --git a/server/api/src/__tests__/routes/composeSessionProjection.test.ts b/server/api/src/__tests__/routes/composeSessionProjection.test.ts new file mode 100644 index 00000000..9fe0c380 --- /dev/null +++ b/server/api/src/__tests__/routes/composeSessionProjection.test.ts @@ -0,0 +1,61 @@ +/** + * `composeSessionProjection` のユニットテスト (#950)。 + * Unit tests for `composeSessionProjection`. + */ +import { describe, expect, it } from "vitest"; +import { projectComposeStateValues } from "../../routes/composeSessionProjection.js"; + +describe("projectComposeStateValues", () => { + it("projects a Brief interrupt from __interrupt__", () => { + const projection = projectComposeStateValues({ + __interrupt__: [ + { + value: { + kind: "human_review_brief", + questions: [{ id: "q1", question: "Scope?", required: false, options: [] }], + pageSnapshot: { pageId: "p1", title: "T", body: "", hasContent: false }, + }, + }, + ], + }); + expect(projection.phase).toBe("brief"); + expect(projection.briefQuestions).toHaveLength(1); + expect(projection.pageSnapshot).toMatchObject({ title: "T" }); + }); + + it("keeps interrupt-derived phase when row phase is also present", () => { + const projection = projectComposeStateValues({ + phase: "brief:await_user", + __interrupt__: [ + { + value: { + kind: "human_review_research", + batch: null, + pendingSources: [], + }, + }, + ], + }); + expect(projection.phase).toBe("research"); + }); + + it("projects completion markdown from checkpoint values", () => { + const projection = projectComposeStateValues({ + phase: "completed", + completion: { + markdown: "## A\n\nBody", + sections: [ + { + sectionId: "sec-1", + heading: "A", + body: "Body", + citedSourceIds: [], + completedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }, + }); + expect(projection.completedMarkdown).toBe("## A\n\nBody"); + expect(projection.draftedSections).toHaveLength(1); + }); +}); diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts b/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts index be4a5ec6..77ad972d 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts @@ -71,7 +71,11 @@ const SYSTEM_PROMPT = "make the article unwritable. Most questions should be optional.\n" + "Respond as JSON only."; -function buildUserPrompt(title: string, body: string): string { +function buildUserPrompt( + title: string, + body: string, + chatSeed?: { outline: string; conversationText: string; userSchema?: string } | null, +): string { const parts: string[] = [`[Page title]`, title || "(no title yet)"]; if (body.trim()) { parts.push( @@ -83,6 +87,22 @@ function buildUserPrompt(title: string, body: string): string { } else { parts.push("", "(Page body is empty.)"); } + if (chatSeed?.outline?.trim()) { + parts.push("", "[User-approved outline from chat]", chatSeed.outline.trim().slice(0, 2000)); + } + if (chatSeed?.conversationText?.trim()) { + parts.push( + "", + "[Chat transcript excerpt]", + chatSeed.conversationText.trim().slice(0, 4000), + chatSeed.conversationText.length > 4000 + ? `\n(…truncated; total ${chatSeed.conversationText.length} chars)` + : "", + ); + } + if (chatSeed?.userSchema?.trim()) { + parts.push("", "[User wiki schema]", chatSeed.userSchema.trim().slice(0, 1500)); + } return parts.join("\n"); } @@ -120,7 +140,10 @@ export async function briefDialogue( try { raw = await structured.invoke([ { role: "system", content: SYSTEM_PROMPT }, - { role: "user", content: buildUserPrompt(snapshot.title, snapshot.body) }, + { + role: "user", + content: buildUserPrompt(snapshot.title, snapshot.body, state.chatSeed), + }, ]); } catch { // Defensive fallback: if the LLM call fails, emit an empty Brief so the diff --git a/server/api/src/agents/graphs/wikiCompose/state.ts b/server/api/src/agents/graphs/wikiCompose/state.ts index 03e793d6..2cac45ce 100644 --- a/server/api/src/agents/graphs/wikiCompose/state.ts +++ b/server/api/src/agents/graphs/wikiCompose/state.ts @@ -31,6 +31,7 @@ import type { ApprovedOutline, BriefQuestion, BriefResult, + ComposeChatSeed, ComposeCompletion, DraftedSection, OutlineSection, @@ -95,6 +96,17 @@ export const WikiComposeState = Annotation.Root({ ...BaseState.spec, // ── Brief phase ─────────────────────────────────────────────────────────── + /** + * チャット由来 seed(outline + 会話)。AI Chat / Promote to Wiki からの + * 初回 `POST /run` input でセットする (#950)。 + * + * Chat → Compose seed (outline + conversation). Set on the first `POST /run` + * input when the user arrives from AI Chat / Promote to Wiki (#950). + */ + chatSeed: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : next), + default: () => null, + }), /** Page snapshot loaded once at session start. */ pageSnapshot: Annotation({ reducer: (prev, next) => (next === undefined ? prev : next), diff --git a/server/api/src/agents/graphs/wikiCompose/types.ts b/server/api/src/agents/graphs/wikiCompose/types.ts index 91ec2c83..0cefd0cb 100644 --- a/server/api/src/agents/graphs/wikiCompose/types.ts +++ b/server/api/src/agents/graphs/wikiCompose/types.ts @@ -14,6 +14,20 @@ import type { Source } from "../../subgraphs/research/types.js"; /** Re-exported here so orchestrator nodes can import everything from one barrel. */ export type { Source }; +/** + * チャットから Compose に入ったときの任意 seed (#950)。 + * 初回 `POST /run` の input とセッション行 `metadata` に載せる。 + * + * Optional chat context seeded when entering Compose from AI Chat (#950). + * Passed on the first graph `input` and stored on the session row metadata. + */ +export interface ComposeChatSeed { + outline: string; + conversationText: string; + userSchema?: string; + conversationId?: string; +} + /** * Brief フェーズで Orchestrator が生成する 1 つの構造化質問。 * diff --git a/server/api/src/routes/composeSessionProjection.ts b/server/api/src/routes/composeSessionProjection.ts new file mode 100644 index 00000000..c631c537 --- /dev/null +++ b/server/api/src/routes/composeSessionProjection.ts @@ -0,0 +1,180 @@ +/** + * Project LangGraph checkpoint state into Compose UI slices (#950). + * + * `GET /compose-sessions/:id` が interrupted / completed 行を再開するとき、 + * チェックポイントから Brief 質問・アウトライン等を復元する。 + * + * Maps persisted graph state (including `__interrupt__`) into a JSON shape the + * frontend hook can merge without replaying `POST /run`. + */ +import { GRAPH_CONTEXT_CONFIG_KEY } from "../agents/core/types/graphContext.js"; +import type { GraphContext } from "../agents/core/types/graphContext.js"; +import { resolveCheckpointerForRun } from "../agents/core/checkpoint/index.js"; +import { getRegisteredGraph } from "../agents/registry/graphRegistry.js"; +import type { WikiComposeSessionStatus } from "../schema/wikiComposeSessions.js"; + +/** + * `GET /compose-sessions/:id` が返す UI projection。 + * Wire projection returned by `GET /compose-sessions/:id`. + */ +export interface ComposeSessionUiProjection { + phase?: string; + briefQuestions?: unknown[]; + pageSnapshot?: unknown; + pendingSources?: unknown[]; + latestBatch?: unknown; + approvedSources?: unknown[]; + outlineProposal?: unknown[]; + draftedSections?: unknown[]; + completedMarkdown?: string | null; +} + +function phaseFromSessionRow(phase: string, status: WikiComposeSessionStatus): string { + if (status === "completed") return "completed"; + if (phase.startsWith("brief")) return "brief"; + if (phase.startsWith("research")) return "research"; + if (phase.startsWith("structure")) return "structure"; + if (phase.startsWith("draft")) return "draft"; + return "brief"; +} + +/** + * Build UI projection from a LangGraph state snapshot (values + interrupts). + */ +export function projectComposeStateValues( + state: Record, +): ComposeSessionUiProjection { + const projection: ComposeSessionUiProjection = {}; + + if (Array.isArray(state.briefQuestions)) { + projection.briefQuestions = state.briefQuestions; + } + if (state.pageSnapshot && typeof state.pageSnapshot === "object") { + projection.pageSnapshot = state.pageSnapshot; + } + if (Array.isArray(state.pendingSources)) { + projection.pendingSources = state.pendingSources; + } + if (Array.isArray(state.batches) && state.batches.length > 0) { + projection.latestBatch = state.batches[state.batches.length - 1]; + } + if (Array.isArray(state.approvedResearch)) { + projection.approvedSources = state.approvedResearch; + } + if (Array.isArray(state.outlineProposal) && state.outlineProposal.length > 0) { + projection.outlineProposal = state.outlineProposal; + } else { + const approved = state.approvedOutline as { sections?: unknown[] } | undefined; + if (approved?.sections?.length) { + projection.outlineProposal = approved.sections; + } + } + + if (Array.isArray(state.draftedSections) && state.draftedSections.length > 0) { + projection.draftedSections = state.draftedSections; + } + + const completion = state.completion; + if (completion && typeof completion === "object") { + const c = completion as { markdown?: string; sections?: unknown[] }; + if (typeof c.markdown === "string") { + projection.completedMarkdown = c.markdown; + } + if (Array.isArray(c.sections)) { + projection.draftedSections = c.sections; + } + } + + const interrupts = state.__interrupt__; + if (Array.isArray(interrupts) && interrupts.length > 0) { + const entry = interrupts[0]; + const value = + entry && typeof entry === "object" ? (entry as { value?: unknown }).value : undefined; + if (value && typeof value === "object" && "kind" in value) { + const payload = value as { + kind: string; + questions?: unknown[]; + pageSnapshot?: unknown; + batch?: unknown; + pendingSources?: unknown[]; + outline?: unknown[]; + approvedSources?: unknown[]; + }; + switch (payload.kind) { + case "human_review_brief": + if (payload.questions) projection.briefQuestions = payload.questions; + if (payload.pageSnapshot) projection.pageSnapshot = payload.pageSnapshot; + projection.phase = "brief"; + break; + case "human_review_research": + if (payload.batch) projection.latestBatch = payload.batch; + if (payload.pendingSources) projection.pendingSources = payload.pendingSources; + projection.phase = "research"; + break; + case "human_review_outline": + if (payload.outline) projection.outlineProposal = payload.outline; + if (payload.approvedSources) projection.approvedSources = payload.approvedSources; + projection.phase = "structure"; + break; + default: + break; + } + } + } + + // Interrupt-derived phase wins; row `phase` is only a fallback. + // interrupt 由来の phase を優先し、行の phase はフォールバックのみ。 + if (typeof state.phase === "string" && projection.phase === undefined) { + projection.phase = phaseFromSessionRow(state.phase, "interrupted"); + } + + return projection; +} + +/** + * チェックポイントから UI projection を読み込む。利用不可時は `null`。 + * Load checkpoint projection for a compose session row, or `null` when unavailable. + */ +export async function loadComposeSessionProjection(input: { + sessionId: string; + pageId: string; + graphId: string; + status: WikiComposeSessionStatus; + phase: string; + context: GraphContext; +}): Promise { + if (input.status !== "interrupted" && input.status !== "completed" && input.status !== "failed") { + return null; + } + + const checkpointer = await resolveCheckpointerForRun(); + if (checkpointer === false) return null; + + const registered = getRegisteredGraph(input.graphId); + if (!registered) return null; + + const graph = registered.factory({ checkpointer }) as { + getState?: (config: unknown) => Promise<{ values?: Record } | undefined>; + }; + if (typeof graph.getState !== "function") return null; + + const config = { + configurable: { + thread_id: input.sessionId, + [GRAPH_CONTEXT_CONFIG_KEY]: input.context, + }, + }; + + try { + const snap = await graph.getState(config); + const values = snap?.values; + if (!values || typeof values !== "object") return null; + const projection = projectComposeStateValues(values); + if (!projection.phase) { + projection.phase = phaseFromSessionRow(input.phase, input.status); + } + return projection; + } catch { + return null; + } +} diff --git a/server/api/src/routes/composeSessions.ts b/server/api/src/routes/composeSessions.ts index a0100346..39ae4509 100644 --- a/server/api/src/routes/composeSessions.ts +++ b/server/api/src/routes/composeSessions.ts @@ -61,6 +61,7 @@ import { RESEARCH_GRAPH_ID } from "../agents/subgraphs/research/index.js"; import { WIKI_COMPOSE_GRAPH_ID } from "../agents/graphs/wikiCompose/index.js"; import type { AppEnv } from "../types/index.js"; import { persistOutcomeIfStillRunning } from "./composeSessionPersistence.js"; +import { loadComposeSessionProjection } from "./composeSessionProjection.js"; /** * Translate the documented `body.input.kind === "additional_research"` shape @@ -210,7 +211,40 @@ app.get("/:pageId/compose-sessions/:id", authRequired, async (c) => { .limit(1); if (!row) throw new HTTPException(404, { message: "Session not found" }); - return c.json({ session: row }); + const tier = await getUserTier(userId, db); + + // Stale / unsupported backend rows must still be readable; skip projection + // instead of turning GET into a 500 (CodeRabbit P1 on reload path). + // 古い backend 行でもセッション行は返し、projection だけ省略する。 + let projection = null; + try { + const backend = assertSupportedBackendP0(row.backend); + projection = await loadComposeSessionProjection({ + sessionId: row.id, + pageId: row.pageId, + graphId: row.graphId, + status: row.status, + phase: row.phase, + context: { + threadId: row.id, + sessionId: row.id, + userId, + userEmail: c.get("userEmail") ?? null, + pageId: row.pageId, + graphId: row.graphId, + backend, + tier, + db, + feature: `wiki_compose:${row.graphId}`, + }, + }); + } catch (err) { + if (!(err instanceof UnsupportedBackendError)) { + throw err; + } + } + + return c.json({ session: row, projection }); }); // ── POST /:id/run — SSE run ───────────────────────────────────────────────── diff --git a/src/components/ai-chat/PromoteToWikiDialog.tsx b/src/components/ai-chat/PromoteToWikiDialog.tsx index 25b59dc8..5923edff 100644 --- a/src/components/ai-chat/PromoteToWikiDialog.tsx +++ b/src/components/ai-chat/PromoteToWikiDialog.tsx @@ -22,7 +22,7 @@ import { callAIService } from "@/lib/aiService"; import { loadAISettings } from "@/lib/aiSettings"; import { useCreatePage } from "@/hooks/usePageQueries"; import { useWikiSchema } from "@/hooks/useWikiSchema"; -import type { PendingChatPageGenerationState } from "@/types/chatPageGeneration"; +import { navigateToWikiCompose } from "@/lib/wikiCompose/navigation"; import { EntityRow } from "./EntityRow"; /** @@ -258,19 +258,18 @@ function PromoteToWikiDialogBody({ if (!firstCreated) throw new Error("no pages created"); const firstEntity = selectedEntities[created.indexOf(firstCreated)]; - const pending: PendingChatPageGenerationState = { - outline: `- ${firstEntity.summary}`, - conversationText, - userSchema: schemaData?.content, - conversationId, - }; toast({ title: t("aiChat.notifications.promoteSuccess") }); onClose(); - // Issue #889 Phase 3: `/pages/:id` 撤去のため `/notes/:noteId/:pageId` に遷移。 - // Issue #889 Phase 3: route to `/notes/:noteId/:pageId` after `/pages/:id` - // was retired. - navigate(`/notes/${firstCreated.noteId}/${firstCreated.id}`, { - state: { pendingChatPageGeneration: pending }, + navigateToWikiCompose({ + navigate, + noteId: firstCreated.noteId, + pageId: firstCreated.id, + seed: { + outline: `- ${firstEntity.summary}`, + conversationText, + userSchema: schemaData?.content, + conversationId, + }, }); } catch { toast({ title: t("aiChat.notifications.promoteFailed"), variant: "destructive" }); diff --git a/src/components/editor/PageActionHub/PageActionHub.test.tsx b/src/components/editor/PageActionHub/PageActionHub.test.tsx index bca02982..a3b70418 100644 --- a/src/components/editor/PageActionHub/PageActionHub.test.tsx +++ b/src/components/editor/PageActionHub/PageActionHub.test.tsx @@ -33,6 +33,10 @@ vi.mock("react-i18next", () => ({ "editor.pageActionHub.actions.thumbnailGenerate.loading": "Generating image...", "editor.pageActionHub.actions.thumbnailGenerate.retry": "Regenerate", "editor.pageActionHub.actions.thumbnailGenerate.missingTitle": "Please enter a title", + "editor.pageActionHub.actions.wikiCompose.label": "Wiki Compose", + "editor.pageActionHub.actions.wikiCompose.description": "Co-author with AI", + "editor.pageActionHub.actions.wikiCompose.start": "Open Wiki Compose", + "editor.pageActionHub.actions.wikiCompose.unavailable": "Unavailable", }; return map[key] ?? key; }, diff --git a/src/components/editor/PageActionHub/actions/WikiComposeAction.tsx b/src/components/editor/PageActionHub/actions/WikiComposeAction.tsx new file mode 100644 index 00000000..b39454fc --- /dev/null +++ b/src/components/editor/PageActionHub/actions/WikiComposeAction.tsx @@ -0,0 +1,51 @@ +/** + * `wiki.compose` PageActionHub detail view (#950). + * + * 分割画面 Compose へ遷移する。`ctx.wikiComposeHref` が無いときは説明のみ表示。 + * + * Opens the Wiki Compose split-screen when `ctx.wikiComposeHref` is set. + */ +import React, { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { Sparkles } from "lucide-react"; +import { Button } from "@zedi/ui"; +import { useTranslation } from "react-i18next"; +import type { PageActionComponentProps } from "../types"; + +/** Detail view for the wiki.compose hub action. */ +export const WikiComposeAction: React.FC = ({ ctx, onClose }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const href = ctx.wikiComposeHref?.trim() ?? ""; + + const handleStart = useCallback(() => { + if (!href) return; + navigate(href); + onClose(); + }, [href, navigate, onClose]); + + if (!href) { + return ( +

+ {t("editor.pageActionHub.actions.wikiCompose.unavailable")} +

+ ); + } + + return ( +
+

+ {t("editor.pageActionHub.actions.wikiCompose.description")} +

+ +
+ ); +}; diff --git a/src/components/editor/PageActionHub/registry.test.ts b/src/components/editor/PageActionHub/registry.test.ts index 705430db..654ba083 100644 --- a/src/components/editor/PageActionHub/registry.test.ts +++ b/src/components/editor/PageActionHub/registry.test.ts @@ -14,13 +14,13 @@ function makeCtx(overrides: Partial = {}): PageActionContext } describe("PageActionHub registry", () => { - it("登録されているのは thumbnail.search と thumbnail.generate の 2 件 / exposes both thumbnail actions", () => { + it("登録されているアクション ID / exposes registered action ids", () => { const ids = PAGE_ACTIONS.map((a) => a.id); - expect(ids).toEqual(["thumbnail.search", "thumbnail.generate"]); + expect(ids).toEqual(["thumbnail.search", "thumbnail.generate", "wiki.compose"]); }); - it("両アクションは insertStrategy=head / both thumbnail actions are head-insert", () => { - for (const action of PAGE_ACTIONS) { + it("サムネイル系は insertStrategy=head / thumbnail actions are head-insert", () => { + for (const action of PAGE_ACTIONS.filter((a) => a.category === "thumbnail")) { expect(action.insertStrategy).toBe("head"); expect(action.category).toBe("thumbnail"); } @@ -56,14 +56,39 @@ describe("PageActionHub registry", () => { ); it("getAvailablePageActions は利用可能なアクションのみ返す / returns only available actions", () => { - const allow = getAvailablePageActions(makeCtx()); - expect(allow.map((a) => a.id)).toEqual(["thumbnail.search", "thumbnail.generate"]); + const allow = getAvailablePageActions(makeCtx({ wikiComposeHref: "/notes/n/p/compose" })); + expect(allow.map((a) => a.id)).toEqual([ + "thumbnail.search", + "thumbnail.generate", + "wiki.compose", + ]); const blockedReadOnly = getAvailablePageActions(makeCtx({ isReadOnly: true })); expect(blockedReadOnly).toEqual([]); - const blockedThumb = getAvailablePageActions(makeCtx({ hasThumbnail: true })); - expect(blockedThumb).toEqual([]); + const blockedThumb = getAvailablePageActions( + makeCtx({ hasThumbnail: true, wikiComposeHref: "/notes/n/p/compose" }), + ); + expect(blockedThumb.map((a) => a.id)).toEqual(["wiki.compose"]); + }); + + describe("wiki.compose availability gates", () => { + const action = PAGE_ACTIONS.find((a) => a.id === "wiki.compose"); + if (!action) throw new Error("missing wiki.compose"); + + it("wikiComposeHref があるとき利用可 / available when compose href is set", () => { + expect(action.isAvailable(makeCtx({ wikiComposeHref: "/notes/n/p/compose" }))).toBe(true); + }); + + it("wikiComposeHref が無いときは不可 / blocked without compose href", () => { + expect(action.isAvailable(makeCtx())).toBe(false); + }); + + it("タイトルが空のときは不可 / blocked when title is empty", () => { + expect( + action.isAvailable(makeCtx({ pageTitle: "", wikiComposeHref: "/notes/n/p/compose" })), + ).toBe(false); + }); }); it("getPageActionById は一致 ID の記述を返し、未知 ID は undefined / lookup behavior", () => { diff --git a/src/components/editor/PageActionHub/registry.ts b/src/components/editor/PageActionHub/registry.ts index a9784c6a..b4288885 100644 --- a/src/components/editor/PageActionHub/registry.ts +++ b/src/components/editor/PageActionHub/registry.ts @@ -1,6 +1,7 @@ -import { Image as ImageIcon, Wand2 } from "lucide-react"; +import { Image as ImageIcon, Sparkles, Wand2 } from "lucide-react"; import { ThumbnailSearchAction } from "./actions/ThumbnailSearchAction"; import { ThumbnailGenerateAction } from "./actions/ThumbnailGenerateAction"; +import { WikiComposeAction } from "./actions/WikiComposeAction"; import type { PageAction, PageActionContext } from "./types"; /** @@ -18,6 +19,18 @@ function isThumbnailActionAvailable(ctx: PageActionContext): boolean { return true; } +/** + * Wiki Compose 入口。タイトルがあり、Compose URL が組み立て可能なときのみ表示。 + * Available when the page has a title and a Compose route is configured. + */ +function isWikiComposeActionAvailable(ctx: PageActionContext): boolean { + if (ctx.isReadOnly) return false; + if (!ctx.isSignedIn) return false; + if (ctx.pageTitle.trim().length === 0) return false; + if (!ctx.wikiComposeHref?.trim()) return false; + return true; +} + /** * Phase 1 で利用可能なアクション一覧。配列順序が一覧グリッド上の表示順を兼ねる。 * 後続フェーズで WebClipper / Mermaid / AI / テンプレートを末尾に追加していく。 @@ -46,6 +59,16 @@ export const PAGE_ACTIONS: ReadonlyArray = [ isAvailable: isThumbnailActionAvailable, Component: ThumbnailGenerateAction, }, + { + id: "wiki.compose", + labelI18nKey: "editor.pageActionHub.actions.wikiCompose.label", + descriptionI18nKey: "editor.pageActionHub.actions.wikiCompose.description", + icon: Sparkles, + category: "ai", + insertStrategy: "custom", + isAvailable: isWikiComposeActionAvailable, + Component: WikiComposeAction, + }, ]; /** diff --git a/src/components/editor/PageActionHub/types.ts b/src/components/editor/PageActionHub/types.ts index 21df2d71..62a8a5da 100644 --- a/src/components/editor/PageActionHub/types.ts +++ b/src/components/editor/PageActionHub/types.ts @@ -27,6 +27,11 @@ export interface PageActionContext { * to the existing `useThumbnailController`'s `handleInsertThumbnailImage`. */ insertThumbnail: (imageUrl: string, alt: string, previewUrl?: string) => void; + /** + * Wiki Compose 画面 URL。ノートネイティブページでのみ設定される (#950)。 + * Route to the Wiki Compose split-screen; set only on note-native pages. + */ + wikiComposeHref?: string; } /** diff --git a/src/components/editor/TiptapEditor.tsx b/src/components/editor/TiptapEditor.tsx index b0d20573..47e7f991 100644 --- a/src/components/editor/TiptapEditor.tsx +++ b/src/components/editor/TiptapEditor.tsx @@ -50,6 +50,7 @@ const TiptapEditor: React.FC = ({ wikiContentForCollab, onWikiContentApplied, pageNoteId = null, + wikiComposeHref, bottomBarTrailingAction, }) => { const { t } = useTranslation(); @@ -117,6 +118,7 @@ const TiptapEditor: React.FC = ({ wikiContentForCollab, onWikiContentApplied, pageNoteId, + wikiComposeHref, }); // 入力バーへフォーカスを移すための imperative ハンドル(issue #928 §Cmd+K)。 diff --git a/src/components/editor/TiptapEditor/types.ts b/src/components/editor/TiptapEditor/types.ts index bf5478ce..8a70bf83 100644 --- a/src/components/editor/TiptapEditor/types.ts +++ b/src/components/editor/TiptapEditor/types.ts @@ -82,6 +82,11 @@ export interface TiptapEditorProps { * Phase 4. */ pageNoteId?: string | null; + /** + * Wiki Compose 画面 URL。PageActionHub の `wiki.compose` と同経路 (#950)。 + * Route to the Wiki Compose UI; used by PageActionHub `wiki.compose`. + */ + wikiComposeHref?: string; /** * 画面下部の Wiki Link 入力バー右隣に並べるアクション(例: PageActionHub FAB)。 * Trailing control rendered beside the floating Wiki Link input bar. diff --git a/src/components/editor/TiptapEditor/useTiptapEditorController.ts b/src/components/editor/TiptapEditor/useTiptapEditorController.ts index 74f4c348..f783a33f 100644 --- a/src/components/editor/TiptapEditor/useTiptapEditorController.ts +++ b/src/components/editor/TiptapEditor/useTiptapEditorController.ts @@ -172,6 +172,7 @@ export function useTiptapEditorController({ wikiContentForCollab, onWikiContentApplied, pageNoteId = null, + wikiComposeHref, }: TiptapEditorProps) { const { editorFontSizePx } = useGeneralSettings(); const { isSignedIn } = useAuth(); @@ -287,8 +288,9 @@ export function useTiptapEditorController({ isSignedIn, hasThumbnail, insertThumbnail: handleInsertThumbnailImage, + wikiComposeHref, }), - [pageTitle, isReadOnly, isSignedIn, hasThumbnail, handleInsertThumbnailImage], + [pageTitle, isReadOnly, isSignedIn, hasThumbnail, handleInsertThumbnailImage, wikiComposeHref], ); return { diff --git a/src/components/note/PageEditorContent.tsx b/src/components/note/PageEditorContent.tsx index 0bc45896..168db918 100644 --- a/src/components/note/PageEditorContent.tsx +++ b/src/components/note/PageEditorContent.tsx @@ -257,6 +257,7 @@ export const PageEditorContent: React.FC = ({ wikiContentForCollab={wikiContentForCollab ?? undefined} onWikiContentApplied={onWikiContentApplied} pageNoteId={pageNoteId} + wikiComposeHref={wikiComposeHref} bottomBarTrailingAction={bottomBarTrailingAction} /> diff --git a/src/hooks/runAIChatAction.test.ts b/src/hooks/runAIChatAction.test.ts index 930adae0..f4661c04 100644 --- a/src/hooks/runAIChatAction.test.ts +++ b/src/hooks/runAIChatAction.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { runAIChatAction, type RunAIChatActionDeps } from "./runAIChatAction"; +import { COMPOSE_SEED_STATE_KEY } from "@/lib/wikiCompose/navigation"; import type { ChatMessage } from "@/types/aiChat"; const t = ((key: string, opts?: Record) => { @@ -46,7 +47,7 @@ describe("runAIChatAction — create", () => { vi.clearAllMocks(); }); - it("create-page: creates empty page and navigates with pendingChatPageGeneration state", async () => { + it("create-page: creates empty page and navigates to Wiki Compose with chat seed", async () => { const createPageMutateAsync = vi.fn().mockResolvedValue({ id: "new-page-1", noteId: "note-1" }); const navigate = vi.fn(); const messages: ChatMessage[] = [{ id: "1", role: "user", content: "hello", timestamp: 1 }]; @@ -64,9 +65,9 @@ describe("runAIChatAction — create", () => { title: "Wiki Topic", content: "", }); - expect(navigate).toHaveBeenCalledWith("/notes/note-1/new-page-1", { + expect(navigate).toHaveBeenCalledWith("/notes/note-1/new-page-1/compose", { state: { - pendingChatPageGeneration: { + [COMPOSE_SEED_STATE_KEY]: { outline: "- A\n- B", conversationText: expect.stringContaining("hello"), }, @@ -97,9 +98,9 @@ describe("runAIChatAction — create", () => { }); expect(createPageMutateAsync).toHaveBeenCalledTimes(2); - expect(navigate).toHaveBeenCalledWith("/notes/note-1/p1", { + expect(navigate).toHaveBeenCalledWith("/notes/note-1/p1/compose", { state: { - pendingChatPageGeneration: { + [COMPOSE_SEED_STATE_KEY]: { outline: "- o1", conversationText: expect.stringContaining("ctx"), }, @@ -129,9 +130,9 @@ describe("runAIChatAction — create", () => { reason: "multi", }); - expect(navigate).toHaveBeenCalledWith("/notes/note-1/p1", { + expect(navigate).toHaveBeenCalledWith("/notes/note-1/p1/compose", { state: { - pendingChatPageGeneration: { + [COMPOSE_SEED_STATE_KEY]: { outline: "- from-second", conversationText: expect.stringContaining("ctx"), }, diff --git a/src/hooks/runAIChatAction.ts b/src/hooks/runAIChatAction.ts index 02b5082d..2b5c0b26 100644 --- a/src/hooks/runAIChatAction.ts +++ b/src/hooks/runAIChatAction.ts @@ -10,7 +10,7 @@ import type { SuggestWikiLinksAction, } from "@/types/aiChat"; import type { PageContext } from "@/types/aiChat"; -import type { PendingChatPageGenerationState } from "@/types/chatPageGeneration"; +import { navigateToWikiCompose } from "@/lib/wikiCompose/navigation"; import { buildSuggestedWikiLinksMarkdown, getCreatePageOutline, @@ -53,20 +53,19 @@ async function handleCreatePage( ): Promise { const outline = getCreatePageOutline(action); const conversationText = serializeChatMessagesForPageGeneration(deps.messages); - const pending: PendingChatPageGenerationState = { outline, conversationText }; const result = await deps.createPageMutateAsync({ title: action.title, content: "", }); if (result?.id && result.noteId) { - // Issue #889 Phase 3: `/pages/:id` 撤去のため `/notes/:noteId/:pageId` に遷移。 - // `noteId` が無い場合は不正な URL になるので遷移しない(バックエンドの - // 想定外応答に対する防御)。 - // Issue #889 Phase 3: route to `/notes/:noteId/:pageId` (legacy - // `/pages/:id` route was retired). Skip navigation when `noteId` is - // missing so we never build `/notes/undefined/...`. - deps.navigate(`/notes/${result.noteId}/${result.id}`, { - state: { pendingChatPageGeneration: pending }, + // Issue #950: 旧 `pendingChatPageGeneration` インライン生成の代わりに + // Wiki Compose 分割画面へ遷移し、チャット文脈を seed する。 + // Issue #950: open Wiki Compose instead of inline generation on the page. + navigateToWikiCompose({ + navigate: deps.navigate, + noteId: result.noteId, + pageId: result.id, + seed: { outline, conversationText }, }); } } @@ -98,17 +97,11 @@ async function handleCreateMultiplePages( } } if (firstCreated?.id && firstCreated.noteId) { - const pending: PendingChatPageGenerationState = { - outline: firstOutline, - conversationText, - }; - // Issue #889 Phase 3: `/pages/:id` 撤去のため `/notes/:noteId/:pageId` に遷移。 - // `noteId` 欠落時は不正な URL になるため遷移を打ち切る。 - // Issue #889 Phase 3: route to `/notes/:noteId/:pageId` (legacy - // `/pages/:id` route was retired). Skip navigation when `noteId` is - // missing so we never build `/notes/undefined/...`. - deps.navigate(`/notes/${firstCreated.noteId}/${firstCreated.id}`, { - state: { pendingChatPageGeneration: pending }, + navigateToWikiCompose({ + navigate: deps.navigate, + noteId: firstCreated.noteId, + pageId: firstCreated.id, + seed: { outline: firstOutline, conversationText }, }); } } diff --git a/src/hooks/useWikiComposeSession.test.ts b/src/hooks/useWikiComposeSession.test.ts index fd2601b6..4b84e358 100644 --- a/src/hooks/useWikiComposeSession.test.ts +++ b/src/hooks/useWikiComposeSession.test.ts @@ -238,6 +238,52 @@ describe("useWikiComposeSession", () => { expect(result.current.phase).toBe("research"); }); + it("ignores malformed metadata.composeSeed when building run input", async () => { + mocks.createSession.mockResolvedValue({ + ...SESSION, + metadata: { composeSeed: { outline: 1, conversationText: true } }, + }); + arrangeRun([{ type: "done", status: "completed" }]); + + const { result } = renderHook(() => + useWikiComposeSession({ pageId: "page-1", sessionId: null }), + ); + await waitFor(() => expect(result.current.session).not.toBeNull()); + + expect(mocks.runSession).toHaveBeenCalledWith( + expect.objectContaining({ + body: undefined, + }), + ); + }); + + it("hydrates interrupted session from GET projection without POST /run", async () => { + mocks.createSession.mockReset(); + mocks.getSession.mockResolvedValue({ + session: { ...SESSION, status: "interrupted", phase: "brief:await_user" }, + projection: { + phase: "brief", + briefQuestions: [ + { + id: "q1", + question: "Reloaded?", + required: false, + options: [], + }, + ], + pageSnapshot: { pageId: "page-1", title: "T", body: "", hasContent: false }, + }, + }); + + const { result } = renderHook(() => + useWikiComposeSession({ pageId: "page-1", sessionId: "sess-1" }), + ); + + await waitFor(() => expect(result.current.briefQuestions).toHaveLength(1)); + expect(result.current.briefQuestions[0]?.question).toBe("Reloaded?"); + expect(mocks.runSession).not.toHaveBeenCalled(); + }); + it("submitBrief calls resumeSession with the answer payload and re-streams", async () => { // Initial run: halt at Brief. arrangeRun([ diff --git a/src/hooks/useWikiComposeSession.ts b/src/hooks/useWikiComposeSession.ts index bd54fb06..e182c75e 100644 --- a/src/hooks/useWikiComposeSession.ts +++ b/src/hooks/useWikiComposeSession.ts @@ -19,12 +19,14 @@ import { resumeSession, runSession, } from "@/lib/wikiCompose/composeService"; +import type { ComposeNavigationSeed } from "@/lib/wikiCompose/navigation"; import type { BriefAnswer, BriefQuestion, ComposeInterruptPayload, ComposeSession, ComposeSessionStatus, + ComposeSessionUiProjection, ComposeSseEvent, DraftedSection, OutlineSection, @@ -105,8 +107,16 @@ export interface UseWikiComposeSessionArgs { pageId: string; /** Existing session to resume; pass `null` to create a fresh session on start. */ sessionId: string | null; - /** Optional initial body for the first run (e.g. seed messages). */ + /** + * 初回 `POST /run` に渡す graph input(例: `chatSeed`)。 + * Initial graph input for the first `POST /run` (e.g. `{ chatSeed }`). + */ initialInput?: Record; + /** + * チャット由来 seed。セッション行 `metadata` にも保存する。 + * Chat-origin seed; also persisted on the session row metadata. + */ + composeSeed?: ComposeNavigationSeed; /** Auto-start the first `run` when the session is created. Default `true`. */ autoStart?: boolean; } @@ -138,6 +148,43 @@ export interface UseWikiComposeSessionReturn extends WikiComposeSessionState { * available (modern browsers); falls back to a coarse fallback for old * environments and SSR. */ +/** + * `session.metadata.composeSeed` を型検証して graph input 用 seed にする。 + * Validate persisted `metadata.composeSeed` before sending `/run` input. + */ +function parseComposeSeedFromMetadata(metadata: Record | null | undefined): + | { + outline: string; + conversationText: string; + userSchema?: string; + conversationId?: string; + } + | undefined { + if (!metadata || typeof metadata !== "object") return undefined; + const raw = metadata.composeSeed; + if (!raw || typeof raw !== "object") return undefined; + const seed = raw as Record; + if (typeof seed.outline !== "string" || typeof seed.conversationText !== "string") { + return undefined; + } + const out: { + outline: string; + conversationText: string; + userSchema?: string; + conversationId?: string; + } = { + outline: seed.outline, + conversationText: seed.conversationText, + }; + if (typeof seed.userSchema === "string" && seed.userSchema.trim()) { + out.userSchema = seed.userSchema; + } + if (typeof seed.conversationId === "string" && seed.conversationId.trim()) { + out.conversationId = seed.conversationId; + } + return out; +} + function activityId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); return `act-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -306,6 +353,32 @@ function appendActivity( * interrupted checkpoint (invalid for LangGraph) and drop `completion` on the * final outline approve path. */ +/** + * Merge `GET /compose-sessions/:id` checkpoint projection into hook state (#950). + * `GET` の projection をフック state にマージする(リロード再開用)。 + */ +function hydrateFromProjection( + projection: ComposeSessionUiProjection, +): Partial { + const partial: Partial = {}; + if (projection.phase) partial.phase = projection.phase; + if (projection.briefQuestions?.length) partial.briefQuestions = projection.briefQuestions; + if (projection.pageSnapshot) partial.pageSnapshot = projection.pageSnapshot; + if (projection.latestBatch !== undefined) partial.latestBatch = projection.latestBatch; + if (projection.pendingSources?.length) partial.pendingSources = projection.pendingSources; + if (projection.approvedSources?.length) partial.approvedSources = projection.approvedSources; + if (projection.outlineProposal?.length) partial.outlineProposal = projection.outlineProposal; + if (projection.completedMarkdown) partial.completedMarkdown = projection.completedMarkdown; + if (projection.draftedSections?.length) { + const draftedSections: Record = {}; + for (const section of projection.draftedSections) { + if (section?.sectionId) draftedSections[section.sectionId] = section; + } + partial.draftedSections = draftedSections; + } + return partial; +} + function reduceResumeOutput( output: unknown, status: ComposeSessionStatus, @@ -402,7 +475,7 @@ function reduceInterrupt( export function useWikiComposeSession( args: UseWikiComposeSessionArgs, ): UseWikiComposeSessionReturn { - const { pageId, sessionId: initialSessionId, initialInput, autoStart = true } = args; + const { pageId, sessionId: initialSessionId, initialInput, composeSeed, autoStart = true } = args; const [state, setState] = useState(INITIAL_STATE); const sessionRef = useRef(null); const abortRef = useRef(null); @@ -451,22 +524,49 @@ export function useWikiComposeSession( /** Create or resume the session, then begin streaming. */ const start = useCallback(async () => { try { - const session = initialSessionId - ? await getSession(pageId, initialSessionId) - : await createSession({ pageId }); + const loaded = initialSessionId ? await getSession(pageId, initialSessionId) : null; + const session = + loaded?.session ?? + (await createSession({ + pageId, + metadata: composeSeed + ? { + composeSeed: { + outline: composeSeed.outline, + conversationText: composeSeed.conversationText, + userSchema: composeSeed.userSchema, + conversationId: composeSeed.conversationId, + }, + } + : undefined, + })); + const projectionHydration = loaded?.projection + ? hydrateFromProjection(loaded.projection) + : {}; + sessionRef.current = session; - update({ session, status: session.status, error: null }); + update({ session, status: session.status, error: null, ...projectionHydration }); + + const metadataSeed = parseComposeSeedFromMetadata(session.metadata); + const runInput = + initialInput ?? + (metadataSeed + ? { + chatSeed: metadataSeed, + } + : undefined); + // Only fresh / retriable rows may call `POST /run` with graph input. // Interrupted checkpoints require `Command({ resume })`; replaying input // would restart or error, and resume payloads are not stored on the row. if (session.status === "pending" || session.status === "failed") { - await streamRun(session, initialInput); + await streamRun(session, runInput); } } catch (err) { const message = err instanceof Error ? err.message : String(err); update({ error: message }); } - }, [pageId, initialSessionId, initialInput, streamRun, update]); + }, [pageId, initialSessionId, initialInput, composeSeed, streamRun, update]); const submitBrief = useCallback( async (input) => { diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 5d4b5db1..17cd9968 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -254,6 +254,12 @@ "loading": "Generating image...", "retry": "Regenerate", "missingTitle": "Please enter a title" + }, + "wikiCompose": { + "label": "Wiki Compose", + "description": "Co-author this page with AI in a guided Brief → research → outline → draft flow.", + "start": "Open Wiki Compose", + "unavailable": "Wiki Compose is not available for this page." } } }, diff --git a/src/i18n/locales/ja/editor.json b/src/i18n/locales/ja/editor.json index 0a3c575e..48d4a562 100644 --- a/src/i18n/locales/ja/editor.json +++ b/src/i18n/locales/ja/editor.json @@ -254,6 +254,12 @@ "loading": "画像を生成中...", "retry": "再生成", "missingTitle": "タイトルを入力してください" + }, + "wikiCompose": { + "label": "Wiki Compose", + "description": "Brief → 調査 → 構成 → 執筆のガイド付きフローで AI と協働してページを作成します。", + "start": "Wiki Compose を開く", + "unavailable": "このページでは Wiki Compose を利用できません。" } } }, diff --git a/src/lib/wikiCompose/composeService.ts b/src/lib/wikiCompose/composeService.ts index bb7a2a14..69dfff6d 100644 --- a/src/lib/wikiCompose/composeService.ts +++ b/src/lib/wikiCompose/composeService.ts @@ -10,7 +10,12 @@ * built around the wire spec produced by `streamSSE` on the server (each event * is `event: \n` + `data: \n\n`). */ -import type { ComposeSession, ComposeSseEvent, ComposeSessionStatus } from "./types"; +import type { + ComposeSession, + ComposeSessionUiProjection, + ComposeSseEvent, + ComposeSessionStatus, +} from "./types"; import { WIKI_COMPOSE_GRAPH_ID } from "./types"; const getApiBaseUrl = () => (import.meta.env.VITE_API_BASE_URL as string) ?? ""; @@ -70,15 +75,33 @@ export async function createSession(input: CreateSessionInput): Promise { +/** + * `GET /compose-sessions/:id` の応答(行 + 任意の checkpoint projection)。 + * Response of `GET /compose-sessions/:id` (session row + optional checkpoint projection). + */ +export interface GetComposeSessionResult { + session: ComposeSession; + projection: ComposeSessionUiProjection | null; +} + +/** + * Compose セッション行と、再開用 projection を取得する (#950)。 + * Fetch a compose session row and optional UI projection for reload. + */ +export async function getSession( + pageId: string, + sessionId: string, +): Promise { const apiBase = getApiBaseUrl(); const res = await fetch( `${apiBase}/api/pages/${encodeURIComponent(pageId)}/compose-sessions/${encodeURIComponent(sessionId)}`, { ...REST_OPTS, method: "GET" }, ); - const data = await jsonOrThrow<{ session: ComposeSession }>(res, "getSession"); - return data.session; + const data = await jsonOrThrow<{ + session: ComposeSession; + projection?: ComposeSessionUiProjection | null; + }>(res, "getSession"); + return { session: data.session, projection: data.projection ?? null }; } /** Cancel a compose session (sets status=cancelled). */ diff --git a/src/lib/wikiCompose/navigation.test.ts b/src/lib/wikiCompose/navigation.test.ts new file mode 100644 index 00000000..40af07bc --- /dev/null +++ b/src/lib/wikiCompose/navigation.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from "vitest"; +import { COMPOSE_SEED_STATE_KEY, navigateToWikiCompose, wikiComposePath } from "./navigation"; + +describe("wikiCompose navigation", () => { + it("wikiComposePath builds the compose route", () => { + expect(wikiComposePath("note-1", "page-1")).toBe("/notes/note-1/page-1/compose"); + }); + + it("navigateToWikiCompose forwards optional seed on location state", () => { + const navigate = vi.fn(); + navigateToWikiCompose({ + navigate, + noteId: "note-1", + pageId: "page-1", + seed: { outline: "- a", conversationText: "User: hi" }, + }); + expect(navigate).toHaveBeenCalledWith("/notes/note-1/page-1/compose", { + state: { + [COMPOSE_SEED_STATE_KEY]: { outline: "- a", conversationText: "User: hi" }, + }, + }); + }); + + it("navigateToWikiCompose omits state when seed is absent", () => { + const navigate = vi.fn(); + navigateToWikiCompose({ navigate, noteId: "n", pageId: "p" }); + expect(navigate).toHaveBeenCalledWith("/notes/n/p/compose", { state: undefined }); + }); +}); diff --git a/src/lib/wikiCompose/navigation.ts b/src/lib/wikiCompose/navigation.ts new file mode 100644 index 00000000..99d1fa0d --- /dev/null +++ b/src/lib/wikiCompose/navigation.ts @@ -0,0 +1,62 @@ +/** + * Wiki Compose navigation helpers (#950). + * + * チャットからのページ作成や Promote to Wiki など、旧 + * `pendingChatPageGeneration` navigate state の代わりに Compose 画面へ遷移する。 + * + * Replaces the legacy `pendingChatPageGeneration` location state with a direct + * route to the split-screen Compose UI. Optional seed data is forwarded so the + * orchestrator can bias the Brief phase. + */ +import type { NavigateFunction } from "react-router-dom"; +import type { PendingChatPageGenerationState } from "@/types/chatPageGeneration"; + +/** + * `location.state` に載せる Compose seed のキー(チャット → ページ → compose)。 + * Location state key for Compose seed data (chat → page → compose). + */ +export const COMPOSE_SEED_STATE_KEY = "composeSeed" as const; + +/** + * チャット経由で Compose に入るときの seed。 + * Seed payload stored on `location.state` when entering Compose from chat. + */ +export type ComposeNavigationSeed = PendingChatPageGenerationState; + +/** + * `navigateToWikiCompose` の引数。 + * Parameters for {@link navigateToWikiCompose}. + */ +export interface NavigateToWikiComposeParams { + navigate: NavigateFunction; + noteId: string; + pageId: string; + /** + * Brief / 調査に渡す任意のチャット文脈。 + * Optional chat context to seed the Brief / research phases. + */ + seed?: ComposeNavigationSeed; +} + +/** + * ページの Wiki Compose 分割画面へ遷移する。`seed` があるときは location state に載せる。 + * Navigate to the Wiki Compose split-screen; `seed` is stored on location state when set. + */ +export function navigateToWikiCompose({ + navigate, + noteId, + pageId, + seed, +}: NavigateToWikiComposeParams): void { + navigate(`/notes/${noteId}/${pageId}/compose`, { + state: seed ? { [COMPOSE_SEED_STATE_KEY]: seed } : undefined, + }); +} + +/** + * ノートネイティブページ向け Compose URL(ツールバー / PageActionHub 入口)。 + * Build the Compose URL for a note-native page (toolbar / PageActionHub entry). + */ +export function wikiComposePath(noteId: string, pageId: string): string { + return `/notes/${noteId}/${pageId}/compose`; +} diff --git a/src/lib/wikiCompose/types.ts b/src/lib/wikiCompose/types.ts index 6344eab5..f6d5acba 100644 --- a/src/lib/wikiCompose/types.ts +++ b/src/lib/wikiCompose/types.ts @@ -114,6 +114,22 @@ export interface ComposeSession { updatedAt: string; } +/** + * Checkpoint projection returned by `GET /compose-sessions/:id` for reload (#950). + * チェックポイントから復元した UI 用スライス。 + */ +export interface ComposeSessionUiProjection { + phase?: "brief" | "research" | "structure" | "draft" | "completed"; + briefQuestions?: BriefQuestion[]; + pageSnapshot?: PageSnapshot; + pendingSources?: ResearchSource[]; + latestBatch?: ResearchBatch | null; + approvedSources?: ResearchSource[]; + outlineProposal?: OutlineSection[]; + draftedSections?: DraftedSection[]; + completedMarkdown?: string | null; +} + // ── Interrupt payloads (discriminated union) ─────────────────────────────── export type ComposeInterruptPayload = | { diff --git a/src/pages/WikiComposePage.tsx b/src/pages/WikiComposePage.tsx index a26b1834..8dd3b292 100644 --- a/src/pages/WikiComposePage.tsx +++ b/src/pages/WikiComposePage.tsx @@ -10,8 +10,8 @@ * 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 } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import React, { useEffect, useMemo, useState } from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; import { ArrowLeft, X } from "lucide-react"; import { Alert, @@ -24,6 +24,7 @@ import { useIsMobile, } from "@zedi/ui"; import { useWikiComposeSession } from "@/hooks/useWikiComposeSession"; +import { COMPOSE_SEED_STATE_KEY, type ComposeNavigationSeed } from "@/lib/wikiCompose/navigation"; import type { DraftedSection } from "@/lib/wikiCompose/types"; import { EditorPane } from "@/components/wikiCompose/EditorPane"; import { ComposePanel } from "@/components/wikiCompose/ComposePanel"; @@ -39,18 +40,65 @@ function indexById(items: DraftedSection[]): Record { const WikiComposePage: React.FC = () => { const params = useParams<{ noteId: string; pageId: string; sessionId?: string }>(); const navigate = useNavigate(); + const location = useLocation(); const isMobile = useIsMobile(); const noteId = params.noteId ?? ""; const pageId = params.pageId ?? ""; const sessionId = params.sessionId ?? null; + // チャット seed は mount 時に 1 回だけ保持。`location.state` を消しても hook 側に残す。 + // Capture chat seed once on mount; survives clearing `location.state` for the hook. + const [composeSeed] = useState((): ComposeNavigationSeed | undefined => { + const raw = (location.state as Record | null)?.[COMPOSE_SEED_STATE_KEY]; + if (!raw || typeof raw !== "object") return undefined; + const s = raw as ComposeNavigationSeed; + if (typeof s.outline !== "string" || typeof s.conversationText !== "string") return undefined; + return s; + }); + + const initialInput = useMemo( + () => + composeSeed + ? { + chatSeed: { + outline: composeSeed.outline, + conversationText: composeSeed.conversationText, + userSchema: composeSeed.userSchema, + conversationId: composeSeed.conversationId, + }, + } + : undefined, + [composeSeed], + ); + const session = useWikiComposeSession({ pageId, sessionId, autoStart: Boolean(pageId), + composeSeed, + initialInput, }); + // Clear history seed only after the session row left `pending` (first run claimed). + // `pending` のまま state を消すと失敗時リロードで chatSeed が届かなくなる (#950)。 + useEffect(() => { + if (!composeSeed || !location.state) return; + if (session.status === "idle" || session.status === "pending") return; + navigate(location.pathname + location.search + location.hash, { + replace: true, + state: null, + }); + }, [ + composeSeed, + location.hash, + location.pathname, + location.search, + location.state, + navigate, + session.status, + ]); + // Persist the session id in the URL so refresh re-opens the same row. useEffect(() => { const id = session.session?.id;