storkit: merge 429_story_interactive_project_setup_wizard_for_new_storkit_projects

This commit is contained in:
dave
2026-03-28 13:26:29 +00:00
parent 9feed0f882
commit 0b50c66caa
10 changed files with 1217 additions and 59 deletions
+29
View File
@@ -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;
+77 -55
View File
@@ -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)}`}
+354
View File
@@ -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>
);
}