storkit: merge 429_story_interactive_project_setup_wizard_for_new_storkit_projects
This commit is contained in:
@@ -21,6 +21,19 @@ export type WsRequest =
|
||||
config: ProviderConfig;
|
||||
};
|
||||
|
||||
export interface WizardStepInfo {
|
||||
step: string;
|
||||
label: string;
|
||||
status: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface WizardStateData {
|
||||
steps: WizardStepInfo[];
|
||||
current_step_index: number;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface AgentAssignment {
|
||||
agent_name: string;
|
||||
model: string | null;
|
||||
@@ -80,6 +93,13 @@ export type WsResponse =
|
||||
| { type: "pong" }
|
||||
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
|
||||
| { type: "onboarding_status"; needs_onboarding: boolean }
|
||||
/** Sent on connect when a setup wizard is active. */
|
||||
| {
|
||||
type: "wizard_state";
|
||||
steps: WizardStepInfo[];
|
||||
current_step_index: number;
|
||||
completed: boolean;
|
||||
}
|
||||
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
|
||||
| { type: "thinking_token"; content: string }
|
||||
/** Streaming token from a /btw side question response. */
|
||||
@@ -438,6 +458,7 @@ export class ChatWebSocket {
|
||||
private onAgentConfigChanged?: () => void;
|
||||
private onAgentStateChanged?: () => void;
|
||||
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||
private onWizardState?: (state: WizardStateData) => void;
|
||||
private onSideQuestionToken?: (content: string) => void;
|
||||
private onSideQuestionDone?: (response: string) => void;
|
||||
private onLogEntry?: (
|
||||
@@ -528,6 +549,12 @@ export class ChatWebSocket {
|
||||
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
|
||||
if (data.type === "onboarding_status")
|
||||
this.onOnboardingStatus?.(data.needs_onboarding);
|
||||
if (data.type === "wizard_state")
|
||||
this.onWizardState?.({
|
||||
steps: data.steps,
|
||||
current_step_index: data.current_step_index,
|
||||
completed: data.completed,
|
||||
});
|
||||
if (data.type === "side_question_token")
|
||||
this.onSideQuestionToken?.(data.content);
|
||||
if (data.type === "side_question_done")
|
||||
@@ -587,6 +614,7 @@ export class ChatWebSocket {
|
||||
onAgentConfigChanged?: () => void;
|
||||
onAgentStateChanged?: () => void;
|
||||
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||
onWizardState?: (state: WizardStateData) => void;
|
||||
onSideQuestionToken?: (content: string) => void;
|
||||
onSideQuestionDone?: (response: string) => void;
|
||||
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
||||
@@ -606,6 +634,7 @@ export class ChatWebSocket {
|
||||
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
||||
this.onAgentStateChanged = handlers.onAgentStateChanged;
|
||||
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
||||
this.onWizardState = handlers.onWizardState;
|
||||
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
||||
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
||||
this.onLogEntry = handlers.onLogEntry;
|
||||
|
||||
@@ -4,7 +4,11 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import type { AgentConfigInfo } from "../api/agents";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import type { AnthropicModelInfo, PipelineState } from "../api/client";
|
||||
import type {
|
||||
AnthropicModelInfo,
|
||||
PipelineState,
|
||||
WizardStateData,
|
||||
} from "../api/client";
|
||||
import { api, ChatWebSocket } from "../api/client";
|
||||
import { useChatHistory } from "../hooks/useChatHistory";
|
||||
import type { Message, ProviderConfig } from "../types";
|
||||
@@ -17,6 +21,7 @@ import { LozengeFlyProvider } from "./LozengeFlyContext";
|
||||
import { MessageItem } from "./MessageItem";
|
||||
import type { LogEntry } from "./ServerLogsPanel";
|
||||
import { ServerLogsPanel } from "./ServerLogsPanel";
|
||||
import SetupWizard from "./SetupWizard";
|
||||
import { SideQuestionOverlay } from "./SideQuestionOverlay";
|
||||
import { StagePanel } from "./StagePanel";
|
||||
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||
@@ -217,6 +222,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
new Map(),
|
||||
);
|
||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||
const [wizardState, setWizardState] = useState<WizardStateData | null>(null);
|
||||
const onboardingTriggeredRef = useRef(false);
|
||||
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
||||
null,
|
||||
@@ -466,6 +472,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
onOnboardingStatus: (onboarding: boolean) => {
|
||||
setNeedsOnboarding(onboarding);
|
||||
},
|
||||
onWizardState: (state: WizardStateData) => {
|
||||
setWizardState(state);
|
||||
},
|
||||
onSideQuestionToken: (content) => {
|
||||
setSideQuestion((prev) =>
|
||||
prev ? { ...prev, response: prev.response + content } : prev,
|
||||
@@ -978,63 +987,76 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
gap: "24px",
|
||||
}}
|
||||
>
|
||||
{needsOnboarding && messages.length === 0 && !loading && (
|
||||
<div
|
||||
data-testid="onboarding-welcome"
|
||||
style={{
|
||||
padding: "24px",
|
||||
borderRadius: "12px",
|
||||
background: "#1c2a1c",
|
||||
border: "1px solid #2d4a2d",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
{wizardState &&
|
||||
!wizardState.completed &&
|
||||
messages.length === 0 &&
|
||||
!loading && (
|
||||
<SetupWizard
|
||||
wizardState={wizardState}
|
||||
onWizardUpdate={setWizardState}
|
||||
sendMessage={sendMessage}
|
||||
/>
|
||||
)}
|
||||
{needsOnboarding &&
|
||||
!wizardState &&
|
||||
messages.length === 0 &&
|
||||
!loading && (
|
||||
<div
|
||||
data-testid="onboarding-welcome"
|
||||
style={{
|
||||
margin: "0 0 8px 0",
|
||||
color: "#a0d4a0",
|
||||
fontSize: "1.1rem",
|
||||
padding: "24px",
|
||||
borderRadius: "12px",
|
||||
background: "#1c2a1c",
|
||||
border: "1px solid #2d4a2d",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Welcome to Storkit
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 16px 0",
|
||||
color: "#ccc",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
This project needs to be set up before you can start writing
|
||||
stories. The agent will guide you through configuring your
|
||||
project goals and tech stack.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="onboarding-start-button"
|
||||
onClick={() => {
|
||||
if (onboardingTriggeredRef.current) return;
|
||||
onboardingTriggeredRef.current = true;
|
||||
setNeedsOnboarding(false);
|
||||
sendMessage(
|
||||
"I just created a new project. Help me set it up.",
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
backgroundColor: "#a0d4a0",
|
||||
color: "#1a1a1a",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.95rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Start Project Setup
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<h3
|
||||
style={{
|
||||
margin: "0 0 8px 0",
|
||||
color: "#a0d4a0",
|
||||
fontSize: "1.1rem",
|
||||
}}
|
||||
>
|
||||
Welcome to Storkit
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 16px 0",
|
||||
color: "#ccc",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
This project needs to be set up before you can start
|
||||
writing stories. The agent will guide you through
|
||||
configuring your project goals and tech stack.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="onboarding-start-button"
|
||||
onClick={() => {
|
||||
if (onboardingTriggeredRef.current) return;
|
||||
onboardingTriggeredRef.current = true;
|
||||
setNeedsOnboarding(false);
|
||||
sendMessage(
|
||||
"I just created a new project. Help me set it up.",
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
backgroundColor: "#a0d4a0",
|
||||
color: "#1a1a1a",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.95rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Start Project Setup
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg: Message, idx: number) => (
|
||||
<MessageItem
|
||||
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { WizardStateData, WizardStepInfo } from "../api/client";
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
interface SetupWizardProps {
|
||||
wizardState: WizardStateData;
|
||||
onWizardUpdate: (state: WizardStateData) => void;
|
||||
sendMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
/** Style constants for the wizard UI. */
|
||||
const STEP_BG_PENDING = "#1a1f2e";
|
||||
const STEP_BG_ACTIVE = "#1c2a1c";
|
||||
const STEP_BG_DONE = "#1a2a1a";
|
||||
const STEP_BORDER_PENDING = "#2a2f3e";
|
||||
const STEP_BORDER_ACTIVE = "#2d4a2d";
|
||||
const STEP_BORDER_DONE = "#2d4a2d";
|
||||
const COLOR_LABEL = "#ccc";
|
||||
const COLOR_LABEL_DONE = "#a0d4a0";
|
||||
const COLOR_ACCENT = "#a0d4a0";
|
||||
|
||||
function statusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case "confirmed":
|
||||
return "\u2713";
|
||||
case "skipped":
|
||||
return "\u2013";
|
||||
case "generating":
|
||||
return "\u2026";
|
||||
case "awaiting_confirmation":
|
||||
return "?";
|
||||
default:
|
||||
return "\u00B7";
|
||||
}
|
||||
}
|
||||
|
||||
function stepBackground(status: string, isActive: boolean): string {
|
||||
if (status === "confirmed" || status === "skipped") return STEP_BG_DONE;
|
||||
if (isActive) return STEP_BG_ACTIVE;
|
||||
return STEP_BG_PENDING;
|
||||
}
|
||||
|
||||
function stepBorder(status: string, isActive: boolean): string {
|
||||
if (status === "confirmed" || status === "skipped") return STEP_BORDER_DONE;
|
||||
if (isActive) return STEP_BORDER_ACTIVE;
|
||||
return STEP_BORDER_PENDING;
|
||||
}
|
||||
|
||||
/** Messages sent to the chat to trigger agent generation for each step. */
|
||||
const STEP_PROMPTS: Record<string, string> = {
|
||||
context:
|
||||
"Read the codebase and generate .storkit/specs/00_CONTEXT.md with a project context spec. Include High-Level Goal, Core Features, Domain Definition, and Glossary sections. Then call the wizard API to store the content: PUT /api/wizard/step/context/content",
|
||||
stack:
|
||||
"Read the tech stack and generate .storkit/specs/tech/STACK.md with a tech stack spec. Include Core Stack, Coding Standards, Quality Gates, and Libraries sections. Then call the wizard API to store the content: PUT /api/wizard/step/stack/content",
|
||||
test_script:
|
||||
"Read the project structure and create script/test — a bash script that runs the project's actual test suite. Then call the wizard API: PUT /api/wizard/step/test_script/content",
|
||||
release_script:
|
||||
"Read the project's deployment setup and create script/release tailored to the project. Then call the wizard API: PUT /api/wizard/step/release_script/content",
|
||||
test_coverage:
|
||||
"If the stack supports coverage reporting, create script/test_coverage. Then call the wizard API: PUT /api/wizard/step/test_coverage/content",
|
||||
};
|
||||
|
||||
async function apiPost(path: string): Promise<WizardStateData | null> {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}${path}`, { method: "POST" });
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as WizardStateData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function StepCard({
|
||||
step,
|
||||
isActive,
|
||||
onGenerate,
|
||||
onConfirm,
|
||||
onSkip,
|
||||
}: {
|
||||
step: WizardStepInfo;
|
||||
isActive: boolean;
|
||||
onGenerate: () => void;
|
||||
onConfirm: () => void;
|
||||
onSkip: () => void;
|
||||
}) {
|
||||
const isDone = step.status === "confirmed" || step.status === "skipped";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`wizard-step-${step.step}`}
|
||||
style={{
|
||||
padding: "16px",
|
||||
borderRadius: "8px",
|
||||
background: stepBackground(step.status, isActive),
|
||||
border: `1px solid ${stepBorder(step.status, isActive)}`,
|
||||
opacity: !isActive && !isDone ? 0.5 : 1,
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
background: isDone ? COLOR_ACCENT : "transparent",
|
||||
border: isDone ? "none" : `1px solid ${COLOR_LABEL}`,
|
||||
color: isDone ? "#1a1a1a" : COLOR_LABEL,
|
||||
}}
|
||||
>
|
||||
{statusIcon(step.status)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
color: isDone ? COLOR_LABEL_DONE : COLOR_LABEL,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
{isActive && step.status === "pending" && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`wizard-generate-${step.step}`}
|
||||
onClick={onGenerate}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
backgroundColor: COLOR_ACCENT,
|
||||
color: "#1a1a1a",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
)}
|
||||
{isActive && step.status === "generating" && (
|
||||
<span style={{ color: "#aaa", fontSize: "0.85rem" }}>
|
||||
Generating...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step.content && step.status === "awaiting_confirmation" && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<pre
|
||||
data-testid={`wizard-preview-${step.step}`}
|
||||
style={{
|
||||
background: "#111",
|
||||
padding: "12px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "0.8rem",
|
||||
color: "#ddd",
|
||||
whiteSpace: "pre-wrap",
|
||||
maxHeight: "200px",
|
||||
overflow: "auto",
|
||||
margin: "0 0 12px 0",
|
||||
}}
|
||||
>
|
||||
{step.content}
|
||||
</pre>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`wizard-confirm-${step.step}`}
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
backgroundColor: COLOR_ACCENT,
|
||||
color: "#1a1a1a",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`wizard-revise-${step.step}`}
|
||||
onClick={onGenerate}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #555",
|
||||
backgroundColor: "transparent",
|
||||
color: "#ccc",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
Revise
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`wizard-skip-${step.step}`}
|
||||
onClick={onSkip}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #555",
|
||||
backgroundColor: "transparent",
|
||||
color: "#888",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && step.status === "pending" && !step.content && (
|
||||
<div style={{ marginTop: "8px", display: "flex", gap: "8px" }}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`wizard-skip-${step.step}`}
|
||||
onClick={onSkip}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #444",
|
||||
backgroundColor: "transparent",
|
||||
color: "#888",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
Skip this step
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SetupWizard({
|
||||
wizardState,
|
||||
onWizardUpdate,
|
||||
sendMessage,
|
||||
}: SetupWizardProps) {
|
||||
const [, setRefreshKey] = useState(0);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
(step: WizardStepInfo) => {
|
||||
const prompt = STEP_PROMPTS[step.step];
|
||||
if (prompt) {
|
||||
sendMessage(prompt);
|
||||
}
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
async (step: WizardStepInfo) => {
|
||||
const result = await apiPost(`/wizard/step/${step.step}/confirm`);
|
||||
if (result) {
|
||||
onWizardUpdate(result);
|
||||
setRefreshKey((k) => k + 1);
|
||||
}
|
||||
},
|
||||
[onWizardUpdate],
|
||||
);
|
||||
|
||||
const handleSkip = useCallback(
|
||||
async (step: WizardStepInfo) => {
|
||||
const result = await apiPost(`/wizard/step/${step.step}/skip`);
|
||||
if (result) {
|
||||
onWizardUpdate(result);
|
||||
setRefreshKey((k) => k + 1);
|
||||
}
|
||||
},
|
||||
[onWizardUpdate],
|
||||
);
|
||||
|
||||
if (wizardState.completed) {
|
||||
return (
|
||||
<div
|
||||
data-testid="wizard-complete"
|
||||
style={{
|
||||
padding: "24px",
|
||||
borderRadius: "12px",
|
||||
background: STEP_BG_DONE,
|
||||
border: `1px solid ${STEP_BORDER_DONE}`,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: "0 0 8px 0", color: COLOR_ACCENT }}>
|
||||
Setup Complete
|
||||
</h3>
|
||||
<p style={{ margin: 0, color: COLOR_LABEL }}>
|
||||
Your project is configured. You can start writing stories.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="setup-wizard"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<h3
|
||||
style={{
|
||||
margin: "0 0 4px 0",
|
||||
color: COLOR_ACCENT,
|
||||
fontSize: "1.1rem",
|
||||
}}
|
||||
>
|
||||
Project Setup Wizard
|
||||
</h3>
|
||||
<p style={{ margin: 0, color: "#999", fontSize: "0.85rem" }}>
|
||||
Step {wizardState.current_step_index + 1} of{" "}
|
||||
{wizardState.steps.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{wizardState.steps.map((step, idx) => (
|
||||
<StepCard
|
||||
key={step.step}
|
||||
step={step}
|
||||
isActive={idx === wizardState.current_step_index}
|
||||
onGenerate={() => handleGenerate(step)}
|
||||
onConfirm={() => handleConfirm(step)}
|
||||
onSkip={() => handleSkip(step)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user