From d37ddc501c98ea7bbd22991f86b604b97b43024f Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:40:09 +0800 Subject: [PATCH] feat(app): add prompt input story (#32308) --- .../src/components/prompt-input.stories.tsx | 117 +++++++ packages/app/src/components/prompt-input.tsx | 325 ++++++++++-------- .../components/prompt-input/attachments.ts | 5 +- .../components/prompt-input/context-items.tsx | 2 +- .../components/prompt-input/submit.test.ts | 32 +- .../app/src/components/prompt-input/submit.ts | 5 +- packages/app/src/context/prompt.tsx | 46 ++- .../composer/session-composer-region.tsx | 74 +++- packages/storybook/.storybook/main.ts | 2 +- .../mocks/app/context/permission.ts | 3 + .../.storybook/mocks/app/context/prompt.ts | 64 ++-- .../.storybook/mocks/app/context/sdk.ts | 18 +- .../.storybook/mocks/app/context/sync.ts | 31 +- .../.storybook/mocks/solid-router.tsx | 4 + packages/storybook/.storybook/preview.tsx | 1 + 15 files changed, 501 insertions(+), 228 deletions(-) create mode 100644 packages/app/src/components/prompt-input.stories.tsx diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx new file mode 100644 index 000000000..b56287a87 --- /dev/null +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -0,0 +1,117 @@ +// @ts-nocheck +import { createStore } from "solid-js/store" +import { createPromptState } from "@/context/prompt" +import { createPromptInputHistory, PromptInput } from "./prompt-input" + +function PromptInputExample() { + const state = createPromptState() + const history = createPromptInputHistory() + const [controls, setControls] = createStore({ + agent: "build", + variant: undefined as string | undefined, + comments: 0, + tabs: [] as string[], + activeTab: undefined as string | undefined, + reviewOpen: false, + }) + const model = { + current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), + variant: { + list: () => ["fast", "thinking"], + current: () => controls.variant, + set: (variant?: string) => setControls("variant", variant), + }, + } + const submission = { + abort() {}, + handleSubmit(event: Event) { + event.preventDefault() + state.reset() + }, + } + const inputControls = { + agents: { + available: [{ name: "review", hidden: false, mode: "subagent" }], + options: ["build", "review", "plan"], + get current() { + return controls.agent + }, + loading: false, + visible: true, + select: (agent?: string) => setControls("agent", agent ?? "build"), + }, + model: { + selection: model, + paid: true, + loading: false, + }, + projects: { + available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }], + directory: "/tmp/story", + select() {}, + add() {}, + }, + session: { + id: "story-session", + tabs: { + active: () => controls.activeTab, + all: () => controls.tabs, + open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])), + setActive: (tab: string) => setControls("activeTab", tab), + }, + reviewPanel: { + opened: () => controls.reviewOpen, + open: () => setControls("reviewOpen", true), + }, + }, + newLayoutDesigns: true, + } + const addReviewComment = () => { + const comment = controls.comments + 1 + setControls("comments", comment) + state.context.add({ + type: "file", + path: "src/components/prompt-input.tsx", + selection: { + startLine: 84 + comment, + startChar: 0, + endLine: 84 + comment, + endChar: 0, + }, + comment: `Review comment ${comment}`, + commentID: `review-comment-${comment}`, + commentOrigin: "review", + preview: "export const PromptInput = ...", + }) + } + + return ( +
+ +
+ +
+
+ ) +} + +export default { + title: "App/PromptInput", + id: "app-prompt-input", + component: PromptInput, +} + +export const Basic = { + render: () => ( +
+

Prompt Input

+ +
+ ), +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index d9aaf336c..404893a23 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -17,8 +17,8 @@ import { type JSX, } from "solid-js" import { Popover as KobaltePopover } from "@kobalte/core/popover" -import { createStore } from "solid-js/store" -import { useLocal } from "@/context/local" +import { createStore, type SetStoreFunction, type Store } from "solid-js/store" +import type { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { ContentPart, @@ -31,10 +31,7 @@ import { FileAttachmentPart, } from "@/context/prompt" import { useLayout } from "@/context/layout" -import { useNavigate, useSearchParams } from "@solidjs/router" import { useSDK } from "@/context/sdk" -import { useServer } from "@/context/server" -import { useTabs } from "@/context/tabs" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" @@ -46,14 +43,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover } from "@/components/dialog-select-model" -import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" -import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" @@ -73,18 +67,104 @@ import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" -import { useDirectoryPicker } from "./directory-picker" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" -import { useQueries } from "@tanstack/solid-query" -import { useQueryOptions } from "@/context/server-sync" import { pathKey } from "@/utils/path-key" -import { base64Encode } from "@opencode-ai/core/util/encode" import { displayName } from "@/pages/layout/helpers" -interface PromptInputProps { +export type PromptInputState = ReturnType + +export type PromptInputHistory = { + entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] + add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void +} + +export type PromptInputSubmission = { + abort: () => Promise | void + handleSubmit: (event: Event) => Promise | void +} + +export type PromptInputControls = { + agents: { + available: { name: string; hidden?: boolean; mode: string }[] + options: string[] + current: string + loading: boolean + visible: boolean + select: (name: string | undefined) => void + } + model: { + selection: ReturnType["model"] + paid: boolean + loading: boolean + } + projects: { + available: { name?: string; worktree: string; sandboxes?: string[] }[] + directory: string + select: (worktree: string) => void + add: (title: string) => void + } + session: { + id?: string + tabs: { + active: () => string | undefined + all: () => string[] + open: (tab: string) => void | Promise + setActive: (tab: string) => void + } + reviewPanel: { + opened: () => boolean + open: () => void + } + } + newLayoutDesigns: boolean +} + +export function createPromptInputHistory(): PromptInputHistory { + const [normal, setNormal] = createStore({ entries: [] }) + const [shell, setShell] = createStore({ entries: [] }) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} + +type PromptHistoryState = { entries: PromptHistoryStoredEntry[] } + +function createPromptInputHistoryStore( + normal: Store, + setNormal: SetStoreFunction, + shell: Store, + setShell: SetStoreFunction, +): PromptInputHistory { + return { + entries: (mode) => (mode === "shell" ? shell.entries : normal.entries), + add(prompt, mode, comments) { + const current = mode === "shell" ? shell : normal + const setCurrent = mode === "shell" ? setShell : setNormal + const next = prependHistoryEntry(current.entries, prompt, comments) + if (next === current.entries) return + setCurrent("entries", next) + }, + } +} + +function createPersistedPromptInputHistory() { + const [normal, setNormal] = persisted( + Persist.global("prompt-history", ["prompt-history.v1"]), + createStore({ entries: [] }), + ) + const [shell, setShell] = persisted( + Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), + createStore({ entries: [] }), + ) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} + +export interface PromptInputProps { class?: string variant?: "dock" | "new-session" + state?: PromptInputState + history?: PromptInputHistory + submission?: PromptInputSubmission + controls: PromptInputControls ref?: (el: HTMLDivElement) => void newSessionWorktree?: string onNewSessionWorktreeReset?: () => void @@ -126,27 +206,18 @@ const EXAMPLES = [ export const PromptInput: Component = (props) => { const sdk = useSDK() - const navigate = useNavigate() - const queryOptions = useQueryOptions() const sync = useSync() - const local = useLocal() const files = useFile() - const prompt = usePrompt() + const prompt = props.state ?? usePrompt() const layout = useLayout() - const server = useServer() const comments = useComments() const dialog = useDialog() - const providers = useProviders() const command = useCommand() const permission = usePermission() const language = useLanguage() const platform = usePlatform() - const pickDirectory = useDirectoryPicker() - const settings = useSettings() - const tabsStore = useTabs() - const [search] = useSearchParams<{ draftId?: string }>() - const { params, tabs, view } = useSessionLayout() + const tabs = () => props.controls.session.tabs let editorRef!: HTMLDivElement let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement @@ -204,7 +275,7 @@ export const PromptInput: Component = (props) => { }).activeFileTab const commentInReview = (path: string) => { - const sessionID = params.id + const sessionID = props.controls.session.id if (!sessionID) return false const diffs = sync().data.session_diff[sessionID] @@ -237,14 +308,14 @@ export const PromptInput: Component = (props) => { const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) if (wantsReview) { - if (!view().reviewPanel.opened()) view().reviewPanel.open() + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() layout.fileTree.setTab("changes") tabs().setActive("review") queueCommentFocus() return } - if (!view().reviewPanel.opened()) view().reviewPanel.open() + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() layout.fileTree.setTab("all") const tab = files.tab(item.path) void tabs().open(tab) @@ -269,8 +340,10 @@ export const PromptInput: Component = (props) => { return paths }) - const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) - const working = createMemo(() => sync().data.session_working(params.id ?? "")) + const info = createMemo(() => + props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined, + ) + const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) const imageAttachments = createMemo(() => prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) @@ -347,29 +420,14 @@ export const PromptInput: Component = (props) => { }) const hasUserPrompt = createMemo(() => { - const sessionID = params.id + const sessionID = props.controls.session.id if (!sessionID) return false const messages = sync().data.message[sessionID] if (!messages) return false return messages.some((m) => m.role === "user") }) - const [history, setHistory] = persisted( - Persist.global("prompt-history", ["prompt-history.v1"]), - createStore<{ - entries: PromptHistoryStoredEntry[] - }>({ - entries: [], - }), - ) - const [shellHistory, setShellHistory] = persisted( - Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), - createStore<{ - entries: PromptHistoryStoredEntry[] - }>({ - entries: [], - }), - ) + const history = props.history ?? createPersistedPromptInputHistory() const suggest = createMemo(() => !hasUserPrompt()) @@ -573,8 +631,8 @@ export const PromptInput: Component = (props) => { } createEffect(() => { - params.id - if (params.id) return + props.controls.session.id + if (props.controls.session.id) return if (!suggest()) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) @@ -603,11 +661,10 @@ export const PromptInput: Component = (props) => { } const agentList = createMemo(() => - sync() - .data.agent.filter((agent) => !agent.hidden && agent.mode !== "primary") + props.controls.agents.available + .filter((agent) => !agent.hidden && agent.mode !== "primary") .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) - const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) const handleAtSelect = (option: AtOption | undefined) => { if (!option) return @@ -1033,11 +1090,7 @@ export const PromptInput: Component = (props) => { } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { - const currentHistory = mode === "shell" ? shellHistory : history - const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory - const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments()) - if (next === currentHistory.entries) return - setCurrentHistory("entries", next) + history.add(prompt, mode, mode === "shell" ? [] : historyComments()) } createEffect( @@ -1082,7 +1135,7 @@ export const PromptInput: Component = (props) => { const navigateHistory = (direction: "up" | "down") => { const result = navigatePromptHistory({ direction, - entries: store.mode === "shell" ? shellHistory.entries : history.entries, + entries: history.entries(store.mode), historyIndex: store.historyIndex, currentPrompt: prompt.current(), currentComments: historyComments(), @@ -1096,6 +1149,7 @@ export const PromptInput: Component = (props) => { } const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ + prompt, editor: () => editorRef, isDialogActive: () => !!dialog.active, setDraggingType: (type) => setStore("draggingType", type), @@ -1122,38 +1176,41 @@ export const PromptInput: Component = (props) => { /> ) - const variants = createMemo(() => ["default", ...local.model.variant.list()]) + const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) // Check provider variants directly: `variants` also includes the UI-only default option. - const showVariantControl = createMemo(() => local.model.variant.list().length > 0) + const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0) const accepting = createMemo(() => { - const id = params.id + const id = props.controls.session.id if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) return permission.isAutoAccepting(id, sdk().directory) }) - const { abort, handleSubmit } = createPromptSubmit({ - info, - imageAttachments, - commentCount, - autoAccept: () => accepting(), - mode: () => store.mode, - working, - editor: () => editorRef, - queueScroll, - promptLength, - addToHistory, - resetHistoryNavigation: () => { - resetHistoryNavigation(true) - }, - setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => setStore("popover", popover), - newSessionWorktree: () => props.newSessionWorktree, - onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, - onQueue: props.onQueue, - onAbort: props.onAbort, - onSubmit: props.onSubmit, - }) + const { abort, handleSubmit } = + props.submission ?? + createPromptSubmit({ + prompt, + info, + imageAttachments, + commentCount, + autoAccept: () => accepting(), + mode: () => store.mode, + working, + editor: () => editorRef, + queueScroll, + promptLength, + addToHistory, + resetHistoryNavigation: () => { + resetHistoryNavigation(true) + }, + setMode: (mode) => setStore("mode", mode), + setPopover: (popover) => setStore("popover", popover), + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + }) const handleKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { @@ -1317,17 +1374,9 @@ export const PromptInput: Component = (props) => { } } - const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ - queries: [ - queryOptions().agents(pathKey(sdk().directory)), - queryOptions().providers(null), - queryOptions().providers(pathKey(sdk().directory)), - ], - })) - - const agentsLoading = () => agentsQuery.isLoading + const agentsLoading = () => props.controls.agents.loading const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) - const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading + const providersLoading = () => props.controls.model.loading const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) const [promptReady] = createResource( @@ -1342,23 +1391,23 @@ export const PromptInput: Component = (props) => { const modelControlState = createMemo(() => ({ loading: providersLoading(), - paid: providers.paid().length > 0, + paid: props.controls.model.paid, title: language.t("command.model.choose"), keybind: command.keybind("model.choose"), - model: local.model, - providerID: local.model.current()?.provider?.id, - modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"), + model: props.controls.model.selection, + providerID: props.controls.model.selection.current()?.provider?.id, + modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"), style: control(), onClose: restoreFocus, onUnpaidClick: () => { void import("@/components/dialog-select-model-unpaid").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) }, })) const newSession = () => props.variant === "new-session" - const projects = createMemo(() => layout.projects.list()) + const projects = createMemo(() => props.controls.projects.available) const projectForDirectory = (directory: string | undefined) => { if (!directory) return const key = pathKey(directory) @@ -1366,13 +1415,13 @@ export const PromptInput: Component = (props) => { (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), ) } - const selectedProject = createMemo(() => projectForDirectory(sdk().directory)) + const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory)) const projectResults = createMemo(() => { const search = picker.projectSearch.trim().toLowerCase() if (!search) return projects() return projects().filter((project) => displayName(project).toLowerCase().includes(search)) }) - const showAgentControl = createMemo(() => settings.visibility.customAgents() && agentNames().length > 0) + const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) const selectProject = (worktree: string) => { setPicker({ projectOpen: false, @@ -1382,33 +1431,11 @@ export const PromptInput: Component = (props) => { restoreFocus() return } - layout.projects.open(worktree) - server.projects.touch(worktree) - - // On the draft route, retarget the existing draft in place so we keep the same - // draft id (and its tab/prompt) instead of spawning a new draft for the new directory. - const draftID = search.draftId - if (draftID) { - tabsStore.updateDraft(draftID, { server: server.key, directory: worktree }) - restoreFocus() - return - } - - navigate(`/${base64Encode(worktree)}/session`) + props.controls.projects.select(worktree) + restoreFocus() } const addProject = () => { - const conn = server.current - if (!conn) return - const select = (result: string | string[] | null) => { - const directory = Array.isArray(result) ? result[0] : result - if (!directory) return - selectProject(directory) - } - pickDirectory({ - server: conn, - title: language.t("command.project.open"), - onSelect: select, - }) + props.controls.projects.add(language.t("command.project.open")) } const projectPickerState = createMemo(() => ({ @@ -1449,11 +1476,11 @@ export const PromptInput: Component = (props) => { const agentControlState = createMemo(() => ({ title: language.t("command.agent.cycle"), keybind: command.keybind("agent.cycle"), - options: agentNames(), - current: local.agent.current()?.name ?? "", + options: props.controls.agents.options, + current: props.controls.agents.current, style: control(), onSelect: (value) => { - local.agent.set(value) + props.controls.agents.select(value) restoreFocus() }, })) @@ -1485,7 +1512,7 @@ export const PromptInput: Component = (props) => { t={(key) => language.t(key as Parameters[0])} /> - +
= (props) => { data-component="prompt-variant-control" classList={{ "hidden group-hover/prompt-input:block group-focus-within/prompt-input:block": - !local.model.variant.current() && !store.variantOpen, + !props.controls.model.selection.variant.current() && !store.variantOpen, }} > = (props) => { { - local.agent.set(value) + props.controls.agents.select(value) restoreFocus() }} class="capitalize max-w-[160px] text-text-base" @@ -1883,7 +1910,7 @@ export const PromptInput: Component = (props) => { style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined} > 0} + when={props.controls.model.paid} fallback={ = (props) => { style={control()} onClick={() => { void import("@/components/dialog-select-model-unpaid").then((x) => { - dialog.show(() => ) + dialog.show(() => ( + + )) }) }} > - + - {local.model.current()?.name ?? language.t("dialog.model.select.title")} + {props.controls.model.selection.current()?.name ?? + language.t("dialog.model.select.title")} @@ -1926,7 +1956,7 @@ export const PromptInput: Component = (props) => { keybind={command.keybind("model.choose")} > = (props) => { }} onClose={restoreFocus} > - + - {local.model.current()?.name ?? language.t("dialog.model.select.title")} + {props.controls.model.selection.current()?.name ?? + language.t("dialog.model.select.title")} @@ -1966,10 +1997,10 @@ export const PromptInput: Component = (props) => {