diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx new file mode 100644 index 000000000..9fd2f9938 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx @@ -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 & { chips?: MentionChip[] }) { + const ref = useRef(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]); + + return ; +} + +// --- Wrapper with stateful selectors --- + +function PromptInputWithSelectors({ + chips, + showSelectors = true, + ...props +}: React.ComponentProps & { + chips?: MentionChip[]; + showSelectors?: boolean; +}) { + const [adapter, setAdapter] = useState("claude"); + const [modelOption, setModelOption] = + useState(mockModelOption); + const [reasoningOption, setReasoningOption] = + useState(mockReasoningOption); + + const handleModelChange = (value: string) => { + setModelOption({ ...mockModelOption, currentValue: value }); + }; + + const handleReasoningChange = (value: string) => { + setReasoningOption({ ...mockReasoningOption, currentValue: value }); + }; + + return ( + + ) : ( + false + ) + } + reasoningSelector={ + showSelectors ? ( + + ) : ( + false + ) + } + {...props} + /> + ); +} + +const meta: Meta = { + title: "Features/MessageEditor/PromptInput", + component: PromptInputWithSelectors, + parameters: { + layout: "padded", + }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + 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; + +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, + }, +}; diff --git a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx index ae1733913..5a75b8118 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx +++ b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx @@ -1,57 +1,102 @@ import { Tooltip } from "@components/ui/Tooltip"; import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore"; -import { GithubLogo } from "@phosphor-icons/react"; +import { + ChartLineIcon, + FileTextIcon, + FlagIcon, + FlaskIcon, + GithubLogoIcon, + TerminalIcon, + WarningIcon, + XIcon, +} from "@phosphor-icons/react"; +import { Chip } from "@posthog/quill"; import { trpcClient } from "@renderer/trpc/client"; import type { Node as PmNode } from "@tiptap/pm/model"; import type { Editor } from "@tiptap/react"; import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import type { MentionChipAttrs } from "./MentionChipNode"; +import type { ChipType, MentionChipAttrs } from "./MentionChipNode"; -const chipClass = - "inline cursor-default select-all rounded-[var(--radius-1)] bg-[var(--accent-a3)] px-1 py-px font-medium text-[var(--accent-11)] text-xs"; +const chipBase = "group/chip relative top-px active:translate-y-0 pl-1"; + +const selectedRing = "border-ring/50 ring-[1px] ring-ring/50"; + +const typeIconMap: Record> = { + file: FileTextIcon, + command: TerminalIcon, + github_issue: GithubLogoIcon, + error: WarningIcon, + experiment: FlaskIcon, + insight: ChartLineIcon, + feature_flag: FlagIcon, +}; + +function IconCloseButton({ + type, + onRemove, +}: { + type: ChipType; + onRemove: () => void; +}) { + const Icon = typeIconMap[type] || FileTextIcon; + + return ( + + ); +} function DefaultChip({ type, id, label, + selected, + onRemove, }: { type: string; id: string; label: string; + selected: boolean; + onRemove: () => void; }) { - if (type === "github_issue") { - return ( - - ); - } - const isCommand = type === "command"; const prefix = isCommand ? "/" : "@"; const isFile = type === "file"; - const chip = ( - window.open(id, "_blank") : undefined + } + className={`${chipBase} ${type === "github_issue" ? "cursor-pointer!" : "cursor-default! active:translate-y-0!"} ${isCommand ? "cli-slash-command" : "cli-file-mention"} ${selected ? selectedRing : ""}`} > - {prefix} - {label} - + + {type === "github_issue" ? label : `${prefix}${label}`} + ); if (isFile) { - return {chip}; + return {chipContent}; } - return chip; + return chipContent; } function PastedTextChip({ @@ -60,12 +105,16 @@ function PastedTextChip({ editor, node, getPos, + selected, + onRemove, }: { label: string; filePath: string; editor: Editor; node: PmNode; getPos: () => number | undefined; + selected: boolean; + onRemove: () => void; }) { const handleClick = async () => { useFeatureSettingsStore.getState().markHintLearned("paste-as-file"); @@ -88,21 +137,37 @@ function PastedTextChip({ return ( - + @{label} + ); } -export function MentionChipView({ node, getPos, editor }: NodeViewProps) { +export function MentionChipView({ + node, + getPos, + editor, + selected, +}: NodeViewProps) { const { type, id, label, pastedText } = node.attrs as MentionChipAttrs; + const handleRemove = () => { + const pos = getPos(); + if (pos == null) return; + editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + node.nodeSize }) + .run(); + }; + return ( {pastedText ? ( @@ -112,9 +177,17 @@ export function MentionChipView({ node, getPos, editor }: NodeViewProps) { editor={editor} node={node} getPos={getPos} + selected={selected} + onRemove={handleRemove} /> ) : ( - + )} );