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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions src/components/ai-chat/AIChatPanelContent.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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} 向けプロパティ。
Expand Down Expand Up @@ -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 (
<div className="bg-background relative flex h-full flex-col border-l">
<AIChatHeader />
Expand Down Expand Up @@ -116,7 +133,7 @@ export function AIChatPanelContent({
onSwitchBranch={switchBranch}
isStreaming={isStreaming}
/>
) : (
) : activeViewTab === "branch" ? (
<Suspense fallback={null}>
<AIChatBranchTree
messageMap={messageMap}
Expand All @@ -127,10 +144,23 @@ export function AIChatPanelContent({
onDeleteBranch={handleDeleteBranchFromTree}
/>
</Suspense>
)}
) : null}
{keepWorkflowMounted ? (
<div
className={cn(
"h-full min-h-0 flex-col",
activeViewTab === "workflow" ? "flex" : "hidden",
)}
>
<Suspense fallback={null}>
<AIChatWorkflowPanel />
</Suspense>
</div>
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
) : null}
</div>

<div className="bg-background border-t p-4">
{/* Stay mounted on workflow tab so uncontrolled input draft is not lost. / ワークフロー切替で下書きを失わない */}
<div className={cn("bg-background border-t p-4", activeViewTab === "workflow" && "hidden")}>
<AIChatInput
onSendMessage={handleSendMessage}
onStopStreaming={stopStreaming}
Expand Down
11 changes: 10 additions & 1 deletion src/components/ai-chat/AIChatViewTabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ describe("AIChatViewTabs", () => {
vi.clearAllMocks();
});

it("renders Chat and Branch tabs", () => {
it("renders Chat, Branch, and Workflow tabs", () => {
const onTabChange = vi.fn();
render(<AIChatViewTabs activeTab="chat" onTabChange={onTabChange} />);
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", () => {
Expand All @@ -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(<AIChatViewTabs activeTab="chat" onTabChange={onTabChange} />);
await user.click(screen.getByRole("tab", { name: "aiChat.viewTabs.workflow" }));
expect(onTabChange).toHaveBeenCalledWith("workflow");
});
});
11 changes: 9 additions & 2 deletions src/components/ai-chat/AIChatViewTabs.tsx
Original file line number Diff line number Diff line change
@@ -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}.
Expand Down Expand Up @@ -43,6 +43,13 @@ export function AIChatViewTabs({ activeTab, onTabChange }: AIChatViewTabsProps)
<GitBranch className="h-3.5 w-3.5" />
{t("aiChat.viewTabs.branch")}
</TabsTrigger>
<TabsTrigger
value="workflow"
className="flex h-7 items-center gap-1.5 px-3 text-xs data-[state=active]:shadow-sm"
>
<ListChecks className="h-3.5 w-3.5" />
{t("aiChat.viewTabs.workflow")}
</TabsTrigger>
</TabsList>
</Tabs>
);
Expand Down
16 changes: 16 additions & 0 deletions src/components/ai-chat/AIChatWorkflowPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 <WorkflowPanelForm {...logic} />;
}
135 changes: 135 additions & 0 deletions src/components/ai-chat/WorkflowPanelForm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-full min-h-0 flex-col gap-3 p-3">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<ListChecks className="h-4 w-4 shrink-0" />
<span>{t("aiChat.workflow.subtitle")}</span>
</div>

{!isTauriDesktop() && (
<p className="text-destructive text-xs">{t("aiChat.workflow.desktopOnly")}</p>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<ScrollArea className="min-h-0 flex-1 pr-2">
<div className="flex flex-col gap-3 pb-2">
<WorkflowPanelMetaSection
t={t}
draft={draft}
setDraft={setDraft}
definitions={definitions}
selectedSavedId={selectedSavedId}
importInputRef={importInputRef}
running={running}
loadTemplate={loadTemplate}
saveCustom={saveCustom}
exportJson={exportJson}
onImportFile={onImportFile}
loadSaved={loadSaved}
deleteSaved={deleteSaved}
/>
<WorkflowPanelStepsAndProgress
t={t}
draft={draft}
running={running}
progress={progress}
activeRunSteps={activeRunSteps}
addStep={addStep}
removeStep={removeStep}
updateStep={updateStep}
/>
</div>
</ScrollArea>

<div className="border-border flex flex-wrap gap-2 border-t pt-2">
<Button
type="button"
size="sm"
className="h-8 text-xs"
disabled={running || !canRunWorkflow}
onClick={() => void runExecution("fresh")}
>
<Play className="mr-1 h-3 w-3" />
{t("aiChat.workflow.run")}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 text-xs"
disabled={!running}
onClick={handlePause}
>
<Pause className="mr-1 h-3 w-3" />
{t("aiChat.workflow.pause")}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 text-xs"
disabled={running || !pausedState || !canRunWorkflow}
onClick={() => void runExecution("resume")}
>
{t("aiChat.workflow.resume")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="h-8 text-xs"
disabled={!running}
onClick={handleStop}
>
<Square className="mr-1 h-3 w-3" />
{t("aiChat.workflow.stop")}
</Button>
</div>
</div>
);
}
Loading
Loading