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
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import { Providers } from "@components/Providers";
import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector";
import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector";
import type { AgentAdapter } from "@features/settings/stores/settingsStore";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect, useRef, useState } from "react";
import type { EditorHandle } from "../types";
import type { MentionChip } from "../utils/content";
import { PromptInput } from "./PromptInput";

// --- Mock data matching SessionConfigOption shape ---

const mockModelOption = {
id: "model",
name: "Model",
type: "select" as const,
currentValue: "gpt-5.4",
options: [
{
group: "recommended",
name: "Recommended",
options: [
{ value: "gpt-5.4", name: "GPT 5.4" },
{ value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
],
},
{
group: "other",
name: "Other",
options: [
{ value: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ value: "o3-pro", name: "o3-pro" },
{ value: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
],
},
],
} satisfies SessionConfigOption;

const mockReasoningOption = {
id: "thought",
name: "Reasoning",
type: "select" as const,
currentValue: "high",
options: [
{ value: "off", name: "Off" },
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
],
} satisfies SessionConfigOption;

// --- Wrapper to inject chips after mount ---

function PromptInputWithChips({
chips,
...props
}: React.ComponentProps<typeof PromptInput> & { chips?: MentionChip[] }) {
const ref = useRef<EditorHandle>(null);
const insertedRef = useRef(false);

useEffect(() => {
if (!chips?.length || insertedRef.current) return;
insertedRef.current = true;
const timer = setTimeout(() => {
for (const chip of chips) {
ref.current?.insertChip(chip);
}
}, 200);
return () => clearTimeout(timer);
}, [chips]);
Comment on lines +62 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Chips may be re-inserted on re-render

useEffect runs whenever chips reference changes. In Storybook, switching between stories or toggling any control causes a full re-render with a new chips array reference, so insertChip fires again for each chip already in the editor, producing duplicates. A stable reference (e.g. useRef guard or deep-equal comparison) would prevent this.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx
Line: 61-69

Comment:
**Chips may be re-inserted on re-render**

`useEffect` runs whenever `chips` reference changes. In Storybook, switching between stories or toggling any control causes a full re-render with a new `chips` array reference, so `insertChip` fires again for each chip already in the editor, producing duplicates. A stable reference (e.g. `useRef` guard or deep-equal comparison) would prevent this.

How can I resolve this? If you propose a fix, please make it concise.


return <PromptInput ref={ref} {...props} />;
}

// --- Wrapper with stateful selectors ---

function PromptInputWithSelectors({
chips,
showSelectors = true,
...props
}: React.ComponentProps<typeof PromptInput> & {
chips?: MentionChip[];
showSelectors?: boolean;
}) {
const [adapter, setAdapter] = useState<AgentAdapter>("claude");
const [modelOption, setModelOption] =
useState<SessionConfigOption>(mockModelOption);
const [reasoningOption, setReasoningOption] =
useState<SessionConfigOption>(mockReasoningOption);

const handleModelChange = (value: string) => {
setModelOption({ ...mockModelOption, currentValue: value });
};

const handleReasoningChange = (value: string) => {
setReasoningOption({ ...mockReasoningOption, currentValue: value });
};

return (
<PromptInputWithChips
chips={chips}
modelSelector={
showSelectors ? (
<UnifiedModelSelector
modelOption={modelOption}
adapter={adapter}
onAdapterChange={setAdapter}
onModelChange={handleModelChange}
/>
) : (
false
)
}
reasoningSelector={
showSelectors ? (
<ReasoningLevelSelector
thoughtOption={reasoningOption}
adapter={adapter}
onChange={handleReasoningChange}
/>
) : (
false
)
}
{...props}
/>
);
}

const meta: Meta<typeof PromptInputWithSelectors> = {
title: "Features/MessageEditor/PromptInput",
component: PromptInputWithSelectors,
parameters: {
layout: "padded",
},
decorators: [
(Story) => (
<Providers>
<div className="max-w-[800px]">
<Story />
</div>
</Providers>
),
],
args: {
sessionId: "storybook-session",
placeholder: "Type a message...",
disabled: false,
isLoading: false,
autoFocus: true,
isActiveSession: true,
enableBashMode: true,
enableCommands: true,
showSelectors: true,
onSubmit: () => {},
onCancel: () => {},
},
argTypes: {
disabled: { control: "boolean" },
isLoading: { control: "boolean" },
enableBashMode: { control: "boolean" },
enableCommands: { control: "boolean" },
showSelectors: { control: "boolean" },
placeholder: { control: "text" },
},
};

export default meta;
type Story = StoryObj<typeof PromptInputWithSelectors>;

export const Default: Story = {};

export const WithFileChip: Story = {
name: "With File Chip",
args: {
chips: [
{
type: "file",
id: "/src/settings.json",
label: ".claude/settings.json",
},
],
},
};

export const WithCommandChip: Story = {
name: "With Command Chip",
args: {
chips: [{ type: "command", id: "good", label: "good" }],
},
};

export const WithMultipleChips: Story = {
name: "With Multiple Chips",
args: {
chips: [
{
type: "file",
id: "/src/settings.json",
label: ".claude/settings.json",
},
{ type: "command", id: "good", label: "good" },
{
type: "file",
id: "/workflows/release.yml",
label: "workflows/agent-release.yml",
},
],
},
};

export const AllChipTypes: Story = {
name: "All Chip Types",
args: {
chips: [
{ type: "file", id: "/src/index.ts", label: "src/index.ts" },
{ type: "command", id: "review", label: "review" },
{
type: "github_issue",
id: "https://github.com/org/repo/issues/123",
label: "#123 Fix the bug",
},
{ type: "error", id: "error-1", label: "TypeError: undefined" },
{ type: "experiment", id: "exp-1", label: "new-checkout-flow" },
{ type: "insight", id: "insight-1", label: "Weekly active users" },
{ type: "feature_flag", id: "flag-1", label: "enable-dark-mode" },
{
type: "file",
id: "/tmp/pasted-content.txt",
label: "pasted-content.txt",
},
],
},
};

export const BashMode: Story = {
name: "Bash Mode (type ! to activate)",
args: {
enableBashMode: true,
placeholder: "Type ! to enter bash mode...",
},
};

export const Loading: Story = {
name: "Loading (With Cancel)",
args: {
isLoading: true,
},
};

export const Disabled: Story = {
args: {
disabled: true,
},
};

export const LongChipLabels: Story = {
name: "Long Chip Labels",
args: {
chips: [
{
type: "file",
id: "/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx",
label:
"apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx",
},
{
type: "file",
id: "/packages/agent/src/adapters/claude/permissions/permission-options.ts",
label:
"packages/agent/src/adapters/claude/permissions/permission-options.ts",
},
],
},
};

export const NoToolbar: Story = {
name: "No Toolbar (Minimal)",
args: {
showSelectors: false,
},
};
Loading
Loading