diff --git a/src/components/ai-chat/AIChatPanelContent.tsx b/src/components/ai-chat/AIChatPanelContent.tsx
index 2eebbd69..06ceed75 100644
--- a/src/components/ai-chat/AIChatPanelContent.tsx
+++ b/src/components/ai-chat/AIChatPanelContent.tsx
@@ -1,6 +1,6 @@
-import { Suspense, lazy, useCallback } from "react";
+import { Suspense, lazy, useCallback, useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
-import { useToast } from "@zedi/ui";
+import { cn, useToast } from "@zedi/ui";
import { AIChatHeader } from "./AIChatHeader";
import { AIChatViewTabs } from "./AIChatViewTabs";
import { AIChatInput } from "./AIChatInput";
@@ -14,6 +14,10 @@ const AIChatBranchTree = lazy(() =>
import("./AIChatBranchTree").then((m) => ({ default: m.AIChatBranchTree })),
);
+const AIChatWorkflowPanel = lazy(() =>
+ import("./AIChatWorkflowPanel").then((m) => ({ default: m.AIChatWorkflowPanel })),
+);
+
/**
* Props for {@link AIChatPanelContent}.
* {@link AIChatPanelContent} 向けプロパティ。
@@ -87,6 +91,19 @@ export function AIChatPanelContent({
const canInsert = pageContext?.type === "editor";
+ /** After the workflow tab is visited once, keep the panel mounted so run state survives tab switches. / ワークフロータブを一度開いたらマウントを維持し、タブ切替で実行状態を失わない */
+ const [keepWorkflowMounted, setKeepWorkflowMounted] = useState(
+ () => activeViewTab === "workflow",
+ );
+ useLayoutEffect(() => {
+ if (activeViewTab === "workflow") {
+ // Latch: after first visit to the workflow tab, keep the panel mounted so run/pause state survives tab switches.
+ // 初回表示後はマウントを維持し、タブ切替で実行状態を失わない。
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- one-way latch from tab selection (not external sync)
+ setKeepWorkflowMounted(true);
+ }
+ }, [activeViewTab]);
+
return (
@@ -116,7 +133,7 @@ export function AIChatPanelContent({
onSwitchBranch={switchBranch}
isStreaming={isStreaming}
/>
- ) : (
+ ) : activeViewTab === "branch" ? (
- )}
+ ) : null}
+ {keepWorkflowMounted ? (
+
+ ) : null}
-
+ {/* Stay mounted on workflow tab so uncontrolled input draft is not lost. / ワークフロー切替で下書きを失わない */}
+
{
vi.clearAllMocks();
});
- it("renders Chat and Branch tabs", () => {
+ it("renders Chat, Branch, and Workflow tabs", () => {
const onTabChange = vi.fn();
render();
expect(screen.getByRole("tab", { name: "aiChat.viewTabs.chat" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "aiChat.viewTabs.branch" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "aiChat.viewTabs.workflow" })).toBeInTheDocument();
});
it("Branch tab is always enabled", () => {
@@ -47,4 +48,12 @@ describe("AIChatViewTabs", () => {
await user.click(screen.getByRole("tab", { name: "aiChat.viewTabs.chat" }));
expect(onTabChange).toHaveBeenCalledWith("chat");
});
+
+ it("calls onTabChange with workflow when Workflow tab is clicked", async () => {
+ const user = userEvent.setup();
+ const onTabChange = vi.fn();
+ render();
+ await user.click(screen.getByRole("tab", { name: "aiChat.viewTabs.workflow" }));
+ expect(onTabChange).toHaveBeenCalledWith("workflow");
+ });
});
diff --git a/src/components/ai-chat/AIChatViewTabs.tsx b/src/components/ai-chat/AIChatViewTabs.tsx
index 2512a43d..b158b146 100644
--- a/src/components/ai-chat/AIChatViewTabs.tsx
+++ b/src/components/ai-chat/AIChatViewTabs.tsx
@@ -1,12 +1,12 @@
import { useTranslation } from "react-i18next";
-import { MessageSquare, GitBranch } from "lucide-react";
+import { MessageSquare, GitBranch, ListChecks } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "@zedi/ui";
/**
* Active AI chat panel view: threaded messages or branch tree.
* AI チャットパネルの表示:スレッド表示かブランチツリーか。
*/
-export type AIChatViewTab = "chat" | "branch";
+export type AIChatViewTab = "chat" | "branch" | "workflow";
/**
* Props for {@link AIChatViewTabs}.
@@ -43,6 +43,13 @@ export function AIChatViewTabs({ activeTab, onTabChange }: AIChatViewTabsProps)
{t("aiChat.viewTabs.branch")}
+
+
+ {t("aiChat.viewTabs.workflow")}
+
);
diff --git a/src/components/ai-chat/AIChatWorkflowPanel.tsx b/src/components/ai-chat/AIChatWorkflowPanel.tsx
new file mode 100644
index 00000000..a474adff
--- /dev/null
+++ b/src/components/ai-chat/AIChatWorkflowPanel.tsx
@@ -0,0 +1,16 @@
+/**
+ * Multi-step Claude Code workflow UI (Issue #462).
+ * Claude Code マルチステップワークフロー UI(Issue #462)。
+ */
+
+import { useWorkflowPanelLogic } from "@/hooks/useWorkflowPanelLogic";
+import { WorkflowPanelForm } from "./WorkflowPanelForm";
+
+/**
+ * Workflow editor and runner embedded in the AI chat panel.
+ * AI チャットパネルに埋め込むワークフロー編集・実行。
+ */
+export function AIChatWorkflowPanel() {
+ const logic = useWorkflowPanelLogic();
+ return ;
+}
diff --git a/src/components/ai-chat/WorkflowPanelForm.tsx b/src/components/ai-chat/WorkflowPanelForm.tsx
new file mode 100644
index 00000000..e922fd63
--- /dev/null
+++ b/src/components/ai-chat/WorkflowPanelForm.tsx
@@ -0,0 +1,135 @@
+/**
+ * Form layout for the workflow panel (Issue #462).
+ * ワークフローパネルのフォームレイアウト(Issue #462)。
+ */
+
+import { Button, ScrollArea } from "@zedi/ui";
+import { ListChecks, Pause, Play, Square } from "lucide-react";
+import { isTauriDesktop } from "@/lib/platform";
+import { WorkflowPanelMetaSection } from "./WorkflowPanelMetaSection";
+import { WorkflowPanelStepsAndProgress } from "./WorkflowPanelStepsAndProgress";
+import type { WorkflowPanelFormProps } from "./workflowPanelTypes";
+
+export type { WorkflowPanelFormProps } from "./workflowPanelTypes";
+
+/**
+ * Renders workflow name, templates, steps, progress, and run controls.
+ * ワークフロー名・テンプレート・ステップ・進捗・実行操作を描画する。
+ */
+export function WorkflowPanelForm(props: WorkflowPanelFormProps) {
+ const {
+ t,
+ draft,
+ setDraft,
+ definitions,
+ selectedSavedId,
+ progress,
+ activeRunSteps,
+ pausedState,
+ importInputRef,
+ isEditor,
+ running,
+ runExecution,
+ handlePause,
+ handleStop,
+ addStep,
+ removeStep,
+ updateStep,
+ loadTemplate,
+ saveCustom,
+ exportJson,
+ onImportFile,
+ loadSaved,
+ deleteSaved,
+ } = props;
+
+ const canRunWorkflow = isTauriDesktop() && isEditor;
+
+ return (
+
+
+
+ {t("aiChat.workflow.subtitle")}
+
+
+ {!isTauriDesktop() && (
+
{t("aiChat.workflow.desktopOnly")}
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ai-chat/WorkflowPanelMetaSection.tsx b/src/components/ai-chat/WorkflowPanelMetaSection.tsx
new file mode 100644
index 00000000..ce08566c
--- /dev/null
+++ b/src/components/ai-chat/WorkflowPanelMetaSection.tsx
@@ -0,0 +1,174 @@
+/**
+ * Workflow name, templates, saved definitions, and import/export (Issue #462).
+ * ワークフロー名・テンプレート・保存定義・インポート/エクスポート(Issue #462)。
+ */
+
+import {
+ Button,
+ Input,
+ Label,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@zedi/ui";
+import { Download, Trash2, Upload } from "lucide-react";
+import {
+ WORKFLOW_TEMPLATE_IDS,
+ WORKFLOW_TEMPLATE_NAME_KEYS,
+ type WorkflowTemplateId,
+} from "@/lib/workflow/templates";
+import type { WorkflowPanelFormProps } from "./workflowPanelTypes";
+
+type Props = Pick<
+ WorkflowPanelFormProps,
+ | "t"
+ | "draft"
+ | "setDraft"
+ | "definitions"
+ | "selectedSavedId"
+ | "importInputRef"
+ | "running"
+ | "loadTemplate"
+ | "saveCustom"
+ | "exportJson"
+ | "onImportFile"
+ | "loadSaved"
+ | "deleteSaved"
+>;
+
+/**
+ * Name field, template selector, saved workflow picker, and JSON import/export.
+ * 名前・テンプレート・保存済み選択・JSON インポート/エクスポート。
+ */
+export function WorkflowPanelMetaSection(props: Props) {
+ const {
+ t,
+ draft,
+ setDraft,
+ definitions,
+ selectedSavedId,
+ importInputRef,
+ running,
+ loadTemplate,
+ saveCustom,
+ exportJson,
+ onImportFile,
+ loadSaved,
+ deleteSaved,
+ } = props;
+
+ return (
+ <>
+
+
+ setDraft((d) => ({ ...d, name: e.target.value, updatedAt: Date.now() }))}
+ placeholder={t("aiChat.workflow.workflowNamePlaceholder")}
+ disabled={running}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/ai-chat/WorkflowPanelStepsAndProgress.tsx b/src/components/ai-chat/WorkflowPanelStepsAndProgress.tsx
new file mode 100644
index 00000000..521b0cc2
--- /dev/null
+++ b/src/components/ai-chat/WorkflowPanelStepsAndProgress.tsx
@@ -0,0 +1,115 @@
+/**
+ * Step editor list and run progress summary (Issue #462).
+ * ステップ編集リストと実行進捗サマリー(Issue #462)。
+ */
+
+import { Button, Input, Label } from "@zedi/ui";
+import { Plus, Trash2 } from "lucide-react";
+import type { WorkflowPanelFormProps } from "./workflowPanelTypes";
+
+type Props = Pick<
+ WorkflowPanelFormProps,
+ | "t"
+ | "draft"
+ | "running"
+ | "progress"
+ | "activeRunSteps"
+ | "addStep"
+ | "removeStep"
+ | "updateStep"
+>;
+
+/**
+ * Editable steps and optional streaming progress block.
+ * 編集可能なステップとストリーミング進捗ブロック。
+ */
+export function WorkflowPanelStepsAndProgress(props: Props) {
+ const { t, draft, running, progress, activeRunSteps, addStep, removeStep, updateStep } = props;
+
+ return (
+ <>
+
+
+
+
+
+
+ {draft.steps.map((step, index) => (
+
+
+
+ {t("aiChat.workflow.stepLabel", { n: index + 1 })}
+
+
+
+
updateStep(index, { title: e.target.value })}
+ />
+
+ ))}
+
+
+ {progress && (
+
+
+ {t("aiChat.workflow.progressTitle")}
+
+
+ {(activeRunSteps ?? draft.steps).map((s, i) => {
+ const st = progress.stepStatuses[i] ?? "pending";
+ const mark =
+ st === "done" ? "☑" : st === "running" ? "🔄" : st === "error" ? "⚠️" : "⬜";
+ return (
+ -
+
+ {mark} {s.title || t("aiChat.workflow.unnamedStep", { n: i + 1 })}
+
+ {progress.phase === "running" &&
+ st === "running" &&
+ progress.currentStepStreaming ? (
+
+ {progress.currentStepStreaming}
+
+ ) : null}
+
+ );
+ })}
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/ai-chat/workflowPanelTypes.ts b/src/components/ai-chat/workflowPanelTypes.ts
new file mode 100644
index 00000000..ab94c2fc
--- /dev/null
+++ b/src/components/ai-chat/workflowPanelTypes.ts
@@ -0,0 +1,12 @@
+/**
+ * Shared props type for workflow panel subcomponents (Issue #462).
+ * ワークフローパネル子コンポーネント共通の props 型(Issue #462)。
+ */
+
+/**
+ * Props mirror {@link useWorkflowPanelLogic} return shape.
+ * {@link useWorkflowPanelLogic} の戻り値と同形。
+ */
+export type WorkflowPanelFormProps = ReturnType<
+ typeof import("@/hooks/useWorkflowPanelLogic").useWorkflowPanelLogic
+>;
diff --git a/src/hooks/useWorkflowDraft.ts b/src/hooks/useWorkflowDraft.ts
new file mode 100644
index 00000000..523db16a
--- /dev/null
+++ b/src/hooks/useWorkflowDraft.ts
@@ -0,0 +1,163 @@
+/**
+ * Draft editing and local persistence for workflow definitions (Issue #462).
+ * ワークフロー定義のドラフト編集とローカル永続化(Issue #462)。
+ */
+
+import { useCallback, useRef, useState, type ChangeEvent } from "react";
+import { useTranslation } from "react-i18next";
+import { useToast } from "@zedi/ui";
+import { newWorkflowId } from "@/lib/workflow/newWorkflowId";
+import { parseWorkflowDefinitionImport } from "@/lib/workflow/parseWorkflowDefinitionImport";
+import type { WorkflowDefinition, WorkflowStepDefinition } from "@/lib/workflow/types";
+import {
+ WORKFLOW_TEMPLATE_NAME_KEYS,
+ instantiateWorkflowTemplate,
+ type WorkflowTemplateId,
+} from "@/lib/workflow/templates";
+import { useWorkflowDefinitionsStore } from "@/stores/workflowDefinitionsStore";
+
+function emptyDraft(): WorkflowDefinition {
+ const now = Date.now();
+ return {
+ id: newWorkflowId(),
+ name: "",
+ steps: [{ id: newWorkflowId(), title: "", instruction: "" }],
+ createdAt: now,
+ updatedAt: now,
+ };
+}
+
+/**
+ * Draft state, templates, import/export, and saved-definition list.
+ * ドラフト状態・テンプレート・インポート/エクスポート・保存一覧。
+ */
+export function useWorkflowDraft() {
+ const { t } = useTranslation();
+ const { toast } = useToast();
+
+ const definitions = useWorkflowDefinitionsStore((s) => s.definitions);
+ const upsertDefinition = useWorkflowDefinitionsStore((s) => s.upsertDefinition);
+ const removeDefinition = useWorkflowDefinitionsStore((s) => s.removeDefinition);
+
+ const [draft, setDraft] = useState(() => emptyDraft());
+ const [selectedSavedId, setSelectedSavedId] = useState("");
+ const importInputRef = useRef(null);
+
+ const addStep = useCallback(() => {
+ setDraft((d) => ({
+ ...d,
+ steps: [...d.steps, { id: newWorkflowId(), title: "", instruction: "" }],
+ updatedAt: Date.now(),
+ }));
+ }, []);
+
+ const removeStep = useCallback((index: number) => {
+ setDraft((d) => ({
+ ...d,
+ steps: d.steps.filter((_, i) => i !== index),
+ updatedAt: Date.now(),
+ }));
+ }, []);
+
+ const updateStep = useCallback((index: number, patch: Partial) => {
+ setDraft((d) => ({
+ ...d,
+ steps: d.steps.map((s, i) => (i === index ? { ...s, ...patch } : s)),
+ updatedAt: Date.now(),
+ }));
+ }, []);
+
+ const loadTemplate = useCallback(
+ (tid: WorkflowTemplateId) => {
+ const name = t(WORKFLOW_TEMPLATE_NAME_KEYS[tid]);
+ setDraft(instantiateWorkflowTemplate(tid, name));
+ setSelectedSavedId("");
+ },
+ [t],
+ );
+
+ const saveCustom = useCallback(() => {
+ if (!draft.name.trim()) {
+ toast({ title: t("aiChat.workflow.nameRequired"), variant: "destructive" });
+ return;
+ }
+ const now = Date.now();
+ upsertDefinition({ ...draft, updatedAt: now });
+ toast({ title: t("aiChat.workflow.saved") });
+ }, [draft, t, toast, upsertDefinition]);
+
+ const exportJson = useCallback(() => {
+ const blob = new Blob([JSON.stringify(draft, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${draft.name.trim() || "workflow"}.json`;
+ a.click();
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
+ }, [draft]);
+
+ const onImportFile = useCallback(
+ (e: ChangeEvent) => {
+ const file = e.target.files?.[0];
+ e.target.value = "";
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ try {
+ const parsed = JSON.parse(String(reader.result)) as unknown;
+ const { name, steps } = parseWorkflowDefinitionImport(parsed);
+ const now = Date.now();
+ setDraft({
+ id: newWorkflowId(),
+ name,
+ steps,
+ createdAt: now,
+ updatedAt: now,
+ });
+ setSelectedSavedId("");
+ toast({ title: t("aiChat.workflow.imported") });
+ } catch {
+ toast({ title: t("aiChat.workflow.importFailed"), variant: "destructive" });
+ }
+ };
+ reader.readAsText(file);
+ },
+ [t, toast],
+ );
+
+ const loadSaved = useCallback(
+ (id: string) => {
+ const found = definitions.find((d) => d.id === id);
+ if (found) {
+ setDraft({ ...found });
+ setSelectedSavedId(id);
+ }
+ },
+ [definitions],
+ );
+
+ const deleteSaved = useCallback(() => {
+ if (!selectedSavedId) return;
+ removeDefinition(selectedSavedId);
+ setSelectedSavedId("");
+ toast({ title: t("aiChat.workflow.deleted") });
+ }, [removeDefinition, selectedSavedId, t, toast]);
+
+ return {
+ t,
+ draft,
+ setDraft,
+ definitions,
+ selectedSavedId,
+ importInputRef,
+ addStep,
+ removeStep,
+ updateStep,
+ loadTemplate,
+ saveCustom,
+ exportJson,
+ onImportFile,
+ loadSaved,
+ deleteSaved,
+ };
+}
diff --git a/src/hooks/useWorkflowPanelLogic.ts b/src/hooks/useWorkflowPanelLogic.ts
new file mode 100644
index 00000000..1b7cc1cc
--- /dev/null
+++ b/src/hooks/useWorkflowPanelLogic.ts
@@ -0,0 +1,21 @@
+/**
+ * Composes draft editing and workflow execution for the AI chat workflow panel (Issue #462).
+ * AI チャットのワークフローパネル向けにドラフト編集と実行を合成する(Issue #462)。
+ */
+
+import { useWorkflowDraft } from "./useWorkflowDraft";
+import { useWorkflowRunSession } from "./useWorkflowRunSession";
+
+/**
+ * Combined state and actions for {@link AIChatWorkflowPanel}.
+ * {@link AIChatWorkflowPanel} 向けの状態とアクションをまとめる。
+ */
+export function useWorkflowPanelLogic() {
+ const draftApi = useWorkflowDraft();
+ const runApi = useWorkflowRunSession(draftApi.draft);
+
+ return {
+ ...draftApi,
+ ...runApi,
+ };
+}
diff --git a/src/hooks/useWorkflowRunSession.ts b/src/hooks/useWorkflowRunSession.ts
new file mode 100644
index 00000000..451eaadc
--- /dev/null
+++ b/src/hooks/useWorkflowRunSession.ts
@@ -0,0 +1,210 @@
+/**
+ * Claude Code multi-step workflow execution for the workflow panel (Issue #462).
+ * ワークフローパネル向け Claude Code マルチステップ実行(Issue #462)。
+ */
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useToast } from "@zedi/ui";
+import { useAIChatContext } from "@/contexts/AIChatContext";
+import { isTauriDesktop } from "@/lib/platform";
+import { runWorkflowExecution } from "@/lib/workflow/runWorkflowExecution";
+import type {
+ WorkflowDefinition,
+ WorkflowRunProgress,
+ WorkflowStepDefinition,
+} from "@/lib/workflow/types";
+import { applyWorkflowRunOutcome } from "./workflowRunOutcomeHandlers";
+
+/** Snapshot while paused (step id + outputs by id). / 一時停止中のスナップショット */
+type PausedSnapshot = {
+ pausedStepId: string;
+ stepOutputsById: Record;
+ partialForStep: string;
+};
+
+/**
+ * Maps paused snapshot onto current valid steps; returns null if the paused step id is missing.
+ * 現在の有効ステップへ一時停止状態を写す。paused ステップが無ければ null。
+ */
+function resolveResumeFromPaused(
+ paused: PausedSnapshot,
+ validSteps: WorkflowStepDefinition[],
+): { startIndex: number; initialOutputs: string[]; resumePartial: string } | null {
+ const j = validSteps.findIndex((s) => s.id === paused.pausedStepId);
+ if (j === -1) return null;
+ return {
+ startIndex: j,
+ initialOutputs: validSteps.map((s, k) => (k < j ? (paused.stepOutputsById[s.id] ?? "") : "")),
+ resumePartial: paused.partialForStep,
+ };
+}
+
+/**
+ * Runs, pauses, and resumes workflows against the current note context.
+ * 現在のノート文脈に対してワークフローを実行・一時停止・再開する。
+ */
+export function useWorkflowRunSession(draft: WorkflowDefinition) {
+ const { t } = useTranslation();
+ const { toast } = useToast();
+ const { pageContext, contentAppendHandlerRef } = useAIChatContext();
+
+ const [progress, setProgress] = useState(null);
+ const [activeRunSteps, setActiveRunSteps] = useState(null);
+ const [pausedState, setPausedState] = useState(null);
+
+ const workflowAbortRef = useRef(null);
+ const currentStepAbortRef = useRef(null);
+ const baseSnapshotRef = useRef("");
+
+ const isEditor = pageContext?.type === "editor";
+ const cwd = pageContext?.claudeWorkspaceRoot;
+ const pageExcerpt =
+ pageContext?.pageFullContent?.slice(0, 12_000) ??
+ pageContext?.pageContent?.slice(0, 12_000) ??
+ "";
+
+ useEffect(() => {
+ return () => {
+ workflowAbortRef.current?.abort();
+ currentStepAbortRef.current?.abort();
+ };
+ }, []);
+
+ const applyNoteContent = useCallback(
+ (fullMarkdown: string) => {
+ const fn = contentAppendHandlerRef.current;
+ if (fn) fn(fullMarkdown);
+ },
+ [contentAppendHandlerRef],
+ );
+
+ const runExecution = useCallback(
+ async (mode: "fresh" | "resume") => {
+ if (!isTauriDesktop()) {
+ toast({ title: t("aiChat.workflow.desktopOnly"), variant: "destructive" });
+ return;
+ }
+ if (!isEditor) {
+ toast({ title: t("aiChat.workflow.editorRequired"), variant: "destructive" });
+ return;
+ }
+ if (mode === "resume" && !pausedState) {
+ toast({ title: t("aiChat.workflow.nothingToResume"), variant: "destructive" });
+ return;
+ }
+ if (!draft.name.trim()) {
+ toast({ title: t("aiChat.workflow.nameRequired"), variant: "destructive" });
+ return;
+ }
+ const validSteps = draft.steps.filter((s) => s.title.trim() && s.instruction.trim());
+ if (validSteps.length === 0) {
+ toast({ title: t("aiChat.workflow.stepsRequired"), variant: "destructive" });
+ return;
+ }
+
+ const def: WorkflowDefinition = {
+ ...draft,
+ steps: validSteps,
+ updatedAt: Date.now(),
+ };
+
+ workflowAbortRef.current = new AbortController();
+ if (mode === "fresh") {
+ baseSnapshotRef.current = pageContext?.pageFullContent ?? "";
+ }
+
+ let startIndex = 0;
+ let initialOutputs: string[] = [];
+ let resumePartial: string | undefined;
+ if (mode === "resume" && pausedState) {
+ const resolved = resolveResumeFromPaused(pausedState, validSteps);
+ if (!resolved) {
+ toast({ title: t("aiChat.workflow.pausedStepNotFound"), variant: "destructive" });
+ setPausedState(null);
+ return;
+ }
+ ({ startIndex, initialOutputs, resumePartial } = resolved);
+ }
+
+ if (mode === "fresh") {
+ setPausedState(null);
+ }
+
+ setActiveRunSteps(validSteps);
+
+ setProgress({
+ phase: "running",
+ currentStepIndex: startIndex,
+ stepStatuses: validSteps.map((_, i) =>
+ i < startIndex ? "done" : i === startIndex ? "running" : "pending",
+ ),
+ stepOutputs: initialOutputs.length
+ ? initialOutputs
+ : Array.from({ length: validSteps.length }, () => ""),
+ currentStepStreaming: resumePartial ?? "",
+ });
+
+ const result = await runWorkflowExecution({
+ definition: def,
+ cwd,
+ pageExcerpt,
+ workflowSignal: workflowAbortRef.current.signal,
+ createStepAbort: () => {
+ const c = new AbortController();
+ currentStepAbortRef.current = c;
+ return c;
+ },
+ startStepIndex: startIndex,
+ stepOutputs: initialOutputs.length
+ ? initialOutputs
+ : Array.from({ length: validSteps.length }, () => ""),
+ resumePartialForCurrentStep: resumePartial,
+ onProgress: setProgress,
+ onNoteMarkdown: applyNoteContent,
+ baseContentBeforeWorkflow: baseSnapshotRef.current,
+ });
+
+ applyWorkflowRunOutcome(result, validSteps, {
+ t,
+ toast,
+ setPausedState,
+ setActiveRunSteps,
+ setProgress,
+ });
+ },
+ [
+ applyNoteContent,
+ cwd,
+ draft,
+ isEditor,
+ pageContext?.pageFullContent,
+ pageExcerpt,
+ pausedState,
+ t,
+ toast,
+ ],
+ );
+
+ const handlePause = useCallback(() => {
+ currentStepAbortRef.current?.abort();
+ }, []);
+
+ const handleStop = useCallback(() => {
+ workflowAbortRef.current?.abort();
+ currentStepAbortRef.current?.abort();
+ }, []);
+
+ const running = progress?.phase === "running";
+
+ return {
+ progress,
+ activeRunSteps,
+ pausedState,
+ isEditor,
+ running,
+ runExecution,
+ handlePause,
+ handleStop,
+ };
+}
diff --git a/src/hooks/workflowRunOutcomeHandlers.ts b/src/hooks/workflowRunOutcomeHandlers.ts
new file mode 100644
index 00000000..e5964708
--- /dev/null
+++ b/src/hooks/workflowRunOutcomeHandlers.ts
@@ -0,0 +1,98 @@
+/**
+ * Maps {@link WorkflowExecutionOutcome} to UI state updates (Issue #462).
+ * {@link WorkflowExecutionOutcome} を UI 状態更新へ写す(Issue #462)。
+ */
+
+import type { Dispatch, SetStateAction } from "react";
+import type { TFunction } from "i18next";
+import type { WorkflowExecutionOutcome } from "@/lib/workflow/runWorkflowExecution";
+import type { WorkflowRunProgress, WorkflowStepDefinition } from "@/lib/workflow/types";
+
+type ToastArg = { title: string; variant?: "destructive" };
+type ToastFn = (props: ToastArg) => void;
+
+type PausedState = {
+ /** Step to resume (lookup in current valid steps). / 再開するステップ(現在の有効ステップで解決) */
+ pausedStepId: string;
+ /** Completed outputs keyed by step id. / 完了ステップの出力(id キー) */
+ stepOutputsById: Record;
+ partialForStep: string;
+};
+
+/**
+ * Applies execution outcome: toasts and state setters for pause / complete / error.
+ * 実行結果を適用する(トーストと pause / 完了 / エラー用の setter)。
+ *
+ * Terminal outcomes keep `activeRunSteps` as `validSteps` so progress rows align with `stepStatuses`
+ * when the draft still contains empty placeholder steps. / 終端時も activeRunSteps を validSteps に保ち、空ステップがあっても進捗行と stepStatuses を一致させる。
+ */
+export function applyWorkflowRunOutcome(
+ result: WorkflowExecutionOutcome,
+ validSteps: WorkflowStepDefinition[],
+ ctx: {
+ t: TFunction;
+ toast: ToastFn;
+ setPausedState: Dispatch>;
+ setActiveRunSteps: Dispatch>;
+ setProgress: Dispatch>;
+ },
+): void {
+ const { t, toast, setPausedState, setActiveRunSteps, setProgress } = ctx;
+
+ switch (result.outcome) {
+ case "completed":
+ setPausedState(null);
+ setActiveRunSteps(validSteps);
+ setProgress((p) => (p ? { ...p, phase: "completed" } : null));
+ toast({ title: t("aiChat.workflow.completed") });
+ return;
+ case "paused":
+ setActiveRunSteps(validSteps);
+ setPausedState({
+ pausedStepId: result.pausedStepId,
+ stepOutputsById: result.stepOutputsById,
+ partialForStep: result.partialForStep,
+ });
+ setProgress((p) => (p ? { ...p, phase: "paused" } : null));
+ toast({ title: t("aiChat.workflow.paused") });
+ return;
+ case "stopped":
+ setPausedState(null);
+ setActiveRunSteps(validSteps);
+ setProgress((p) => {
+ if (!p) return null;
+ const stepStatuses = p.stepStatuses.map((s) => (s === "running" ? "pending" : s));
+ return {
+ ...p,
+ phase: "aborted",
+ stepStatuses,
+ currentStepStreaming: "",
+ };
+ });
+ toast({ title: t("aiChat.workflow.stopped") });
+ return;
+ case "error":
+ setPausedState(null);
+ setActiveRunSteps(validSteps);
+ setProgress((p) => {
+ if (!p) return null;
+ const stepStatuses = p.stepStatuses.map((s) => (s === "running" ? "error" : s));
+ return {
+ ...p,
+ phase: "aborted",
+ stepStatuses,
+ currentStepStreaming: "",
+ lastError: result.error,
+ };
+ });
+ toast({
+ title: t("aiChat.workflow.error", { message: result.error }),
+ variant: "destructive",
+ });
+ return;
+ default: {
+ const _exhaustive: never = result;
+ throw new Error(`Unhandled outcome: ${JSON.stringify(_exhaustive)}`);
+ }
+ }
+}
diff --git a/src/i18n/locales/en/aiChat.json b/src/i18n/locales/en/aiChat.json
index 031525a6..db959c32 100644
--- a/src/i18n/locales/en/aiChat.json
+++ b/src/i18n/locales/en/aiChat.json
@@ -72,8 +72,56 @@
"viewTabs": {
"chat": "Chat",
"branch": "Branches",
+ "workflow": "Workflow",
"noBranches": "No branches in this conversation"
},
+ "workflow": {
+ "subtitle": "Multi-step Claude Code runs with live note updates (desktop).",
+ "desktopOnly": "Workflows require the Zedi desktop app with Claude Code.",
+ "editorRequired": "Open a note page to write results into the editor.",
+ "nameRequired": "Enter a workflow name.",
+ "stepsRequired": "Add at least one step with a title and instructions.",
+ "nothingToResume": "Nothing to resume. Run a workflow first, then pause.",
+ "pausedStepNotFound": "The paused step is no longer in this workflow. Run again from the start.",
+ "workflowName": "Workflow name",
+ "workflowNamePlaceholder": "e.g. New API endpoint design",
+ "template": "Template",
+ "templatePlaceholder": "Load a template…",
+ "templates": {
+ "codeInvestigateDesign": "Code investigation → design memo",
+ "testAnalyzeImprove": "Run tests → analyze → suggest improvements",
+ "repoAnalyzeDocs": "Repository analysis → documentation draft",
+ "webResearchNote": "Web research → organize → draft note"
+ },
+ "savedWorkflows": "Saved workflows",
+ "pickSaved": "Load saved…",
+ "deleteSaved": "Delete saved workflow",
+ "save": "Save",
+ "export": "Export",
+ "import": "Import",
+ "steps": "Steps",
+ "addStep": "Add step",
+ "stepLabel": "Step {{n}}",
+ "removeStep": "Remove step {{n}}",
+ "stepTitleAria": "Step {{n}} title",
+ "stepInstructionAria": "Step {{n}} instructions",
+ "stepTitlePlaceholder": "Step title",
+ "stepInstructionPlaceholder": "What Claude Code should do in this step",
+ "unnamedStep": "Step {{n}}",
+ "progressTitle": "Progress",
+ "run": "Run",
+ "pause": "Pause",
+ "resume": "Resume",
+ "stop": "Stop",
+ "saved": "Workflow saved",
+ "deleted": "Workflow removed",
+ "imported": "Workflow imported",
+ "importFailed": "Could not import workflow JSON",
+ "completed": "Workflow completed",
+ "paused": "Workflow paused",
+ "stopped": "Workflow stopped",
+ "error": "Workflow error: {{message}}"
+ },
"branchTree": {
"goToBranch": "Go to this branch",
"branchFromHere": "Branch from here",
diff --git a/src/i18n/locales/ja/aiChat.json b/src/i18n/locales/ja/aiChat.json
index 701283e4..0fe4f57c 100644
--- a/src/i18n/locales/ja/aiChat.json
+++ b/src/i18n/locales/ja/aiChat.json
@@ -72,8 +72,56 @@
"viewTabs": {
"chat": "チャット",
"branch": "ブランチ",
+ "workflow": "ワークフロー",
"noBranches": "この会話にはブランチがありません"
},
+ "workflow": {
+ "subtitle": "Claude Code で複数ステップを順に実行し、ノートへリアルタイム反映(デスクトップ)。",
+ "desktopOnly": "ワークフローは Claude Code 利用可能な Zedi デスクトップアプリが必要です。",
+ "editorRequired": "結果を書き込むにはノートページを開いてください。",
+ "nameRequired": "ワークフロー名を入力してください。",
+ "stepsRequired": "タイトルと指示があるステップを1つ以上追加してください。",
+ "nothingToResume": "再開できる実行がありません。実行して一時停止してください。",
+ "pausedStepNotFound": "一時停止中のステップがワークフローにありません。最初からやり直してください。",
+ "workflowName": "ワークフロー名",
+ "workflowNamePlaceholder": "例: 新 API エンドポイント設計",
+ "template": "テンプレート",
+ "templatePlaceholder": "テンプレートを読み込む…",
+ "templates": {
+ "codeInvestigateDesign": "コード調査→設計メモ作成",
+ "testAnalyzeImprove": "テスト実行→結果分析→改善提案",
+ "repoAnalyzeDocs": "リポジトリ分析→ドキュメント生成",
+ "webResearchNote": "Web 調査→情報整理→ノート作成"
+ },
+ "savedWorkflows": "保存済みワークフロー",
+ "pickSaved": "保存を読み込む…",
+ "deleteSaved": "保存を削除",
+ "save": "保存",
+ "export": "エクスポート",
+ "import": "インポート",
+ "steps": "ステップ",
+ "addStep": "ステップを追加",
+ "stepLabel": "ステップ {{n}}",
+ "removeStep": "ステップ {{n}} を削除",
+ "stepTitleAria": "ステップ {{n}} のタイトル",
+ "stepInstructionAria": "ステップ {{n}} の指示",
+ "stepTitlePlaceholder": "ステップのタイトル",
+ "stepInstructionPlaceholder": "このステップで Claude Code にやってほしいこと",
+ "unnamedStep": "ステップ {{n}}",
+ "progressTitle": "進捗",
+ "run": "実行",
+ "pause": "一時停止",
+ "resume": "再開",
+ "stop": "中断",
+ "saved": "ワークフローを保存しました",
+ "deleted": "ワークフローを削除しました",
+ "imported": "ワークフローをインポートしました",
+ "importFailed": "ワークフロー JSON を読み込めませんでした",
+ "completed": "ワークフローが完了しました",
+ "paused": "ワークフローを一時停止しました",
+ "stopped": "ワークフローを中断しました",
+ "error": "ワークフローエラー: {{message}}"
+ },
"branchTree": {
"goToBranch": "このブランチに移動",
"branchFromHere": "ここから分岐",
diff --git a/src/lib/claudeCode/runQueryToCompletion.test.ts b/src/lib/claudeCode/runQueryToCompletion.test.ts
index ba6116a4..ccf96f0d 100644
--- a/src/lib/claudeCode/runQueryToCompletion.test.ts
+++ b/src/lib/claudeCode/runQueryToCompletion.test.ts
@@ -30,6 +30,8 @@ vi.mock("./bridge", () => ({
onError = cb;
return Promise.resolve(() => {});
}),
+ onClaudeToolUseStart: vi.fn().mockResolvedValue(() => {}),
+ onClaudeToolUseComplete: vi.fn().mockResolvedValue(() => {}),
}));
import { claudeQuery } from "./bridge";
diff --git a/src/lib/claudeCode/runQueryToCompletion.ts b/src/lib/claudeCode/runQueryToCompletion.ts
index b993a42e..70a3d19f 100644
--- a/src/lib/claudeCode/runQueryToCompletion.ts
+++ b/src/lib/claudeCode/runQueryToCompletion.ts
@@ -5,58 +5,14 @@
* Used by executable code blocks and similar flows that need a full result string.
* 実行可能コードブロックなど、完全な結果文字列が必要なフローで使用する。
*
- * Events that arrive after listeners are registered but before `claudeQuery` resolves
- * its request id are buffered and replayed for the matching id (avoids losing early chunks).
- * リスナー登録後〜`claudeQuery` が ID を返すまでに届くイベントはバッファし、同一 ID で再生する(早期チャンク欠落を防ぐ)。
+ * Implemented via {@link streamClaudeQuery} without incremental callbacks.
+ * {@link streamClaudeQuery} をチャンクコールバックなしで呼び出す実装。
*/
-import { isTauriDesktop } from "@/lib/platform";
-import {
- claudeAbort,
- claudeQuery,
- onClaudeError,
- onClaudeStreamChunk,
- onClaudeStreamComplete,
-} from "./bridge";
import type { ClaudeQueryOptions } from "./bridge";
+import { streamClaudeQuery, type ClaudeQueryCompletionResult } from "./streamClaudeQuery";
-/** Outcome of {@link runClaudeQueryToCompletion}. / {@link runClaudeQueryToCompletion} の結果。 */
-export type ClaudeQueryCompletionResult =
- | { ok: true; content: string }
- | { ok: false; error: string };
-
-type PreRequestEvent =
- | { type: "chunk"; id: string; content: string }
- | { type: "complete"; id: string; result?: { content: string } }
- | { type: "error"; id: string; error: string };
-
-/**
- * Applies buffered sidecar events that arrived before `requestId` was known.
- * `requestId` 確定前に届いた sidecar イベントを適用する。
- */
-function applyPreRequestBuffer(
- requestId: string,
- buffer: PreRequestEvent[],
- state: {
- aggregated: string;
- finished: boolean;
- errorMessage: string | null;
- },
-): void {
- for (const ev of buffer) {
- if (ev.id !== requestId) continue;
- if (ev.type === "chunk") {
- state.aggregated += ev.content;
- } else if (ev.type === "complete") {
- state.aggregated = ev.result?.content ?? state.aggregated;
- state.finished = true;
- } else if (ev.type === "error") {
- state.errorMessage = ev.error;
- state.finished = true;
- }
- }
- buffer.length = 0;
-}
+export type { ClaudeQueryCompletionResult } from "./streamClaudeQuery";
/**
* Sends `prompt` via the sidecar and resolves when the stream completes or errors.
@@ -67,111 +23,5 @@ export async function runClaudeQueryToCompletion(
options?: ClaudeQueryOptions,
signal?: AbortSignal,
): Promise {
- if (!isTauriDesktop()) {
- return { ok: false, error: "Claude Code is only available in the desktop app." };
- }
-
- let requestId: string | null = null;
- let resolveWait: (() => void) | null = null;
- /** Removes the pending `abort` listener when the wait ends without firing abort. */
- let pendingAbortCleanup: (() => void) | null = null;
- const wake = (): void => {
- pendingAbortCleanup?.();
- pendingAbortCleanup = null;
- resolveWait?.();
- resolveWait = null;
- };
-
- let aggregated = "";
- let finished = false;
- let errorMessage: string | null = null;
-
- const preRequestBuffer: PreRequestEvent[] = [];
-
- const unlistenChunk = await onClaudeStreamChunk((payload) => {
- if (finished) return;
- if (requestId && payload.id === requestId) {
- aggregated += payload.content;
- wake();
- } else if (!requestId) {
- preRequestBuffer.push({ type: "chunk", id: payload.id, content: payload.content });
- }
- });
-
- const unlistenComplete = await onClaudeStreamComplete((payload) => {
- if (finished) return;
- if (requestId && payload.id === requestId) {
- const text = payload.result?.content ?? aggregated;
- aggregated = text;
- finished = true;
- wake();
- } else if (!requestId) {
- preRequestBuffer.push({
- type: "complete",
- id: payload.id,
- result: payload.result,
- });
- }
- });
-
- const unlistenError = await onClaudeError((payload) => {
- if (finished) return;
- if (requestId && payload.id === requestId) {
- errorMessage = payload.error;
- finished = true;
- wake();
- } else if (!requestId) {
- preRequestBuffer.push({ type: "error", id: payload.id, error: payload.error });
- }
- });
-
- try {
- requestId = await claudeQuery(prompt, options);
-
- const merged = { aggregated, finished, errorMessage };
- applyPreRequestBuffer(requestId, preRequestBuffer, merged);
- aggregated = merged.aggregated;
- finished = merged.finished;
- errorMessage = merged.errorMessage;
- if (finished) wake();
-
- while (!finished) {
- if (signal?.aborted) {
- if (requestId) await claudeAbort(requestId);
- return { ok: false, error: "Aborted" };
- }
-
- await new Promise((resolve) => {
- resolveWait = resolve;
- const onAbort = (): void => {
- pendingAbortCleanup = null;
- resolve();
- resolveWait = null;
- };
- pendingAbortCleanup = (): void => {
- signal?.removeEventListener("abort", onAbort);
- };
- signal?.addEventListener("abort", onAbort, { once: true });
- if (finished || signal?.aborted) {
- pendingAbortCleanup?.();
- pendingAbortCleanup = null;
- resolve();
- resolveWait = null;
- }
- });
- }
-
- if (errorMessage) {
- return { ok: false, error: errorMessage };
- }
- return { ok: true, content: aggregated };
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- return { ok: false, error: msg };
- } finally {
- unlistenChunk();
- unlistenComplete();
- unlistenError();
- requestId = null;
- }
+ return streamClaudeQuery(prompt, options, signal, {});
}
diff --git a/src/lib/claudeCode/streamClaudeQuery.ts b/src/lib/claudeCode/streamClaudeQuery.ts
new file mode 100644
index 00000000..5f20f734
--- /dev/null
+++ b/src/lib/claudeCode/streamClaudeQuery.ts
@@ -0,0 +1,242 @@
+/**
+ * Streams a Claude Code sidecar query with incremental text callbacks.
+ * Claude Code sidecar のクエリをテキストチャンク単位でコールバックする。
+ *
+ * @see {@link runClaudeQueryToCompletion} — thin wrapper with no chunk handler.
+ */
+
+import { isTauriDesktop } from "@/lib/platform";
+import {
+ claudeAbort,
+ claudeQuery,
+ onClaudeError,
+ onClaudeStreamChunk,
+ onClaudeStreamComplete,
+ onClaudeToolUseComplete,
+ onClaudeToolUseStart,
+} from "./bridge";
+import type { ClaudeQueryOptions } from "./bridge";
+/** Outcome of {@link streamClaudeQuery} / {@link runClaudeQueryToCompletion}. */
+export type ClaudeQueryCompletionResult =
+ | { ok: true; content: string }
+ | { ok: false; error: string };
+
+type PreRequestEvent =
+ | { type: "chunk"; id: string; content: string }
+ | { type: "complete"; id: string; result?: { content: string } }
+ | { type: "error"; id: string; error: string }
+ | { type: "toolStart"; id: string; toolName: string }
+ | { type: "toolComplete"; id: string; toolName: string };
+
+/**
+ * Optional callbacks while streaming Claude Code output.
+ * ストリーミング中の任意コールバック。
+ */
+export interface StreamClaudeQueryCallbacks {
+ /** Each text delta from the assistant. / アシスタントからのテキスト差分 */
+ onChunk?: (text: string) => void;
+ /** Tool invocation started (Claude Code). / ツール呼び出し開始 */
+ onToolUseStart?: (toolName: string) => void;
+ /** Tool invocation finished. / ツール呼び出し完了 */
+ onToolUseComplete?: (toolName: string) => void;
+}
+
+/**
+ * Applies buffered sidecar events that arrived before `requestId` was known.
+ * `requestId` 確定前に届いた sidecar イベントを適用する。
+ */
+function applyPreRequestBuffer(
+ requestId: string,
+ buffer: PreRequestEvent[],
+ state: {
+ aggregated: string;
+ finished: boolean;
+ errorMessage: string | null;
+ },
+ callbacks: StreamClaudeQueryCallbacks,
+): void {
+ for (const ev of buffer) {
+ if (ev.id !== requestId) continue;
+ if (ev.type === "chunk") {
+ state.aggregated += ev.content;
+ callbacks.onChunk?.(ev.content);
+ } else if (ev.type === "complete") {
+ state.aggregated = ev.result?.content ?? state.aggregated;
+ state.finished = true;
+ } else if (ev.type === "error") {
+ state.errorMessage = ev.error;
+ state.finished = true;
+ } else if (ev.type === "toolStart") {
+ callbacks.onToolUseStart?.(ev.toolName);
+ } else if (ev.type === "toolComplete") {
+ callbacks.onToolUseComplete?.(ev.toolName);
+ }
+ }
+ buffer.length = 0;
+}
+
+/**
+ * Sends `prompt` via the sidecar; invokes `onChunk` for each text delta; resolves with final text.
+ * `prompt` を sidecar へ送り、テキスト差分ごとに `onChunk` を呼び、最終テキストで解決する。
+ */
+export async function streamClaudeQuery(
+ prompt: string,
+ options: ClaudeQueryOptions | undefined,
+ signal: AbortSignal | undefined,
+ callbacks: StreamClaudeQueryCallbacks,
+): Promise {
+ if (!isTauriDesktop()) {
+ return { ok: false, error: "Claude Code is only available in the desktop app." };
+ }
+
+ let requestId: string | null = null;
+ let resolveWait: (() => void) | null = null;
+ let pendingAbortCleanup: (() => void) | null = null;
+ const wake = (): void => {
+ pendingAbortCleanup?.();
+ pendingAbortCleanup = null;
+ resolveWait?.();
+ resolveWait = null;
+ };
+
+ let aggregated = "";
+ let finished = false;
+ let errorMessage: string | null = null;
+
+ const preRequestBuffer: PreRequestEvent[] = [];
+
+ const cleanups: Array<() => void> = [];
+
+ try {
+ cleanups.push(
+ await onClaudeStreamChunk((payload) => {
+ if (finished) return;
+ if (requestId && payload.id === requestId) {
+ aggregated += payload.content;
+ callbacks.onChunk?.(payload.content);
+ wake();
+ } else if (!requestId) {
+ preRequestBuffer.push({ type: "chunk", id: payload.id, content: payload.content });
+ }
+ }),
+ );
+
+ cleanups.push(
+ await onClaudeStreamComplete((payload) => {
+ if (finished) return;
+ if (requestId && payload.id === requestId) {
+ const text = payload.result?.content ?? aggregated;
+ aggregated = text;
+ finished = true;
+ wake();
+ } else if (!requestId) {
+ preRequestBuffer.push({
+ type: "complete",
+ id: payload.id,
+ result: payload.result,
+ });
+ }
+ }),
+ );
+
+ cleanups.push(
+ await onClaudeError((payload) => {
+ if (finished) return;
+ if (requestId && payload.id === requestId) {
+ errorMessage = payload.error;
+ finished = true;
+ wake();
+ } else if (!requestId) {
+ preRequestBuffer.push({ type: "error", id: payload.id, error: payload.error });
+ }
+ }),
+ );
+
+ cleanups.push(
+ await onClaudeToolUseStart((payload) => {
+ if (finished) return;
+ if (requestId && payload.id === requestId) {
+ callbacks.onToolUseStart?.(payload.toolName);
+ } else if (!requestId) {
+ preRequestBuffer.push({
+ type: "toolStart",
+ id: payload.id,
+ toolName: payload.toolName,
+ });
+ }
+ }),
+ );
+
+ cleanups.push(
+ await onClaudeToolUseComplete((payload) => {
+ if (finished) return;
+ if (requestId && payload.id === requestId) {
+ callbacks.onToolUseComplete?.(payload.toolName);
+ } else if (!requestId) {
+ preRequestBuffer.push({
+ type: "toolComplete",
+ id: payload.id,
+ toolName: payload.toolName,
+ });
+ }
+ }),
+ );
+
+ if (signal?.aborted) {
+ return { ok: false, error: "Aborted" };
+ }
+ requestId = await claudeQuery(prompt, options);
+
+ const merged = { aggregated, finished, errorMessage };
+ applyPreRequestBuffer(requestId, preRequestBuffer, merged, callbacks);
+ aggregated = merged.aggregated;
+ finished = merged.finished;
+ errorMessage = merged.errorMessage;
+ if (finished) wake();
+
+ while (!finished) {
+ if (signal?.aborted) {
+ if (requestId) await claudeAbort(requestId);
+ return { ok: false, error: "Aborted" };
+ }
+
+ await new Promise((resolve) => {
+ resolveWait = resolve;
+ const onAbort = (): void => {
+ pendingAbortCleanup = null;
+ resolve();
+ resolveWait = null;
+ };
+ pendingAbortCleanup = (): void => {
+ signal?.removeEventListener("abort", onAbort);
+ };
+ signal?.addEventListener("abort", onAbort, { once: true });
+ if (finished || signal?.aborted) {
+ pendingAbortCleanup?.();
+ pendingAbortCleanup = null;
+ resolve();
+ resolveWait = null;
+ }
+ });
+ }
+
+ if (errorMessage) {
+ return { ok: false, error: errorMessage };
+ }
+ return { ok: true, content: aggregated };
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ return { ok: false, error: msg };
+ } finally {
+ for (let i = cleanups.length - 1; i >= 0; i -= 1) {
+ const unlisten = cleanups[i];
+ if (!unlisten) continue;
+ try {
+ unlisten();
+ } catch {
+ /* ignore unlisten errors */
+ }
+ }
+ requestId = null;
+ }
+}
diff --git a/src/lib/workflow/buildWorkflowStepPrompt.test.ts b/src/lib/workflow/buildWorkflowStepPrompt.test.ts
new file mode 100644
index 00000000..86bd2bcb
--- /dev/null
+++ b/src/lib/workflow/buildWorkflowStepPrompt.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { buildWorkflowStepPrompt, defaultWorkflowStepMaxTurns } from "./buildWorkflowStepPrompt";
+
+describe("buildWorkflowStepPrompt", () => {
+ it("includes step meta, instruction, and prior outputs", () => {
+ const text = buildWorkflowStepPrompt({
+ workflowName: "W",
+ step: { id: "s2", title: "Design", instruction: "Propose schema." },
+ stepIndex: 1,
+ totalSteps: 3,
+ priorOutputs: ["analysis done"],
+ });
+ expect(text).toContain("step 2 of 3");
+ expect(text).toContain("Design");
+ expect(text).toContain("Propose schema.");
+ expect(text).toContain("analysis done");
+ });
+
+ it("adds page excerpt and resume partial when provided", () => {
+ const text = buildWorkflowStepPrompt({
+ workflowName: "W",
+ step: { id: "s1", title: "T", instruction: "Go" },
+ stepIndex: 0,
+ totalSteps: 1,
+ pageExcerpt: "Note body",
+ priorOutputs: [],
+ resumeFromPartial: "half-done",
+ });
+ expect(text).toContain("Note body");
+ expect(text).toContain("half-done");
+ });
+});
+
+describe("defaultWorkflowStepMaxTurns", () => {
+ it("falls back to 15 when maxTurns is missing", () => {
+ expect(defaultWorkflowStepMaxTurns({ id: "a", title: "t", instruction: "i" })).toBe(15);
+ });
+
+ it("uses explicit maxTurns", () => {
+ expect(
+ defaultWorkflowStepMaxTurns({ id: "a", title: "t", instruction: "i", maxTurns: 7 }),
+ ).toBe(7);
+ });
+});
diff --git a/src/lib/workflow/buildWorkflowStepPrompt.ts b/src/lib/workflow/buildWorkflowStepPrompt.ts
new file mode 100644
index 00000000..a927afb7
--- /dev/null
+++ b/src/lib/workflow/buildWorkflowStepPrompt.ts
@@ -0,0 +1,80 @@
+/**
+ * Builds the user prompt for one workflow step (Issue #462).
+ * ワークフロー 1 ステップ分のユーザープロンプトを組み立てる(Issue #462)。
+ */
+
+import type { WorkflowStepDefinition } from "./types";
+
+const DEFAULT_MAX_TURNS = 15;
+
+/**
+ * Returns default max turns when a step omits `maxTurns`.
+ * ステップが `maxTurns` を省略したときの既定値。
+ */
+export function defaultWorkflowStepMaxTurns(step: WorkflowStepDefinition): number {
+ return step.maxTurns ?? DEFAULT_MAX_TURNS;
+}
+
+/**
+ * Builds Claude Code prompt text for `stepIndex` including prior step outputs.
+ * 先行ステップの出力を含めた Claude Code 用プロンプトを組み立てる。
+ */
+export function buildWorkflowStepPrompt(options: {
+ workflowName: string;
+ step: WorkflowStepDefinition;
+ stepIndex: number;
+ totalSteps: number;
+ /** Plain-text excerpt of the open page (optional). / 開いているページの抜粋(任意) */
+ pageExcerpt?: string;
+ /** Completed assistant outputs from earlier steps. / 先行ステップの完了出力 */
+ priorOutputs: string[];
+ /** When resuming after pause, partial text from the interrupted attempt. / 一時停止後の再開時、中断前の部分テキスト */
+ resumeFromPartial?: string;
+}): string {
+ const {
+ workflowName,
+ step,
+ stepIndex,
+ totalSteps,
+ pageExcerpt,
+ priorOutputs,
+ resumeFromPartial,
+ } = options;
+
+ const parts: string[] = [
+ `You are executing step ${stepIndex + 1} of ${totalSteps} in a workflow named "${workflowName}".`,
+ "",
+ `## Step title`,
+ step.title,
+ "",
+ `## Instructions`,
+ step.instruction.trim() || "(no additional instructions)",
+ "",
+ ];
+
+ if (pageExcerpt?.trim()) {
+ parts.push(`## Context from the open note (excerpt)`, pageExcerpt.trim(), "");
+ }
+
+ if (priorOutputs.length > 0) {
+ parts.push(`## Outputs from previous steps`);
+ for (let i = 0; i < priorOutputs.length; i += 1) {
+ parts.push(`### Step ${i + 1}`, priorOutputs[i].trim() || "(empty)", "");
+ }
+ }
+
+ if (resumeFromPartial?.trim()) {
+ parts.push(
+ `## Resume`,
+ "The previous attempt was interrupted. Continue and improve from this partial output:",
+ resumeFromPartial.trim(),
+ "",
+ );
+ }
+
+ parts.push(
+ `Respond with the result for this step only. Use clear Markdown. Be concise unless the instructions ask for detail.`,
+ );
+
+ return parts.join("\n");
+}
diff --git a/src/lib/workflow/formatWorkflowNoteMarkdown.test.ts b/src/lib/workflow/formatWorkflowNoteMarkdown.test.ts
new file mode 100644
index 00000000..9236cbb7
--- /dev/null
+++ b/src/lib/workflow/formatWorkflowNoteMarkdown.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { formatWorkflowNoteMarkdown } from "./formatWorkflowNoteMarkdown";
+
+describe("formatWorkflowNoteMarkdown", () => {
+ it("renders title and step markers", () => {
+ const md = formatWorkflowNoteMarkdown({
+ title: "Test",
+ stepTitles: ["A", "B"],
+ stepStatuses: ["done", "pending"],
+ stepOutputs: ["out-a", ""],
+ streamingStepIndex: null,
+ streamingText: "",
+ });
+ expect(md).toContain("## 📋 Workflow: Test");
+ expect(md).toContain("### ☑ 1. A");
+ expect(md).toContain("out-a");
+ expect(md).toContain("### ⬜ 2. B");
+ });
+
+ it("shows (empty) for done step with empty output", () => {
+ const md = formatWorkflowNoteMarkdown({
+ title: "E",
+ stepTitles: ["Only"],
+ stepStatuses: ["done"],
+ stepOutputs: [""],
+ streamingStepIndex: null,
+ streamingText: "",
+ });
+ expect(md).toContain("(empty)");
+ });
+
+ it("includes streaming text for the running step", () => {
+ const md = formatWorkflowNoteMarkdown({
+ title: "S",
+ stepTitles: ["One"],
+ stepStatuses: ["running"],
+ stepOutputs: [""],
+ streamingStepIndex: 0,
+ streamingText: "partial...",
+ });
+ expect(md).toContain("### 🔄 1. One");
+ expect(md).toContain("partial...");
+ });
+});
diff --git a/src/lib/workflow/formatWorkflowNoteMarkdown.ts b/src/lib/workflow/formatWorkflowNoteMarkdown.ts
new file mode 100644
index 00000000..68ef6a83
--- /dev/null
+++ b/src/lib/workflow/formatWorkflowNoteMarkdown.ts
@@ -0,0 +1,57 @@
+/**
+ * Builds Markdown for embedding workflow progress into a note (Issue #462).
+ * ノートへ進捗を埋め込む Markdown を組み立てる(Issue #462)。
+ */
+
+import type { WorkflowStepRunStatus } from "./types";
+
+const STATUS_PREFIX: Record = {
+ pending: "⬜",
+ running: "🔄",
+ done: "☑",
+ error: "⚠️",
+};
+
+/**
+ * Formats a workflow block with step headings, optional streaming text, and outputs.
+ * ステップ見出し・ストリーミングテキスト・出力付きのワークフローブロックを整形する。
+ */
+export function formatWorkflowNoteMarkdown(options: {
+ /** Workflow title. / ワークフロー名 */
+ title: string;
+ /** Step titles in order. / ステップタイトル(順序どおり) */
+ stepTitles: string[];
+ /** Status per step. / ステップごとの状態 */
+ stepStatuses: WorkflowStepRunStatus[];
+ /** Final text for steps that finished successfully. / 成功完了したステップの最終テキスト */
+ stepOutputs: string[];
+ /** Index of the step currently streaming, or null. / ストリーム中のステップ index、なければ null */
+ streamingStepIndex: number | null;
+ /** Partial text for the streaming step. / ストリーム中ステップの部分テキスト */
+ streamingText: string;
+}): string {
+ const { title, stepTitles, stepStatuses, stepOutputs, streamingStepIndex, streamingText } =
+ options;
+
+ const lines: string[] = [`## 📋 Workflow: ${title}`, ""];
+
+ for (let i = 0; i < stepTitles.length; i += 1) {
+ const status = stepStatuses[i] ?? "pending";
+ const prefix = STATUS_PREFIX[status];
+ lines.push(`### ${prefix} ${i + 1}. ${stepTitles[i]}`);
+ lines.push("");
+
+ if (status === "done") {
+ lines.push((stepOutputs[i] ?? "").trim() || "(empty)");
+ lines.push("");
+ } else if (status === "running" && streamingStepIndex === i && streamingText.trim()) {
+ lines.push(streamingText.trim());
+ lines.push("");
+ } else if (status === "error") {
+ lines.push("(step failed)");
+ lines.push("");
+ }
+ }
+
+ return lines.join("\n").trimEnd();
+}
diff --git a/src/lib/workflow/newWorkflowId.ts b/src/lib/workflow/newWorkflowId.ts
new file mode 100644
index 00000000..a119ae81
--- /dev/null
+++ b/src/lib/workflow/newWorkflowId.ts
@@ -0,0 +1,15 @@
+/**
+ * Generates a random id for workflow definitions and steps.
+ * ワークフロー定義・ステップ用のランダム ID を生成する。
+ */
+
+/**
+ * Returns a UUID when `crypto.randomUUID` exists; otherwise a fallback string.
+ * `crypto.randomUUID` があれば UUID、なければフォールバック文字列。
+ */
+export function newWorkflowId(): string {
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID();
+ }
+ return `wf-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
+}
diff --git a/src/lib/workflow/parseWorkflowDefinitionImport.test.ts b/src/lib/workflow/parseWorkflowDefinitionImport.test.ts
new file mode 100644
index 00000000..ded387ec
--- /dev/null
+++ b/src/lib/workflow/parseWorkflowDefinitionImport.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+import { parseWorkflowDefinitionImport } from "./parseWorkflowDefinitionImport";
+
+describe("parseWorkflowDefinitionImport", () => {
+ it("parses minimal valid JSON", () => {
+ const r = parseWorkflowDefinitionImport({
+ name: "W",
+ steps: [{ id: "a", title: "T", instruction: "Do" }],
+ });
+ expect(r.name).toBe("W");
+ expect(r.steps).toHaveLength(1);
+ expect(r.steps[0].title).toBe("T");
+ expect(r.steps[0].instruction).toBe("Do");
+ });
+
+ it("rejects non-object root", () => {
+ expect(() => parseWorkflowDefinitionImport(null)).toThrow();
+ });
+
+ it("rejects missing steps array", () => {
+ expect(() => parseWorkflowDefinitionImport({ name: "x" })).toThrow();
+ });
+
+ it("rejects step without string title/instruction", () => {
+ expect(() =>
+ parseWorkflowDefinitionImport({
+ name: "x",
+ steps: [{ id: "1", title: 1, instruction: "a" }],
+ }),
+ ).toThrow();
+ });
+
+ it("re-issues step ids when duplicates appear", () => {
+ const r = parseWorkflowDefinitionImport({
+ name: "Dup",
+ steps: [
+ { id: "same", title: "A", instruction: "a" },
+ { id: "same", title: "B", instruction: "b" },
+ ],
+ });
+ expect(r.steps[0].id).toBe("same");
+ expect(r.steps[1].id).not.toBe("same");
+ expect(r.steps[1].title).toBe("B");
+ });
+
+ it("accepts optional maxTurns and allowedTools", () => {
+ const r = parseWorkflowDefinitionImport({
+ name: "x",
+ steps: [
+ {
+ title: "a",
+ instruction: "b",
+ maxTurns: 10,
+ allowedTools: ["Read", "Bash"],
+ },
+ ],
+ });
+ expect(r.steps[0].maxTurns).toBe(10);
+ expect(r.steps[0].allowedTools).toEqual(["Read", "Bash"]);
+ });
+});
diff --git a/src/lib/workflow/parseWorkflowDefinitionImport.ts b/src/lib/workflow/parseWorkflowDefinitionImport.ts
new file mode 100644
index 00000000..b4589459
--- /dev/null
+++ b/src/lib/workflow/parseWorkflowDefinitionImport.ts
@@ -0,0 +1,94 @@
+/**
+ * Validates JSON imported as a workflow definition (Issue #462).
+ * ワークフロー定義としてインポートする JSON を検証する(Issue #462)。
+ */
+
+import { newWorkflowId } from "./newWorkflowId";
+import type { WorkflowStepDefinition } from "./types";
+
+const MAX_STEPS = 50;
+const MAX_FIELD_LEN = 50_000;
+const MAX_TOOL_NAME_LEN = 64;
+
+const INVALID = "invalid workflow JSON";
+
+/**
+ * Parses one step object from imported JSON.
+ * インポート JSON のステップオブジェクトを 1 件パースする。
+ */
+function parseWorkflowStepImport(s: Record): WorkflowStepDefinition {
+ if (typeof s.title !== "string" || typeof s.instruction !== "string") {
+ throw new Error(INVALID);
+ }
+ const title = s.title.slice(0, MAX_FIELD_LEN);
+ const instruction = s.instruction.slice(0, MAX_FIELD_LEN);
+ const id =
+ typeof s.id === "string" && s.id.trim().length > 0 ? s.id.slice(0, 200) : newWorkflowId();
+
+ const step: WorkflowStepDefinition = { id, title, instruction };
+
+ if (s.maxTurns !== undefined) {
+ if (typeof s.maxTurns !== "number" || !Number.isFinite(s.maxTurns)) {
+ throw new Error(INVALID);
+ }
+ const mt = Math.floor(s.maxTurns);
+ if (mt < 1 || mt > 500) {
+ throw new Error(INVALID);
+ }
+ step.maxTurns = mt;
+ }
+
+ if (s.allowedTools !== undefined) {
+ if (!Array.isArray(s.allowedTools)) {
+ throw new Error(INVALID);
+ }
+ step.allowedTools = s.allowedTools.map((t) => {
+ if (typeof t !== "string") {
+ throw new Error(INVALID);
+ }
+ return t.slice(0, MAX_TOOL_NAME_LEN);
+ });
+ }
+
+ return step;
+}
+
+/**
+ * Parses and validates unknown JSON from file import.
+ * ファイルインポート由来の不明な JSON をパースし検証する。
+ *
+ * @throws Error when the shape is invalid or limits are exceeded.
+ */
+export function parseWorkflowDefinitionImport(raw: unknown): {
+ name: string;
+ steps: WorkflowStepDefinition[];
+} {
+ if (!raw || typeof raw !== "object") {
+ throw new Error(INVALID);
+ }
+ const o = raw as Record;
+ const name = typeof o.name === "string" ? o.name.slice(0, MAX_FIELD_LEN) : "";
+
+ if (!Array.isArray(o.steps)) {
+ throw new Error(INVALID);
+ }
+ if (o.steps.length === 0 || o.steps.length > MAX_STEPS) {
+ throw new Error(INVALID);
+ }
+
+ const steps: WorkflowStepDefinition[] = [];
+ const seenIds = new Set();
+ for (const item of o.steps) {
+ if (!item || typeof item !== "object") {
+ throw new Error(INVALID);
+ }
+ let step = parseWorkflowStepImport(item as Record);
+ if (seenIds.has(step.id)) {
+ step = { ...step, id: newWorkflowId() };
+ }
+ seenIds.add(step.id);
+ steps.push(step);
+ }
+
+ return { name, steps };
+}
diff --git a/src/lib/workflow/runWorkflowExecution.test.ts b/src/lib/workflow/runWorkflowExecution.test.ts
new file mode 100644
index 00000000..cbbdac28
--- /dev/null
+++ b/src/lib/workflow/runWorkflowExecution.test.ts
@@ -0,0 +1,99 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { runWorkflowExecution } from "./runWorkflowExecution";
+
+vi.mock("@/lib/claudeCode/streamClaudeQuery", () => ({
+ streamClaudeQuery: vi.fn(),
+}));
+
+import { streamClaudeQuery } from "@/lib/claudeCode/streamClaudeQuery";
+
+describe("runWorkflowExecution", () => {
+ beforeEach(() => {
+ vi.mocked(streamClaudeQuery).mockReset();
+ });
+
+ it("returns error when there are no steps", async () => {
+ const r = await runWorkflowExecution({
+ definition: { id: "w", name: "W", steps: [], createdAt: 0, updatedAt: 0 },
+ workflowSignal: new AbortController().signal,
+ createStepAbort: () => new AbortController(),
+ startStepIndex: 0,
+ stepOutputs: [],
+ onProgress: vi.fn(),
+ onNoteMarkdown: vi.fn(),
+ baseContentBeforeWorkflow: "",
+ });
+ expect(r).toEqual({ outcome: "error", error: "Workflow has no steps." });
+ });
+
+ it("runs one step and completes", async () => {
+ vi.mocked(streamClaudeQuery).mockResolvedValue({ ok: true, content: "done" });
+
+ const onNote = vi.fn();
+ const r = await runWorkflowExecution({
+ definition: {
+ id: "w",
+ name: "W",
+ steps: [{ id: "s1", title: "Only", instruction: "Do work" }],
+ createdAt: 0,
+ updatedAt: 0,
+ },
+ workflowSignal: new AbortController().signal,
+ createStepAbort: () => new AbortController(),
+ startStepIndex: 0,
+ stepOutputs: [],
+ onProgress: vi.fn(),
+ onNoteMarkdown: onNote,
+ baseContentBeforeWorkflow: "base",
+ });
+
+ expect(r).toEqual({ outcome: "completed" });
+ expect(streamClaudeQuery).toHaveBeenCalledTimes(1);
+ const lastNote = onNote.mock.calls.at(-1)?.[0] as string;
+ expect(lastNote).toContain("base");
+ expect(lastNote).toContain("Workflow: W");
+ expect(lastNote).toContain("done");
+ });
+
+ it("returns paused when only the step signal aborts", async () => {
+ vi.mocked(streamClaudeQuery).mockImplementation(async (_p, _o, signal) => {
+ if (signal?.aborted) {
+ return { ok: false, error: "Aborted" };
+ }
+ return { ok: false, error: "Aborted" };
+ });
+
+ const workflow = new AbortController();
+ const step = new AbortController();
+ let createCount = 0;
+ const r = await runWorkflowExecution({
+ definition: {
+ id: "w",
+ name: "W",
+ steps: [{ id: "s1", title: "A", instruction: "x" }],
+ createdAt: 0,
+ updatedAt: 0,
+ },
+ workflowSignal: workflow.signal,
+ createStepAbort: () => {
+ createCount += 1;
+ step.abort();
+ return step;
+ },
+ startStepIndex: 0,
+ stepOutputs: [],
+ onProgress: vi.fn(),
+ onNoteMarkdown: vi.fn(),
+ baseContentBeforeWorkflow: "",
+ });
+
+ expect(r.outcome).toBe("paused");
+ if (r.outcome === "paused") {
+ expect(r.pausedAtStepIndex).toBe(0);
+ expect(r.pausedStepId).toBe("s1");
+ expect(r.stepOutputsById).toEqual({});
+ expect(r.stepOutputs).toEqual([""]);
+ }
+ expect(createCount).toBe(1);
+ });
+});
diff --git a/src/lib/workflow/runWorkflowExecution.ts b/src/lib/workflow/runWorkflowExecution.ts
new file mode 100644
index 00000000..aafceb59
--- /dev/null
+++ b/src/lib/workflow/runWorkflowExecution.ts
@@ -0,0 +1,290 @@
+/**
+ * Orchestrates multi-step Claude Code workflow runs (Issue #462).
+ * Claude Code のマルチステップワークフロー実行をオーケストレーションする(Issue #462)。
+ */
+
+import { streamClaudeQuery } from "@/lib/claudeCode/streamClaudeQuery";
+import type { WorkflowDefinition, WorkflowRunProgress, WorkflowStepRunStatus } from "./types";
+import { buildWorkflowStepPrompt, defaultWorkflowStepMaxTurns } from "./buildWorkflowStepPrompt";
+import { formatWorkflowNoteMarkdown } from "./formatWorkflowNoteMarkdown";
+
+/** Outcome when {@link runWorkflowExecution} finishes or yields control. */
+export type WorkflowExecutionOutcome =
+ | { outcome: "completed" }
+ | { outcome: "stopped" }
+ | {
+ outcome: "paused";
+ /** Step index where the run stopped (same step on resume). / 停止したステップ index(再開時も同じ) */
+ pausedAtStepIndex: number;
+ /** Step id at pause (stable across draft edits). / 一時停止時のステップ id(ドラフト編集後も追跡) */
+ pausedStepId: string;
+ /** Completed outputs keyed by step id. / 完了ステップの出力(id キー) */
+ stepOutputsById: Record;
+ /** Snapshot of outputs for completed steps. / 完了ステップの出力スナップショット */
+ stepOutputs: string[];
+ /** Streaming buffer at pause time for the active step. / 停止時点のアクティブステップのストリーム */
+ partialForStep: string;
+ }
+ | { outcome: "error"; error: string };
+
+type RunWorkflowStepsLoopParams = {
+ definition: WorkflowDefinition;
+ cwd?: string;
+ pageExcerpt?: string;
+ workflowSignal: AbortSignal;
+ createStepAbort: () => AbortController;
+ startStepIndex: number;
+ stepOutputs: string[];
+ resumePartialForCurrentStep?: string;
+ onProgress: (p: WorkflowRunProgress) => void;
+ onNoteMarkdown: (fullMarkdown: string) => void;
+ baseContentBeforeWorkflow: string;
+};
+
+/**
+ * Runs or resumes a workflow: streams each step into the note via `onNoteMarkdown`.
+ * ワークフローを実行または再開し、各ステップを `onNoteMarkdown` 経由でノートへストリームする。
+ */
+export async function runWorkflowExecution(options: {
+ definition: WorkflowDefinition;
+ cwd?: string;
+ pageExcerpt?: string;
+ /** Aborts the whole run (Stop). / 実行全体を中止(停止) */
+ workflowSignal: AbortSignal;
+ /** Fresh controller per step; UI aborts the current one on Pause. / ステップごとに新規。UI が一時停止で現在のみ abort */
+ createStepAbort: () => AbortController;
+ /** First step index to execute (0 on fresh run). / 最初に実行するステップ index(新規は 0) */
+ startStepIndex: number;
+ /** Completed outputs for steps before `startStepIndex`. / `startStepIndex` より前の完了出力 */
+ stepOutputs: string[];
+ /** When resuming the same step after pause, partial assistant text. / 一時停止後に同じステップを再開するときの部分テキスト */
+ resumePartialForCurrentStep?: string;
+ onProgress: (p: WorkflowRunProgress) => void;
+ /** Full note body = base snapshot + formatted workflow block. / ベーススナップショット + 整形済みブロック */
+ onNoteMarkdown: (fullMarkdown: string) => void;
+ /** Editor content before the workflow block was inserted. / ワークフローブロック挿入前のエディタ内容 */
+ baseContentBeforeWorkflow: string;
+}): Promise {
+ const {
+ definition,
+ cwd,
+ pageExcerpt,
+ workflowSignal,
+ createStepAbort,
+ startStepIndex,
+ stepOutputs: initialOutputs,
+ resumePartialForCurrentStep,
+ onProgress,
+ onNoteMarkdown,
+ baseContentBeforeWorkflow,
+ } = options;
+
+ const steps = definition.steps;
+ if (steps.length === 0) {
+ return { outcome: "error", error: "Workflow has no steps." };
+ }
+
+ const stepOutputs = normalizeStepOutputs(initialOutputs, steps.length);
+
+ return runWorkflowStepsLoop({
+ definition,
+ cwd,
+ pageExcerpt,
+ workflowSignal,
+ createStepAbort,
+ startStepIndex,
+ stepOutputs,
+ resumePartialForCurrentStep,
+ onProgress,
+ onNoteMarkdown,
+ baseContentBeforeWorkflow,
+ });
+}
+
+/**
+ * Pads or trims `stepOutputs` to match `stepsLength`.
+ * `stepOutputs` を `stepsLength` に合わせて埋めたり切り詰めたりする。
+ */
+function normalizeStepOutputs(initialOutputs: string[], stepsLength: number): string[] {
+ const stepOutputs = [...initialOutputs];
+ while (stepOutputs.length < stepsLength) {
+ stepOutputs.push("");
+ }
+ if (stepOutputs.length > stepsLength) {
+ stepOutputs.length = stepsLength;
+ }
+ return stepOutputs;
+}
+
+/**
+ * Main step loop: streams each step, updates note and progress.
+ * メインのステップループ:各ステップをストリームし、ノートと進捗を更新する。
+ */
+async function runWorkflowStepsLoop(
+ params: RunWorkflowStepsLoopParams,
+): Promise {
+ const {
+ definition,
+ cwd,
+ pageExcerpt,
+ workflowSignal,
+ createStepAbort,
+ startStepIndex,
+ stepOutputs,
+ resumePartialForCurrentStep,
+ onProgress,
+ onNoteMarkdown,
+ baseContentBeforeWorkflow,
+ } = params;
+
+ const steps = definition.steps;
+ const stepTitles = steps.map((s) => s.title);
+
+ const pushProgress = (
+ phase: WorkflowRunProgress["phase"],
+ currentStepIndex: number,
+ statuses: WorkflowStepRunStatus[],
+ streaming: string,
+ lastError?: string,
+ ): void => {
+ onProgress({
+ phase,
+ currentStepIndex,
+ stepStatuses: statuses,
+ stepOutputs: [...stepOutputs],
+ currentStepStreaming: streaming,
+ lastError,
+ });
+ };
+
+ const emitNote = (
+ currentStepIndex: number,
+ statuses: WorkflowStepRunStatus[],
+ streamingStepIndex: number | null,
+ streamingText: string,
+ ): void => {
+ const block = formatWorkflowNoteMarkdown({
+ title: definition.name,
+ stepTitles,
+ stepStatuses: statuses,
+ stepOutputs,
+ streamingStepIndex,
+ streamingText,
+ });
+ const base = baseContentBeforeWorkflow.trimEnd();
+ const full = base.length > 0 ? `${base}\n\n${block}` : block;
+ onNoteMarkdown(full);
+ };
+
+ for (let i = startStepIndex; i < steps.length; i += 1) {
+ if (workflowSignal.aborted) {
+ pushProgress("aborted", i, buildStatuses(steps.length, i, "pending"), "", undefined);
+ return { outcome: "stopped" };
+ }
+
+ const step = steps[i];
+ const statusesBefore = buildStatuses(steps.length, i, "running");
+ const initialStreaming =
+ resumePartialForCurrentStep && i === startStepIndex ? resumePartialForCurrentStep : "";
+ pushProgress("running", i, statusesBefore, initialStreaming, undefined);
+ emitNote(i, statusesBefore, i, initialStreaming);
+
+ const prior = stepOutputs.slice(0, i);
+
+ const prompt = buildWorkflowStepPrompt({
+ workflowName: definition.name,
+ step,
+ stepIndex: i,
+ totalSteps: steps.length,
+ pageExcerpt,
+ priorOutputs: prior,
+ resumeFromPartial: i === startStepIndex ? resumePartialForCurrentStep : undefined,
+ });
+
+ const stepController = createStepAbort();
+ const merged = AbortSignal.any([workflowSignal, stepController.signal]);
+
+ let streaming = initialStreaming;
+
+ const result = await streamClaudeQuery(
+ prompt,
+ {
+ cwd,
+ maxTurns: defaultWorkflowStepMaxTurns(step),
+ allowedTools: step.allowedTools,
+ },
+ merged,
+ {
+ onChunk: (chunk) => {
+ streaming += chunk;
+ const statuses = buildStatuses(steps.length, i, "running");
+ pushProgress("running", i, statuses, streaming, undefined);
+ emitNote(i, statuses, i, streaming);
+ },
+ },
+ );
+
+ if (!result.ok) {
+ if (result.error === "Aborted") {
+ if (workflowSignal.aborted) {
+ pushProgress("aborted", i, buildStatuses(steps.length, i, "error"), "", undefined);
+ return { outcome: "stopped" };
+ }
+ pushProgress("paused", i, buildStatuses(steps.length, i, "running"), streaming, undefined);
+ emitNote(i, buildStatuses(steps.length, i, "running"), i, streaming);
+ const stepOutputsById: Record = {};
+ for (let k = 0; k < i; k += 1) {
+ stepOutputsById[steps[k].id] = stepOutputs[k];
+ }
+ return {
+ outcome: "paused",
+ pausedAtStepIndex: i,
+ pausedStepId: steps[i].id,
+ stepOutputsById,
+ stepOutputs: [...stepOutputs],
+ partialForStep: streaming,
+ };
+ }
+ stepOutputs[i] = "";
+ const errStatuses = buildStatuses(steps.length, i, "error");
+ pushProgress("running", i, errStatuses, streaming, result.error);
+ emitNote(i, errStatuses, null, "");
+ return { outcome: "error", error: result.error };
+ }
+
+ stepOutputs[i] = result.content;
+ const doneStatuses = buildStatuses(steps.length, i, "done");
+ pushProgress("running", i, doneStatuses, "", undefined);
+ emitNote(i, doneStatuses, null, "");
+ }
+
+ pushProgress(
+ "completed",
+ steps.length - 1,
+ steps.map(() => "done"),
+ "",
+ undefined,
+ );
+ emitNote(
+ steps.length - 1,
+ steps.map(() => "done"),
+ null,
+ "",
+ );
+
+ return { outcome: "completed" };
+}
+
+function buildStatuses(
+ total: number,
+ runningIndex: number,
+ runningKind: WorkflowStepRunStatus,
+): WorkflowStepRunStatus[] {
+ const out: WorkflowStepRunStatus[] = [];
+ for (let j = 0; j < total; j += 1) {
+ if (j < runningIndex) out.push("done");
+ else if (j === runningIndex) out.push(runningKind);
+ else out.push("pending");
+ }
+ return out;
+}
diff --git a/src/lib/workflow/templates.ts b/src/lib/workflow/templates.ts
new file mode 100644
index 00000000..5ce1bbf5
--- /dev/null
+++ b/src/lib/workflow/templates.ts
@@ -0,0 +1,148 @@
+/**
+ * Built-in workflow templates (Issue #462).
+ * 組み込みワークフローテンプレート(Issue #462)。
+ */
+
+import type { WorkflowDefinition, WorkflowStepDefinition } from "./types";
+import { newWorkflowId } from "./newWorkflowId";
+
+/**
+ * Known template ids for selection UI.
+ * 選択 UI 用のテンプレート ID。
+ */
+export const WORKFLOW_TEMPLATE_IDS = [
+ "code-investigate-design",
+ "test-analyze-improve",
+ "repo-analyze-docs",
+ "web-research-note",
+] as const;
+
+/** Template id union. / テンプレート ID ユニオン */
+export type WorkflowTemplateId = (typeof WORKFLOW_TEMPLATE_IDS)[number];
+
+/**
+ * i18n key for the template title (see `aiChat.workflow.templates.*`).
+ * テンプレートタイトル用 i18n キー(`aiChat.workflow.templates.*`)。
+ */
+export const WORKFLOW_TEMPLATE_NAME_KEYS: Record = {
+ "code-investigate-design": "aiChat.workflow.templates.codeInvestigateDesign",
+ "test-analyze-improve": "aiChat.workflow.templates.testAnalyzeImprove",
+ "repo-analyze-docs": "aiChat.workflow.templates.repoAnalyzeDocs",
+ "web-research-note": "aiChat.workflow.templates.webResearchNote",
+};
+
+type StepSeed = Omit;
+
+function stepsForTemplate(id: WorkflowTemplateId): StepSeed[] {
+ switch (id) {
+ case "code-investigate-design":
+ return [
+ {
+ title: "Investigate code patterns",
+ instruction:
+ "Explore the linked workspace: identify API or routing patterns, naming conventions, and error-handling style. Summarize findings as bullet points.",
+ maxTurns: 20,
+ allowedTools: ["Read"],
+ },
+ {
+ title: "Draft design memo",
+ instruction:
+ "Based on the investigation, propose a design for the feature described in the note context. Output structured Markdown (goal, options, recommendation, risks).",
+ maxTurns: 16,
+ allowedTools: ["Read"],
+ },
+ ];
+ case "test-analyze-improve":
+ return [
+ {
+ title: "Run tests",
+ instruction:
+ "Run the project's test command in the linked workspace (e.g. `bun run test:run` or the standard command you detect). Capture failing vs passing summary.",
+ maxTurns: 12,
+ allowedTools: ["Bash", "Read"],
+ },
+ {
+ title: "Analyze results",
+ instruction:
+ "Analyze the test output: categorize failures, likely root causes, and flaky vs deterministic issues.",
+ maxTurns: 14,
+ allowedTools: ["Read"],
+ },
+ {
+ title: "Suggest improvements",
+ instruction:
+ "Propose concrete next steps: code changes, test fixes, and follow-up commands. Use Markdown with numbered actions.",
+ maxTurns: 14,
+ allowedTools: ["Read"],
+ },
+ ];
+ case "repo-analyze-docs":
+ return [
+ {
+ title: "Repository analysis",
+ instruction:
+ "Scan the repository structure (top-level dirs, packages, build entrypoints). Summarize architecture and main technologies.",
+ maxTurns: 20,
+ allowedTools: ["Read", "Bash"],
+ },
+ {
+ title: "Generate documentation draft",
+ instruction:
+ "Produce a documentation outline: overview, setup, development, testing, deployment. Fill with what you can infer from the repo.",
+ maxTurns: 18,
+ allowedTools: ["Read"],
+ },
+ ];
+ case "web-research-note":
+ return [
+ {
+ title: "Web research",
+ instruction:
+ "Research the topic implied by the note title/context using web search. Collect key facts, sources, and conflicting viewpoints.",
+ maxTurns: 20,
+ allowedTools: ["WebSearch", "Read"],
+ },
+ {
+ title: "Organize information",
+ instruction:
+ "Structure the findings: summary table or bullets, source list, and open questions.",
+ maxTurns: 12,
+ allowedTools: [],
+ },
+ {
+ title: "Draft note content",
+ instruction:
+ "Write polished Markdown suitable for the note: clear headings, links to sources, and a short conclusion.",
+ maxTurns: 16,
+ allowedTools: [],
+ },
+ ];
+ default: {
+ const _exhaustive: never = id;
+ throw new Error(`Unknown template: ${String(_exhaustive)}`);
+ }
+ }
+}
+
+/**
+ * Creates a new {@link WorkflowDefinition} from a built-in template.
+ * 組み込みテンプレートから新しい {@link WorkflowDefinition} を作る。
+ */
+export function instantiateWorkflowTemplate(
+ id: WorkflowTemplateId,
+ displayName: string,
+): WorkflowDefinition {
+ const now = Date.now();
+ const seeds = stepsForTemplate(id);
+ const steps: WorkflowStepDefinition[] = seeds.map((s) => ({
+ ...s,
+ id: newWorkflowId(),
+ }));
+ return {
+ id: newWorkflowId(),
+ name: displayName,
+ steps,
+ createdAt: now,
+ updatedAt: now,
+ };
+}
diff --git a/src/lib/workflow/types.ts b/src/lib/workflow/types.ts
new file mode 100644
index 00000000..7e110476
--- /dev/null
+++ b/src/lib/workflow/types.ts
@@ -0,0 +1,67 @@
+/**
+ * Multi-step Claude Code workflow (Issue #462).
+ * Claude Code マルチステップワークフロー(Issue #462)。
+ */
+
+/**
+ * One step in a workflow definition.
+ * ワークフロー定義の 1 ステップ。
+ */
+export interface WorkflowStepDefinition {
+ /** Stable id for React keys and persistence. / React キーと永続化用の安定 ID */
+ id: string;
+ /** Short label shown in UI and note. / UI とノートに表示する短いラベル */
+ title: string;
+ /** Instruction sent to Claude Code for this step. / このステップで Claude Code に渡す指示 */
+ instruction: string;
+ /**
+ * Max agent turns for this step (Claude Agent SDK `maxTurns`).
+ * このステップのエージェント最大ターン数(SDK `maxTurns`)。
+ */
+ maxTurns?: number;
+ /**
+ * Allowed tools for this step; omit for default sidecar tool set.
+ * このステップで許可するツール。省略時は sidecar 既定ツール。
+ */
+ allowedTools?: string[];
+}
+
+/**
+ * A saved or template-derived workflow.
+ * 保存済みまたはテンプレート由来のワークフロー。
+ */
+export interface WorkflowDefinition {
+ id: string;
+ name: string;
+ steps: WorkflowStepDefinition[];
+ createdAt: number;
+ updatedAt: number;
+}
+
+/**
+ * Lifecycle of a workflow run in the UI engine.
+ * UI エンジン上のワークフロー実行ライフサイクル。
+ */
+export type WorkflowRunPhase = "idle" | "running" | "paused" | "completed" | "aborted";
+
+/**
+ * Status of each step during a run.
+ * 実行中の各ステップの状態。
+ */
+export type WorkflowStepRunStatus = "pending" | "running" | "done" | "error";
+
+/**
+ * Snapshot emitted to UI while executing.
+ * 実行中に UI へ送るスナップショット。
+ */
+export interface WorkflowRunProgress {
+ phase: WorkflowRunPhase;
+ currentStepIndex: number;
+ stepStatuses: WorkflowStepRunStatus[];
+ /** Final assistant text per completed step. / 完了ステップごとの最終テキスト */
+ stepOutputs: string[];
+ /** Streaming buffer for the active step. / 実行中ステップのストリームバッファ */
+ currentStepStreaming: string;
+ /** Error message when a step fails. / ステップ失敗時のメッセージ */
+ lastError?: string;
+}
diff --git a/src/stores/workflowDefinitionsStore.ts b/src/stores/workflowDefinitionsStore.ts
new file mode 100644
index 00000000..1aac0cef
--- /dev/null
+++ b/src/stores/workflowDefinitionsStore.ts
@@ -0,0 +1,39 @@
+/**
+ * Persisted custom workflow definitions (Issue #462).
+ * 永続化するカスタムワークフロー定義(Issue #462)。
+ */
+
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import type { WorkflowDefinition } from "@/lib/workflow/types";
+
+interface WorkflowDefinitionsState {
+ definitions: WorkflowDefinition[];
+ /** Insert or replace by id. / id で挿入または置換 */
+ upsertDefinition: (definition: WorkflowDefinition) => void;
+ removeDefinition: (id: string) => void;
+}
+
+/**
+ * Local persisted store for user-defined workflows.
+ * ユーザー定義ワークフローをローカル永続化するストア。
+ */
+export const useWorkflowDefinitionsStore = create()(
+ persist(
+ (set, get) => ({
+ definitions: [],
+ upsertDefinition: (definition) => {
+ const rest = get().definitions.filter((d) => d.id !== definition.id);
+ const next = [...rest, definition].sort((a, b) => b.updatedAt - a.updatedAt);
+ set({ definitions: next });
+ },
+ removeDefinition: (id) => {
+ set({ definitions: get().definitions.filter((d) => d.id !== id) });
+ },
+ }),
+ {
+ name: "zedi-workflow-definitions",
+ version: 1,
+ },
+ ),
+);