feat(session-ui): rewrite v2 prompt input (#37102)
This commit is contained in:
parent
ed926be253
commit
c0a258b22a
35 changed files with 3609 additions and 1083 deletions
|
|
@ -9,7 +9,16 @@ import { Font } from "@opencode-ai/ui/font"
|
|||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import {
|
||||
type BaseRouterProps,
|
||||
Navigate,
|
||||
Route,
|
||||
Router,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
|
@ -29,6 +38,7 @@ import {
|
|||
Show,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
|
|
|
|||
585
packages/app/src/components/prompt-input-v2.tsx
Normal file
585
packages/app/src/components/prompt-input-v2.tsx
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import { createEffect, createMemo, on, Show } from "solid-js"
|
||||
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
import type { PromptInputProps } from "@/components/prompt-input/contracts"
|
||||
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
|
||||
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
|
||||
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
|
||||
import { createPromptSubmit } from "@/components/prompt-input/submit"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
|
||||
import {
|
||||
createPromptInputV2Controller,
|
||||
createPromptInputV2State,
|
||||
type PromptInputV2Interaction,
|
||||
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
|
||||
|
||||
export type PromptInputV2ComposerProps = {
|
||||
class?: string
|
||||
controller: PromptInputV2ComposerController
|
||||
edit?: PromptInputProps["edit"]
|
||||
onEditLoaded?: PromptInputProps["onEditLoaded"]
|
||||
}
|
||||
|
||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
|
||||
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||
readonly model: PromptInputProps["controls"]["model"]
|
||||
}
|
||||
|
||||
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
|
||||
useCommands(props)
|
||||
useEditHandler(props)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInputV2
|
||||
controller={props.controller}
|
||||
class={props.class}
|
||||
modelControl={
|
||||
<PromptInputV2ModelControl
|
||||
loading={props.controller.model.loading}
|
||||
paid={props.controller.model.paid}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybindParts("model.choose")}
|
||||
model={props.controller.model.selection}
|
||||
providerID={props.controller.model.selection.current()?.provider?.id}
|
||||
modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
onClose={props.controller.restoreFocus}
|
||||
onUnpaidClick={() =>
|
||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useEditHandler = (props: PromptInputV2ComposerProps) => {
|
||||
const prompt = usePrompt()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.edit?.id,
|
||||
(id) => {
|
||||
const edit = props.edit
|
||||
if (!id || !edit) return
|
||||
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
||||
edit.context.forEach((item) =>
|
||||
prompt.context.add({
|
||||
type: item.type,
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
}),
|
||||
)
|
||||
props.controller.dispatch({ type: "mode.normal" })
|
||||
props.controller.resetHistory()
|
||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||
props.controller.restoreFocus()
|
||||
props.onEditLoaded?.()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const useCommands = (props: PromptInputV2ComposerProps) => {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
|
||||
command.register("prompt-input", () => [
|
||||
{
|
||||
id: "file.attach",
|
||||
title: language.t("prompt.action.attachFile"),
|
||||
category: language.t("command.category.file"),
|
||||
keybind: "mod+u",
|
||||
disabled: props.controller.state.mode !== "normal",
|
||||
onSelect: () => props.controller.attach(),
|
||||
},
|
||||
{
|
||||
id: "prompt.mode.shell",
|
||||
title: language.t("command.prompt.mode.shell"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "mod+shift+x",
|
||||
disabled: props.controller.state.mode === "shell",
|
||||
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
|
||||
},
|
||||
{
|
||||
id: "prompt.mode.normal",
|
||||
title: language.t("command.prompt.mode.normal"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "mod+shift+e",
|
||||
disabled: props.controller.state.mode === "normal",
|
||||
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const files = useFile()
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const prompt = props.state ?? usePrompt()
|
||||
let editor: HTMLDivElement | undefined
|
||||
|
||||
const interaction = createPromptInputV2State()
|
||||
const mode = () => interaction[0].mode
|
||||
const history = props.history ?? createPersistedPromptInputHistory()
|
||||
const tabs = () => props.controls.session.tabs
|
||||
const activeFileTab = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: files.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
const recent = createMemo(() => {
|
||||
const all = tabs().all()
|
||||
const active = activeFileTab()
|
||||
const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
|
||||
return order.reduce<string[]>((result, tab) => {
|
||||
const path = files.pathFromTab(tab)
|
||||
if (!path || result.includes(path)) return result
|
||||
return [...result, path]
|
||||
}, [])
|
||||
})
|
||||
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 attachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
const commentCount = createMemo(() => {
|
||||
if (mode() === "shell") return 0
|
||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||
})
|
||||
const blank = createMemo(() => {
|
||||
const text = prompt
|
||||
.current()
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
|
||||
})
|
||||
const stopping = createMemo(() => working() && blank())
|
||||
const placeholder = createMemo(() =>
|
||||
promptPlaceholder({
|
||||
mode: mode(),
|
||||
commentCount: commentCount(),
|
||||
example: mode() === "shell" ? "git status" : "",
|
||||
suggest: false,
|
||||
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||
}),
|
||||
)
|
||||
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
|
||||
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
return prompt.context.items().flatMap((item) => {
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return []
|
||||
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
|
||||
const nextSelection =
|
||||
selection ??
|
||||
(item.selection
|
||||
? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
|
||||
: undefined)
|
||||
if (!nextSelection) return []
|
||||
return [
|
||||
{
|
||||
id: item.commentID ?? item.key,
|
||||
path: item.path,
|
||||
selection: { ...nextSelection },
|
||||
comment,
|
||||
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
|
||||
origin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
} satisfies PromptHistoryComment,
|
||||
]
|
||||
})
|
||||
}
|
||||
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
|
||||
comments.replace(
|
||||
items.map((item) => ({
|
||||
id: item.id,
|
||||
file: item.path,
|
||||
selection: { ...item.selection },
|
||||
comment: item.comment,
|
||||
time: item.time,
|
||||
})),
|
||||
)
|
||||
prompt.context.replaceComments(
|
||||
items.map((item) => ({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: selectionFromLines(item.selection),
|
||||
comment: item.comment,
|
||||
commentID: item.id,
|
||||
commentOrigin: item.origin,
|
||||
preview: item.preview,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const accepting = createMemo(() => {
|
||||
const id = props.controls.session.id
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
||||
return permission.isAutoAccepting(id, sdk().directory)
|
||||
})
|
||||
const submission = createPromptSubmit({
|
||||
prompt,
|
||||
info,
|
||||
imageAttachments: attachments,
|
||||
commentCount,
|
||||
autoAccept: accepting,
|
||||
mode,
|
||||
working,
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
promptLength,
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
resetHistoryNavigation: () => controller.resetHistory(),
|
||||
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||
setPopover: (popover) => {
|
||||
if (!popover) controller.dispatch({ type: "popover.close" })
|
||||
},
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
model: props.controls.model.selection,
|
||||
})
|
||||
|
||||
const referenceDescription = (reference: ReferenceInfo) =>
|
||||
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||
const references = createMemo(() =>
|
||||
sync()
|
||||
.data.reference.filter((reference) => !reference.hidden)
|
||||
.map((reference) => ({
|
||||
id: `reference:${reference.name}`,
|
||||
kind: "reference" as const,
|
||||
label: `@${reference.name}`,
|
||||
path: reference.path,
|
||||
description: reference.description ?? referenceDescription(reference),
|
||||
mention: {
|
||||
type: "file" as const,
|
||||
path: reference.path,
|
||||
content: `@${reference.name}`,
|
||||
start: 0,
|
||||
end: 0,
|
||||
mime: "application/x-directory",
|
||||
filename: reference.name,
|
||||
},
|
||||
})),
|
||||
)
|
||||
const resources = createMemo(() =>
|
||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||
id: `resource:${resource.client}:${resource.uri}`,
|
||||
kind: "resource" as const,
|
||||
label: `@${resource.name}`,
|
||||
path: resource.uri,
|
||||
description: resource.description,
|
||||
mention: {
|
||||
type: "file" as const,
|
||||
path: resource.uri,
|
||||
content: `@${resource.name}`,
|
||||
start: 0,
|
||||
end: 0,
|
||||
mime: resource.mimeType ?? "text/plain",
|
||||
filename: resource.name,
|
||||
url: resource.uri,
|
||||
source: {
|
||||
type: "resource" as const,
|
||||
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
||||
clientName: resource.client,
|
||||
uri: resource.uri,
|
||||
},
|
||||
},
|
||||
resource,
|
||||
})),
|
||||
)
|
||||
const context = createMemo<PromptInputV2Suggestion[]>(() => [
|
||||
...references(),
|
||||
...props.controls.agents.available
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.map((agent) => ({
|
||||
id: `agent:${agent.name}`,
|
||||
kind: "agent" as const,
|
||||
label: `@${agent.name}`,
|
||||
mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
|
||||
})),
|
||||
...resources(),
|
||||
...recent().map((path) => ({
|
||||
id: `file:${path}`,
|
||||
kind: "file" as const,
|
||||
label: path,
|
||||
path,
|
||||
recent: true,
|
||||
mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
|
||||
})),
|
||||
])
|
||||
const slashCommands = createMemo(() => [
|
||||
...sync().data.command.map((item) => ({
|
||||
id: `custom.${item.name}`,
|
||||
trigger: item.name,
|
||||
title: item.name,
|
||||
description: item.description,
|
||||
type: "custom" as const,
|
||||
})),
|
||||
...command.options
|
||||
.filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
trigger: item.slash!,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
type: "builtin" as const,
|
||||
})),
|
||||
])
|
||||
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
|
||||
slashCommands().map((item) => ({
|
||||
id: item.id,
|
||||
kind: "command",
|
||||
label: `/${item.trigger}`,
|
||||
trigger: item.trigger,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
keybind: command.keybindParts(item.id),
|
||||
})),
|
||||
)
|
||||
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
||||
const controller = createPromptInputV2Controller({
|
||||
store: () => prompt.capture().store,
|
||||
state: interaction,
|
||||
identity: () => prompt.capture(),
|
||||
history: {
|
||||
entries: (mode) =>
|
||||
history.entries(mode).map((value) => {
|
||||
const entry = normalizePromptHistoryEntry(value)
|
||||
return { prompt: entry.prompt, metadata: entry.comments }
|
||||
}),
|
||||
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
|
||||
capture: historyComments,
|
||||
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
|
||||
},
|
||||
commands,
|
||||
context,
|
||||
searchContextFiles: async (query) =>
|
||||
(await files.searchFilesAndDirectories(query)).map((path) => ({
|
||||
id: `file:${path}`,
|
||||
kind: "file",
|
||||
label: path,
|
||||
path,
|
||||
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
|
||||
})),
|
||||
onContextRemove(item) {
|
||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, props, sync, layout, files, comments)
|
||||
},
|
||||
onEditor(element) {
|
||||
editor = element as HTMLDivElement
|
||||
props.ref?.(editor)
|
||||
},
|
||||
onSuggestionSelect(item) {
|
||||
if (item.kind !== "command") return
|
||||
const selected = slashCommands().find((entry) => entry.id === item.id)
|
||||
if (!selected || selected.type === "custom") return
|
||||
return () => command.trigger(selected.id, "slash")
|
||||
},
|
||||
attachments: {
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk().directory,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
warn: () =>
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
}),
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
getPathForFile: platform.getPathForFile,
|
||||
},
|
||||
view: {
|
||||
placeholder: designPlaceholder,
|
||||
agent:
|
||||
props.controls.agents.visible && props.controls.agents.options.length > 0
|
||||
? {
|
||||
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
||||
current: () => props.controls.agents.current,
|
||||
onSelect: props.controls.agents.select,
|
||||
}
|
||||
: undefined,
|
||||
variant: {
|
||||
options: () => variants().map((value) => ({ id: value, label: value })),
|
||||
current: () => props.controls.model.selection.variant.current() ?? "default",
|
||||
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
|
||||
},
|
||||
submit: {
|
||||
stopping,
|
||||
working,
|
||||
onSubmit: () => void submission.handleSubmit(new Event("submit")),
|
||||
onStop: () => void submission.abort(),
|
||||
},
|
||||
},
|
||||
})
|
||||
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
||||
return controller as PromptInputV2ComposerController
|
||||
}
|
||||
|
||||
function PromptInputV2ModelControl(props: {
|
||||
loading: boolean
|
||||
paid: boolean
|
||||
title: string
|
||||
keybind: string[]
|
||||
model: PromptInputV2ComposerController["model"]["selection"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
onClose: () => void
|
||||
onUnpaidClick: () => void
|
||||
}) {
|
||||
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
|
||||
const content = () => (
|
||||
<>
|
||||
<Show when={props.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate leading-4">{props.modelName}</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
<Icon name="chevron-down" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<Show when={!props.loading}>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.title}
|
||||
<KeybindV2 keys={props.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.paid}
|
||||
fallback={
|
||||
<ButtonV2
|
||||
data-action="prompt-model"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||
style={{ height: "28px" }}
|
||||
onClick={props.onUnpaidClick}
|
||||
>
|
||||
{content()}
|
||||
</ButtonV2>
|
||||
}
|
||||
>
|
||||
<ModelSelectorPopoverV2
|
||||
model={props.model}
|
||||
triggerAs={ButtonV2}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "normal",
|
||||
style: { height: "28px" },
|
||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||
classList: { "animate-in fade-in": shouldAnimate() },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
{content()}
|
||||
</ModelSelectorPopoverV2>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function openComment(
|
||||
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||
props: PromptInputV2ControllerProps,
|
||||
sync: ReturnType<typeof useSync>,
|
||||
layout: ReturnType<typeof useLayout>,
|
||||
files: ReturnType<typeof useFile>,
|
||||
comments: ReturnType<typeof useComments>,
|
||||
) {
|
||||
if (!item.commentID) return
|
||||
const focus = { file: item.path, id: item.commentID }
|
||||
comments.setActive(focus)
|
||||
const queueFocus = (attempts = 6) => {
|
||||
requestAnimationFrame(() => {
|
||||
comments.setFocus({ ...focus })
|
||||
if (attempts <= 0) return
|
||||
requestAnimationFrame(() => {
|
||||
const current = comments.focus()
|
||||
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
|
||||
const review =
|
||||
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
if (review) {
|
||||
layout.fileTree.setTab("changes")
|
||||
props.controls.session.tabs.setActive("review")
|
||||
queueFocus()
|
||||
return
|
||||
}
|
||||
layout.fileTree.setTab("all")
|
||||
const tab = files.tab(item.path)
|
||||
void props.controls.session.tabs.open(tab)
|
||||
props.controls.session.tabs.setActive(tab)
|
||||
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
|
||||
}
|
||||
|
|
@ -30,8 +30,16 @@ function PromptInputExample() {
|
|||
activeTab: undefined as string | undefined,
|
||||
reviewOpen: false,
|
||||
})
|
||||
const storyModel = {
|
||||
id: "claude-3-7-sonnet",
|
||||
name: "Claude 3.7 Sonnet",
|
||||
provider: { id: "anthropic", name: "Anthropic" },
|
||||
}
|
||||
const model = {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
current: () => storyModel,
|
||||
list: () => [storyModel],
|
||||
visible: () => true,
|
||||
set: () => {},
|
||||
variant: {
|
||||
list: () => ["fast", "thinking"],
|
||||
current: () => controls.variant,
|
||||
|
|
@ -67,7 +75,6 @@ function PromptInputExample() {
|
|||
open: () => setControls("reviewOpen", true),
|
||||
},
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
const addReviewComment = () => {
|
||||
const comment = controls.comments + 1
|
||||
|
|
@ -146,7 +153,6 @@ function PromptInputWithOpenDock() {
|
|||
},
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
const state = {
|
||||
blocked: () => false,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
|
|||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
export type PromptAttachmentsInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
isDialogActive: () => boolean
|
||||
|
|
|
|||
57
packages/app/src/components/prompt-input/contracts.ts
Normal file
57
packages/app/src/components/prompt-input/contracts.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { useLocal } from "@/context/local"
|
||||
import type { Prompt, usePrompt } from "@/context/prompt"
|
||||
import type { PromptInputHistory } from "./history-store"
|
||||
import type { FollowupDraft } from "./submit"
|
||||
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
export type PromptInputSubmission = {
|
||||
abort: () => Promise<void> | void
|
||||
handleSubmit: (event: Event) => Promise<void> | 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<typeof useLocal>["model"]
|
||||
paid: boolean
|
||||
loading: boolean
|
||||
}
|
||||
session: {
|
||||
id?: string
|
||||
tabs: {
|
||||
active: () => string | undefined
|
||||
all: () => string[]
|
||||
open: (tab: string) => void | Promise<void>
|
||||
setActive: (tab: string) => void
|
||||
}
|
||||
reviewPanel: {
|
||||
opened: () => boolean
|
||||
open: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PromptInputProps {
|
||||
class?: string
|
||||
state?: PromptInputState
|
||||
history?: PromptInputHistory
|
||||
submission?: PromptInputSubmission
|
||||
controls: PromptInputControls
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
newSessionWorktree?: string
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||
onEditLoaded?: () => void
|
||||
shouldQueue?: () => boolean
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
}
|
||||
51
packages/app/src/components/prompt-input/history-store.ts
Normal file
51
packages/app/src/components/prompt-input/history-store.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import {
|
||||
prependHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./history"
|
||||
|
||||
export type PromptInputHistory = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
|
||||
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||
|
||||
function createPromptInputHistoryStore(
|
||||
normal: Store<PromptHistoryState>,
|
||||
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||
shell: Store<PromptHistoryState>,
|
||||
setShell: SetStoreFunction<PromptHistoryState>,
|
||||
): 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createPromptInputHistory(): PromptInputHistory {
|
||||
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
|
||||
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
}
|
||||
|
||||
export function createPersistedPromptInputHistory() {
|
||||
const [normal, setNormal] = persisted(
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const [shell, setShell] = persisted(
|
||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
}
|
||||
|
|
@ -13,3 +13,8 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
|
|||
if (!input.suggest) return input.t("prompt.placeholder.simple")
|
||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||
}
|
||||
|
||||
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
|
||||
if (mode === "shell") return placeholder
|
||||
return "Ask anything, / for commands, @ for context..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
|
@ -31,7 +32,13 @@ let permissionServer = "server-a"
|
|||
let createSessionGate: Promise<void> | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
prompt: promptValue,
|
||||
cursor: 0,
|
||||
context: { items: [] },
|
||||
})
|
||||
const prompt = {
|
||||
store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
|
||||
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export type PromptInputTransientState = {
|
|||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
variantOpen: boolean
|
||||
}
|
||||
|
||||
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||
|
|
@ -25,7 +24,6 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
|
|||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +38,6 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
|
|||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
|
||||
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function createPromptProjectController(input: {
|
|||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
|
||||
const selected = () => {
|
||||
const current = () => {
|
||||
const key = pathKey(input.controls().directory)
|
||||
return input
|
||||
.controls()
|
||||
|
|
@ -65,6 +65,7 @@ export function createPromptProjectController(input: {
|
|||
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
|
||||
)
|
||||
}
|
||||
const selected = () => current() ?? input.controls().available[0]
|
||||
const projects = () => {
|
||||
const search = store.search.trim().toLowerCase()
|
||||
if (!search) return input.controls().available
|
||||
|
|
@ -100,8 +101,8 @@ export function createPromptProjectController(input: {
|
|||
}
|
||||
const select = (project: PromptProject) => {
|
||||
if (
|
||||
pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
|
||||
project.server?.key !== selected()?.server?.key
|
||||
pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") ||
|
||||
project.server?.key !== current()?.server?.key
|
||||
) {
|
||||
input.controls().select(project.worktree, project.server?.key)
|
||||
}
|
||||
|
|
@ -124,6 +125,7 @@ export function createPromptProjectController(input: {
|
|||
|
||||
return {
|
||||
selected,
|
||||
empty: () => input.controls().available.length === 0,
|
||||
projects,
|
||||
servers,
|
||||
projectKey,
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
|||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
type PromptStore = {
|
||||
export type PromptStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
model?: PromptModel
|
||||
|
|
@ -189,6 +189,7 @@ function promptStore(initial?: InitialPrompt): PromptStore {
|
|||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||
const actions = createPromptActions(setStore)
|
||||
const value = {
|
||||
store: [() => store, setStore] as [Accessor<PromptStore>, SetStoreFunction<PromptStore>],
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { ServerConnection } from "./server"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
|
|
@ -36,6 +36,7 @@ export type {
|
|||
ImageAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
PromptStore,
|
||||
PromptScope,
|
||||
PromptSession,
|
||||
TextPart,
|
||||
|
|
@ -132,18 +133,29 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||
createResource(
|
||||
async () => {
|
||||
const value = cb()
|
||||
await session().ready.promise
|
||||
return value
|
||||
},
|
||||
cb,
|
||||
{ initialValue: cb() },
|
||||
)[0]
|
||||
|
||||
return {
|
||||
ready,
|
||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||
current: () => session().current(),
|
||||
cursor: () => session().cursor(),
|
||||
dirty: () => session().dirty(),
|
||||
current: withSuspense(() => session().current()),
|
||||
cursor: withSuspense(() => session().cursor()),
|
||||
dirty: withSuspense(() => session().dirty()),
|
||||
model: {
|
||||
current: () => session().model.current(),
|
||||
current: withSuspense(() => session().model.current()),
|
||||
set: (model: PromptModel | undefined) => session().model.set(model),
|
||||
},
|
||||
context: {
|
||||
items: () => session().context.items(),
|
||||
items: withSuspense(() => session().context.items()),
|
||||
add: (item: ContextItem) => session().context.add(item),
|
||||
remove: (key: string) => session().context.remove(key),
|
||||
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
|
|
@ -68,8 +68,6 @@ export default function NewSessionPage() {
|
|||
|
||||
useComposerCommands({ model })
|
||||
|
||||
let inputRef: HTMLDivElement | undefined
|
||||
|
||||
const inputController = createPromptInputController({
|
||||
sessionKey: route.sessionKey,
|
||||
sessionID: () => route.params.id,
|
||||
|
|
@ -77,29 +75,6 @@ export default function NewSessionPage() {
|
|||
model,
|
||||
})
|
||||
const projectControls = createPromptProjectControls()
|
||||
const projectController = createPromptProjectController({
|
||||
controls: projectControls,
|
||||
onDone: () => inputRef?.focus(),
|
||||
})
|
||||
|
||||
command.register("new-session", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
||||
void dialog.show(() => <DialogSelectFile />)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: () => inputRef?.focus(),
|
||||
},
|
||||
])
|
||||
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const rightMount = useTitlebarRightMount()
|
||||
|
|
@ -119,6 +94,39 @@ export default function NewSessionPage() {
|
|||
if (worktree === "main" || worktree === "create") return localBranch()
|
||||
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||
})
|
||||
const promptInputV2Controller = usePromptInputV2Controller({
|
||||
get controls() {
|
||||
return inputController()
|
||||
},
|
||||
get newSessionWorktree() {
|
||||
return newSessionWorktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: () => setStore("worktree", undefined),
|
||||
onSubmit: () => comments.clear(),
|
||||
})
|
||||
const projectController = createPromptProjectController({
|
||||
controls: projectControls,
|
||||
onDone: promptInputV2Controller.restoreFocus,
|
||||
})
|
||||
|
||||
command.register("new-session", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
||||
void dialog.show(() => <DialogSelectFile />)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: () => promptInputV2Controller.restoreFocus(),
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
|
|
@ -132,16 +140,18 @@ export default function NewSessionPage() {
|
|||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
requestAnimationFrame(() => inputRef?.focus())
|
||||
promptInputV2Controller.restoreFocus()
|
||||
})
|
||||
|
||||
const ready = Promise.resolve()
|
||||
const [promptReady] = createResource(
|
||||
const [suspendUntilPromptReady] = createResource(
|
||||
() => prompt.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<Show when={rightMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
|
|
@ -158,63 +168,44 @@ export default function NewSessionPage() {
|
|||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||
<NewSessionDesignView>
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Show
|
||||
when={prompt.ready() || promptReady()}
|
||||
fallback={
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak pointer-events-none">
|
||||
{language.t("prompt.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar(), "gap-3": !showWorkspaceBar() }}>
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
variant="new-session"
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar(), "gap-3": !showWorkspaceBar() }}>
|
||||
<PromptInputV2Composer controller={promptInputV2Controller} />
|
||||
<Show when={projectController.empty()}>
|
||||
<PromptProjectAddButton controller={projectController} />
|
||||
</Show>
|
||||
<Show when={projectController.selected()}>
|
||||
<div
|
||||
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
||||
classList={{
|
||||
"flex-col justify-center sm:flex-row": showWorkspaceBar(),
|
||||
"justify-start": !showWorkspaceBar(),
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
|
||||
onSubmit={() => comments.clear()}
|
||||
toolbar={
|
||||
<Show when={!projectController.selected()}>
|
||||
<PromptProjectAddButton controller={projectController} />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<Show when={projectController.selected()}>
|
||||
<div
|
||||
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
||||
classList={{
|
||||
"flex-col justify-center sm:flex-row": showWorkspaceBar(),
|
||||
"justify-start": !showWorkspaceBar(),
|
||||
}}
|
||||
>
|
||||
<PromptProjectSelector
|
||||
controller={projectController}
|
||||
placement={showWorkspaceBar() ? "bottom" : "bottom-start"}
|
||||
>
|
||||
<PromptProjectSelector
|
||||
controller={projectController}
|
||||
placement={showWorkspaceBar() ? "bottom" : "bottom-start"}
|
||||
/>
|
||||
<Show when={showWorkspaceBar()}>
|
||||
<PromptWorkspaceSelector
|
||||
value={newSessionWorktree()}
|
||||
projectRoot={projectRoot()}
|
||||
workspaces={sync().project?.sandboxes ?? []}
|
||||
branch={selectedBranch()}
|
||||
onChange={(value) =>
|
||||
setStore(
|
||||
"worktree",
|
||||
value === "main" && sync().project?.worktree !== sdk().directory
|
||||
? sync().project?.worktree
|
||||
: value,
|
||||
)
|
||||
}
|
||||
onDone={promptInputV2Controller.restoreFocus}
|
||||
/>
|
||||
<Show when={showWorkspaceBar()}>
|
||||
<PromptWorkspaceSelector
|
||||
value={newSessionWorktree()}
|
||||
projectRoot={projectRoot()}
|
||||
workspaces={sync().project?.sandboxes ?? []}
|
||||
branch={selectedBranch()}
|
||||
onChange={(value) =>
|
||||
setStore(
|
||||
"worktree",
|
||||
value === "main" && sync().project?.worktree !== sdk().directory
|
||||
? sync().project?.worktree
|
||||
: value,
|
||||
)
|
||||
}
|
||||
onDone={() => inputRef?.focus()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
{/*</Show>*/}
|
||||
</div>
|
||||
</NewSessionDesignView>
|
||||
<ProviderTip
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import { useSync } from "@/context/sync"
|
|||
import { useTabs } from "@/context/tabs"
|
||||
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||
import { promptLength } from "@/components/prompt-input/history"
|
||||
|
|
@ -2045,83 +2046,6 @@ export default function Page() {
|
|||
|
||||
useUsageExceededDialogs()
|
||||
|
||||
const composerRegion = () => {
|
||||
const controller = createSessionComposerRegionController({
|
||||
state: composer,
|
||||
sessionKey,
|
||||
sessionID: () => params.id,
|
||||
prompt,
|
||||
ready: () => !store.deferRender && messagesReady(),
|
||||
centered,
|
||||
todo: {
|
||||
collapsed: () => view().todoCollapsed.get(),
|
||||
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
|
||||
},
|
||||
followup: () =>
|
||||
params.id && !isChildSession()
|
||||
? {
|
||||
items: followupDock(),
|
||||
sending: sendingFollowup(),
|
||||
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
|
||||
onEdit: editFollowup,
|
||||
}
|
||||
: undefined,
|
||||
revert: () =>
|
||||
rolled().length > 0
|
||||
? {
|
||||
items: rolled(),
|
||||
restoring: restoring(),
|
||||
disabled: reverting(),
|
||||
onRestore: restore,
|
||||
}
|
||||
: undefined,
|
||||
onResponseSubmit: resumeScroll,
|
||||
openParent: () => {
|
||||
const id = info()?.parentID
|
||||
if (!id) return
|
||||
navigate(
|
||||
params.serverKey
|
||||
? sessionHref(requireServerKey(params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
},
|
||||
setPromptRef: (el) => {
|
||||
inputRef = el
|
||||
},
|
||||
setDockRef: (el) => {
|
||||
promptDock = el
|
||||
},
|
||||
})
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={controller}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||
onSubmit={() => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
}}
|
||||
edit={editingFollowup()}
|
||||
onEditLoaded={clearFollowupEdit}
|
||||
shouldQueue={queueEnabled}
|
||||
onQueue={queueFollowup}
|
||||
onAbort={() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
setFollowup("paused", id, true)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const mobileTabs = (compact = false, bottom = false) => (
|
||||
<Tabs value={store.mobileTab} class="h-auto">
|
||||
<Tabs.List
|
||||
|
|
@ -2236,7 +2160,122 @@ export default function Page() {
|
|||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>
|
||||
{(_) => {
|
||||
const controller = createSessionComposerRegionController({
|
||||
state: composer,
|
||||
sessionKey,
|
||||
sessionID: () => params.id,
|
||||
prompt,
|
||||
ready: () => !store.deferRender && messagesReady(),
|
||||
centered,
|
||||
todo: {
|
||||
collapsed: () => view().todoCollapsed.get(),
|
||||
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
|
||||
},
|
||||
followup: () =>
|
||||
params.id && !isChildSession()
|
||||
? {
|
||||
items: followupDock(),
|
||||
sending: sendingFollowup(),
|
||||
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
|
||||
onEdit: editFollowup,
|
||||
}
|
||||
: undefined,
|
||||
revert: () =>
|
||||
rolled().length > 0
|
||||
? {
|
||||
items: rolled(),
|
||||
restoring: restoring(),
|
||||
disabled: reverting(),
|
||||
onRestore: restore,
|
||||
}
|
||||
: undefined,
|
||||
onResponseSubmit: resumeScroll,
|
||||
openParent: () => {
|
||||
const id = info()?.parentID
|
||||
if (!id) return
|
||||
navigate(
|
||||
params.serverKey
|
||||
? sessionHref(requireServerKey(params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
},
|
||||
setPromptRef: (el) => {
|
||||
inputRef = el
|
||||
},
|
||||
setDockRef: (el) => {
|
||||
promptDock = el
|
||||
},
|
||||
})
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={controller}
|
||||
promptInput={
|
||||
<Show
|
||||
when={newSessionDesign()}
|
||||
fallback={
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||
onSubmit={() => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
}}
|
||||
edit={editingFollowup()}
|
||||
onEditLoaded={clearFollowupEdit}
|
||||
shouldQueue={queueEnabled}
|
||||
onQueue={queueFollowup}
|
||||
onAbort={() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
setFollowup("paused", id, true)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(_) => {
|
||||
const controller = usePromptInputV2Controller({
|
||||
get controls() {
|
||||
return inputController()
|
||||
},
|
||||
ref: (el) => {
|
||||
inputRef = el
|
||||
},
|
||||
get newSessionWorktree() {
|
||||
return newSessionWorktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
|
||||
onSubmit: () => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
},
|
||||
shouldQueue: queueEnabled,
|
||||
onQueue: queueFollowup,
|
||||
onAbort: () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
setFollowup("paused", id, true)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<PromptInputV2Composer
|
||||
controller={controller}
|
||||
edit={editingFollowup()}
|
||||
onEditLoaded={clearFollowupEdit}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
|||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import type { PromptInputControls } from "@/components/prompt-input"
|
||||
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
||||
import type { PromptProjectControls } from "@/components/prompt-project-selector"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useGlobal } from "@/context/global"
|
||||
|
|
@ -12,7 +12,6 @@ import type { QueryOptionsApi } from "@/context/server-sync"
|
|||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { serverName, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
|
@ -27,7 +26,6 @@ export function createPromptInputController(input: {
|
|||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const providers = useProviders()
|
||||
const settings = useSettings()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const view = layout.view(input.sessionKey)
|
||||
|
|
@ -54,7 +52,6 @@ export function createPromptInputController(input: {
|
|||
tabs: layout.tabs(input.sessionKey),
|
||||
reviewPanel: view.reviewPanel,
|
||||
},
|
||||
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ const controls = {
|
|||
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
|
||||
const css = `
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue