diff --git a/apps/desktop/src/components/EditorAIAssistantDock.test.tsx b/apps/desktop/src/components/EditorAIAssistantDock.test.tsx index 2b8fa1f..0d3106b 100644 --- a/apps/desktop/src/components/EditorAIAssistantDock.test.tsx +++ b/apps/desktop/src/components/EditorAIAssistantDock.test.tsx @@ -26,26 +26,33 @@ const assistantEntry: AITaskEntryPoint = { function renderDock(overrides?: Partial>) { const onSubmit = overrides?.onSubmit ?? vi.fn(); + const props: ComponentProps = { + canApplyDrafts: true, + canSubmit: true, + currentDocumentSource: "# 현재 본문", + entries: [assistantEntry], + isOpen: true, + isPending: false, + isVisible: true, + messages: [], + onApplyDraft: vi.fn(), + onClose: vi.fn(), + onCopyDraft: vi.fn(), + onPromptBlur: vi.fn(), + onPromptChange: vi.fn(), + onSelectEntry: vi.fn(), + onSubmit, + onToggleCompare: vi.fn(), + onToggleOpen: vi.fn(), + onUndoDraftApply: vi.fn(), + prompt: "한글 입력", + selectedEntry: assistantEntry, + ...overrides, + }; render( - + , ); @@ -102,4 +109,79 @@ describe("EditorAIAssistantDock", () => { expect(screen.getByText("길어진 답변").parentElement?.className).toContain("overflow-y-auto"); }); + + it("renders proposal actions for assistant messages with an applyable draft", () => { + const onCopyDraft = vi.fn(); + + renderDock({ + messages: [ + { + id: "assistant-1", + role: "assistant", + content: "## Recommendation\n\n요약", + format: "markdown", + proposal: { + recommendation: "문서 구조를 더 명확히 정리합니다.", + draftMarkdown: "## Architecture\n\n정리된 본문", + notes: null, + }, + isStreaming: false, + }, + ], + onCopyDraft, + }); + + expect(screen.getByRole("button", { name: "초안 적용" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "복사" })); + expect(screen.getByRole("button", { name: "비교 보기" })).toBeTruthy(); + expect(onCopyDraft).toHaveBeenCalledWith("assistant-1"); + }); + + it("disables the apply action when the edit lock is unavailable", () => { + renderDock({ + canApplyDrafts: false, + messages: [ + { + id: "assistant-1", + role: "assistant", + content: "## Recommendation\n\n요약", + format: "markdown", + proposal: { + recommendation: "문서 구조를 더 명확히 정리합니다.", + draftMarkdown: "## Architecture\n\n정리된 본문", + notes: null, + }, + isStreaming: false, + }, + ], + }); + + expect(screen.getByRole("button", { name: "초안 적용" }).hasAttribute("disabled")).toBe(true); + expect(screen.getByText("편집 잠금을 보유 중일 때만 초안을 적용할 수 있습니다.")).toBeTruthy(); + }); + + it("toggles compare content when requested by the parent", () => { + renderDock({ + messages: [ + { + id: "assistant-1", + role: "assistant", + content: "## Recommendation\n\n요약", + format: "markdown", + proposal: { + recommendation: "문서 구조를 더 명확히 정리합니다.", + draftMarkdown: "## Architecture\n\n정리된 본문", + notes: "메모", + }, + isStreaming: false, + isCompareOpen: true, + }, + ], + }); + + expect(screen.getByText("현재 본문")).toBeTruthy(); + expect(screen.getByText("제안 초안")).toBeTruthy(); + expect(screen.getAllByText("메모").length).toBe(2); + expect(screen.getByRole("button", { name: "비교 닫기" })).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/components/EditorAIAssistantDock.tsx b/apps/desktop/src/components/EditorAIAssistantDock.tsx index f32ec54..d9134cc 100644 --- a/apps/desktop/src/components/EditorAIAssistantDock.tsx +++ b/apps/desktop/src/components/EditorAIAssistantDock.tsx @@ -1,5 +1,5 @@ import { useRef, type FocusEventHandler, type KeyboardEventHandler } from "react"; -import { Bot, LoaderCircle, SendHorizontal, X } from "lucide-react"; +import { Bot, Copy, LoaderCircle, RefreshCcw, SendHorizontal, WandSparkles, X } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { useFloatingDockItem } from "@/components/FloatingDockProvider"; @@ -8,7 +8,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Textarea } from "@/components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { cn } from "@/lib/utils"; -import type { AITaskEntryPoint } from "../types/domain-ui"; +import type { AITaskEntryPoint, EditorAIDraftProposal } from "../types/domain-ui"; export type EditorAIAssistantMessage = { id: string; @@ -18,6 +18,10 @@ export type EditorAIAssistantMessage = { format?: "markdown" | "plain"; isStreaming?: boolean; tone?: "default" | "warning" | "danger"; + proposal?: EditorAIDraftProposal | null; + isCompareOpen?: boolean; + canUndoApply?: boolean; + statusText?: string | null; }; function normalizeStreamingMarkdown(content: string, isStreaming: boolean) { @@ -39,16 +43,22 @@ export function EditorAIAssistantDock({ isPending, isVisible, messages, + currentDocumentSource, prompt, promptError, selectedEntry, canSubmit, + canApplyDrafts, onClose, + onApplyDraft, + onCopyDraft, onPromptBlur, onPromptChange, onSelectEntry, onSubmit, + onToggleCompare, onToggleOpen, + onUndoDraftApply, }: { entries: AITaskEntryPoint[]; emptyStateMessage?: string; @@ -56,16 +66,22 @@ export function EditorAIAssistantDock({ isPending: boolean; isVisible: boolean; messages: EditorAIAssistantMessage[]; + currentDocumentSource: string; prompt: string; promptError?: string | null; selectedEntry: AITaskEntryPoint | null; canSubmit: boolean; + canApplyDrafts: boolean; onClose: () => void; + onApplyDraft: (messageId: string) => void; + onCopyDraft: (messageId: string) => void; onPromptBlur: FocusEventHandler; onPromptChange: (value: string) => void; onSelectEntry: (id: string) => void; onSubmit: () => void; + onToggleCompare: (messageId: string) => void; onToggleOpen: () => void; + onUndoDraftApply: (messageId: string) => void; }) { const isPromptComposingRef = useRef(false); @@ -215,6 +231,105 @@ export function EditorAIAssistantDock({ )} + {message.role === "assistant" && + message.proposal && + !message.isStreaming ? ( +
+ {message.proposal.recommendation ? ( +
+ {message.proposal.recommendation} +
+ ) : null} +
+ + + + {message.canUndoApply ? ( + + ) : null} +
+ {!canApplyDrafts ? ( +

+ 편집 잠금을 보유 중일 때만 초안을 적용할 수 있습니다. +

+ ) : null} + {message.statusText ? ( +

+ {message.statusText} +

+ ) : null} + {message.isCompareOpen ? ( +
+
+

+ 현재 본문 +

+
+                                    {currentDocumentSource}
+                                  
+
+
+

+ 제안 초안 +

+
+                                    {message.proposal.draftMarkdown}
+                                  
+
+ {message.proposal.notes ? ( +
+

+ 메모 +

+
+ {message.proposal.notes} +
+
+ ) : null} +
+ ) : null} +
+ ) : null} ))} {isPending ? ( diff --git a/apps/desktop/src/lib/aiDraftProposal.test.ts b/apps/desktop/src/lib/aiDraftProposal.test.ts new file mode 100644 index 0000000..a45b091 --- /dev/null +++ b/apps/desktop/src/lib/aiDraftProposal.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { parseEditorAIDraftProposal } from "./aiDraftProposal"; + +describe("parseEditorAIDraftProposal", () => { + it("parses recommendation, draft markdown, and notes from a well-formed response", () => { + const proposal = parseEditorAIDraftProposal(` +## Recommendation + +Keep the architecture summary concise and make the contract boundary explicit. + +## Draft Markdown + +\`\`\`md +## Architecture + +The desktop app initiates the publish flow. +\`\`\` + +## Notes + +Preserve the existing review terminology. +`); + + expect(proposal).toEqual({ + recommendation: + "Keep the architecture summary concise and make the contract boundary explicit.", + draftMarkdown: "## Architecture\n\nThe desktop app initiates the publish flow.", + notes: "Preserve the existing review terminology.", + }); + }); + + it("returns null when the response does not include a draft markdown section", () => { + expect( + parseEditorAIDraftProposal(` +## Recommendation + +Only explain the issue. +`), + ).toBeNull(); + }); + + it("returns null when the draft section is not fenced", () => { + expect( + parseEditorAIDraftProposal(` +## Draft Markdown + +## Contracts + +Document the persistence invariants. +`), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/aiDraftProposal.ts b/apps/desktop/src/lib/aiDraftProposal.ts new file mode 100644 index 0000000..df7d28d --- /dev/null +++ b/apps/desktop/src/lib/aiDraftProposal.ts @@ -0,0 +1,60 @@ +import type { EditorAIDraftProposal } from "../types/domain-ui"; + +function normalizeOutput(output: string) { + return output.replace(/\r\n/g, "\n").trim(); +} + +function escapeForPattern(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function getSectionBody(markdown: string, title: string, nextTitles: string[]) { + const nextPattern = + nextTitles.length > 0 + ? `(?=\\n##\\s+(?:${nextTitles.map(escapeForPattern).join("|")})\\s*(?:\\n|$)|$)` + : "$"; + const sectionPattern = new RegExp( + `##\\s+${escapeForPattern(title)}\\s*\\n+([\\s\\S]*?)${nextPattern}`, + "i", + ); + const sectionMatch = sectionPattern.exec(markdown); + + return sectionMatch?.[1]?.trim() ?? null; +} + +function extractMarkdownBlock(sectionBody: string) { + const fencedBlockMatch = sectionBody.match(/```(?:md|markdown)?\n([\s\S]*?)\n```/i); + + if (fencedBlockMatch?.[1]) { + return fencedBlockMatch[1].trim(); + } + + return null; +} + +export function parseEditorAIDraftProposal(output: string): EditorAIDraftProposal | null { + const normalizedOutput = normalizeOutput(output); + + if (!normalizedOutput) { + return null; + } + + const draftSection = getSectionBody(normalizedOutput, "Draft Markdown", ["Notes"]); + + if (!draftSection) { + return null; + } + + const draftMarkdown = extractMarkdownBlock(draftSection); + + if (!draftMarkdown) { + return null; + } + + return { + recommendation: + getSectionBody(normalizedOutput, "Recommendation", ["Draft Markdown", "Notes"]) ?? "", + draftMarkdown, + notes: getSectionBody(normalizedOutput, "Notes", []), + }; +} diff --git a/apps/desktop/src/pages/EditorPage.tsx b/apps/desktop/src/pages/EditorPage.tsx index d0c1cb1..e274f52 100644 --- a/apps/desktop/src/pages/EditorPage.tsx +++ b/apps/desktop/src/pages/EditorPage.tsx @@ -41,10 +41,11 @@ import { cn } from "@/lib/utils"; import { useCleanup } from "../hooks/useCleanup"; import { useUpdateEffect } from "../hooks/useUpdateEffect"; import type { AITaskRunningExecution } from "../domain/aiTasks"; +import { parseEditorAIDraftProposal } from "../lib/aiDraftProposal"; import { buildAITaskEntryPoints } from "../lib/aiTaskEntryPoints"; import { buildAITaskPrompt } from "../lib/runtimePayloads"; import { desktopMutationKeys } from "../queries/queryKeys"; -import type { AITaskEntryPoint } from "../types/domain-ui"; +import type { AITaskEntryPoint, EditorAIDraftProposal } from "../types/domain-ui"; import type { AppPageProps } from "./pageUtils"; import { EmptyStateCard, @@ -81,6 +82,11 @@ export function EditorPage({ app }: AppPageProps) { const [isAssistantOpen, setIsAssistantOpen] = useState(false); const [selectedAssistantEntryId, setSelectedAssistantEntryId] = useState(null); const [assistantMessages, setAssistantMessages] = useState([]); + const [lastAppliedDraft, setLastAppliedDraft] = useState<{ + messageId: string; + previousSource: string; + appliedSource: string; + } | null>(null); const activeAssistantTaskRef = useRef(null); const document = app.activeDocument; const graph = app.activeWorkspaceGraph; @@ -161,12 +167,17 @@ export function EditorPage({ app }: AppPageProps) { prompt, stderrMessageId, }); + const proposal = parseEditorAIDraftProposal(result.output); replaceAssistantMessage(assistantMessageId, result.output, { format: "markdown", isStreaming: false, label: result.promptLabel, tone: "default", + proposal, + canUndoApply: false, + isCompareOpen: false, + statusText: proposal ? "적용 가능한 초안이 포함되어 있습니다." : null, }); finalizeSystemMessage(stderrMessageId, "warning"); } catch (error) { @@ -178,6 +189,10 @@ export function EditorPage({ app }: AppPageProps) { isStreaming: false, label: assistantLabel, tone: "danger", + proposal: null, + canUndoApply: false, + isCompareOpen: false, + statusText: null, }, ); finalizeSystemMessage(stderrMessageId, "danger"); @@ -193,9 +208,31 @@ export function EditorPage({ app }: AppPageProps) { setSelectedAssistantEntryId(null); setAssistantMessages([]); + setLastAppliedDraft(null); assistantForm.reset(); }, [assistantForm, document?.id]); + useUpdateEffect(() => { + if (!lastAppliedDraft || app.activeDocumentSource === lastAppliedDraft.appliedSource) { + return; + } + + setLastAppliedDraft(null); + startTransition(() => { + setAssistantMessages((current) => + current.map((message) => + message.canUndoApply + ? { + ...message, + canUndoApply: false, + statusText: "AI 초안 적용 이후 본문이 다시 변경되어 되돌리기를 닫았습니다.", + } + : message, + ), + ); + }); + }, [app.activeDocumentSource, lastAppliedDraft]); + const upsertAssistantMessage = (message: EditorAIAssistantMessage) => { startTransition(() => { setAssistantMessages((current) => @@ -215,7 +252,12 @@ export function EditorPage({ app }: AppPageProps) { const replaceAssistantMessage = ( messageId: string, content: string, - options: Pick, + options: Pick & { + proposal?: EditorAIDraftProposal | null; + canUndoApply?: boolean; + isCompareOpen?: boolean; + statusText?: string | null; + }, ) => { upsertAssistantMessage({ id: messageId, @@ -273,6 +315,144 @@ export function EditorPage({ app }: AppPageProps) { }); }; + const toggleAssistantProposalCompare = (messageId: string) => { + startTransition(() => { + setAssistantMessages((current) => + current.map((message) => + message.id === messageId + ? { + ...message, + isCompareOpen: !message.isCompareOpen, + } + : message, + ), + ); + }); + }; + + const copyAssistantProposal = async (messageId: string) => { + const message = assistantMessages.find((entry) => entry.id === messageId); + const draftMarkdown = message?.proposal?.draftMarkdown; + + if (!draftMarkdown) { + return; + } + + const clipboard = globalThis.navigator?.clipboard; + + if (!clipboard?.writeText) { + startTransition(() => { + setAssistantMessages((current) => + current.map((entry) => + entry.id === messageId + ? { + ...entry, + statusText: "이 환경에서는 클립보드 복사를 사용할 수 없습니다.", + } + : entry, + ), + ); + }); + return; + } + + try { + await clipboard.writeText(draftMarkdown); + startTransition(() => { + setAssistantMessages((current) => + current.map((entry) => + entry.id === messageId + ? { + ...entry, + statusText: "제안 초안을 클립보드에 복사했습니다.", + } + : entry, + ), + ); + }); + } catch { + startTransition(() => { + setAssistantMessages((current) => + current.map((entry) => + entry.id === messageId + ? { + ...entry, + statusText: "클립보드 복사에 실패했습니다.", + } + : entry, + ), + ); + }); + } + }; + + const applyAssistantProposal = (messageId: string) => { + if (!document || !isLockedByActiveMember) { + return; + } + + const message = assistantMessages.find((entry) => entry.id === messageId); + const proposal = message?.proposal; + + if (!proposal) { + return; + } + + const previousSource = app.activeDocumentSource; + + app.handleDocumentSourceChange(document, proposal.draftMarkdown); + setLastAppliedDraft({ + messageId, + previousSource, + appliedSource: proposal.draftMarkdown, + }); + startTransition(() => { + setAssistantMessages((current) => + current.map((entry) => + entry.role === "assistant" + ? { + ...entry, + canUndoApply: entry.id === messageId, + statusText: + entry.id === messageId + ? "AI 초안을 현재 세션 초안에 반영했습니다." + : entry.canUndoApply + ? null + : entry.statusText, + } + : entry, + ), + ); + }); + }; + + const undoAssistantProposalApply = (messageId: string) => { + if ( + !document || + !isLockedByActiveMember || + lastAppliedDraft?.messageId !== messageId || + app.activeDocumentSource !== lastAppliedDraft.appliedSource + ) { + return; + } + + app.handleDocumentSourceChange(document, lastAppliedDraft.previousSource); + setLastAppliedDraft(null); + startTransition(() => { + setAssistantMessages((current) => + current.map((entry) => + entry.id === messageId + ? { + ...entry, + canUndoApply: false, + statusText: "직전 AI 초안 적용을 되돌렸습니다.", + } + : entry, + ), + ); + }); + }; + const assistantMutation = useMutation({ mutationKey: desktopMutationKeys.ai.runEntryPoint(), mutationFn: async ({ @@ -304,7 +484,17 @@ export function EditorPage({ app }: AppPageProps) { "# User Request", prompt, "", - "Return a practical answer for an in-editor assistant. Explain the change, propose markdown when helpful, and keep it easy to apply manually.", + "Return a practical answer for an in-editor assistant. Explain the change and keep it easy to apply manually.", + "When you are proposing replacement document content, use exactly this structure:", + "## Recommendation", + "A short explanation of the change.", + "## Draft Markdown", + "```md", + "Full replacement markdown draft here.", + "```", + "## Notes", + "Optional implementation or review notes.", + "If you are not proposing replacement markdown, omit the Draft Markdown section entirely.", ].join("\n"), }, { @@ -446,23 +636,31 @@ export function EditorPage({ app }: AppPageProps) { > {(field) => ( 0 } + currentDocumentSource={app.activeDocumentSource} entries={assistantEntries} emptyStateMessage={assistantEmptyStateMessage} isOpen={isAssistantOpen} isPending={assistantMutation.isPending} isVisible={activeTab === "edit"} messages={assistantMessages} + onApplyDraft={applyAssistantProposal} onClose={() => setIsAssistantOpen(false)} + onCopyDraft={(messageId) => { + void copyAssistantProposal(messageId); + }} onPromptBlur={field.handleBlur} onPromptChange={field.handleChange} onSelectEntry={setSelectedAssistantEntryId} onSubmit={() => void assistantForm.handleSubmit()} + onToggleCompare={toggleAssistantProposalCompare} onToggleOpen={() => setIsAssistantOpen((current) => !current)} + onUndoDraftApply={undoAssistantProposalApply} prompt={field.state.value} promptError={field.state.meta.isTouched ? getFieldError(field.state.meta.errors) : null} selectedEntry={selectedAssistantEntry} diff --git a/apps/desktop/src/types/domain-ui.ts b/apps/desktop/src/types/domain-ui.ts index 939b3bb..f4dabf0 100644 --- a/apps/desktop/src/types/domain-ui.ts +++ b/apps/desktop/src/types/domain-ui.ts @@ -26,6 +26,12 @@ export type PublishRecordId = string; export type AIDraftSuggestionStatus = "proposed" | "reviewed" | "accepted" | "rejected"; +export interface EditorAIDraftProposal { + recommendation: string; + draftMarkdown: string; + notes: string | null; +} + export type AITaskEntryPointScope = "workspace" | "document" | "publish"; export type AITaskEntryPointContext = | "workspace_overview"