diff options
| author | Adam <[email protected]> | 2026-02-06 10:02:31 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-06 10:02:31 -0600 |
| commit | 2c58dd6203df7806f57ef6b29672091cb764e871 (patch) | |
| tree | 10fca96d3098465b497f78e29de8d0a585c4dac3 /packages/app/src/components | |
| parent | a4bc883595df9ea0f752079519081bc602408553 (diff) | |
| download | opencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip | |
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages/app/src/components')
16 files changed, 858 insertions, 685 deletions
diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 28a947f3b..53773ed9e 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -124,16 +124,16 @@ export function DialogCustomProvider(props: Props) { const key = apiKey && !env ? apiKey : undefined const idError = !providerID - ? "Provider ID is required" + ? language.t("provider.custom.error.providerID.required") : !PROVIDER_ID.test(providerID) - ? "Use lowercase letters, numbers, hyphens, or underscores" + ? language.t("provider.custom.error.providerID.format") : undefined - const nameError = !name ? "Display name is required" : undefined + const nameError = !name ? language.t("provider.custom.error.name.required") : undefined const urlError = !baseURL - ? "Base URL is required" + ? language.t("provider.custom.error.baseURL.required") : !/^https?:\/\//.test(baseURL) - ? "Must start with http:// or https://" + ? language.t("provider.custom.error.baseURL.format") : undefined const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID) @@ -141,21 +141,21 @@ export function DialogCustomProvider(props: Props) { const existsError = idError ? undefined : existingProvider && !disabled - ? "That provider ID already exists" + ? language.t("provider.custom.error.providerID.exists") : undefined const seenModels = new Set<string>() const modelErrors = form.models.map((m) => { const id = m.id.trim() const modelIdError = !id - ? "Required" + ? language.t("provider.custom.error.required") : seenModels.has(id) - ? "Duplicate" + ? language.t("provider.custom.error.duplicate") : (() => { seenModels.add(id) return undefined })() - const modelNameError = !m.name.trim() ? "Required" : undefined + const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined return { id: modelIdError, name: modelNameError } }) const modelsValid = modelErrors.every((m) => !m.id && !m.name) @@ -168,14 +168,14 @@ export function DialogCustomProvider(props: Props) { if (!key && !value) return {} const keyError = !key - ? "Required" + ? language.t("provider.custom.error.required") : seenHeaders.has(key.toLowerCase()) - ? "Duplicate" + ? language.t("provider.custom.error.duplicate") : (() => { seenHeaders.add(key.toLowerCase()) return undefined })() - const valueError = !value ? "Required" : undefined + const valueError = !value ? language.t("provider.custom.error.required") : undefined return { key: keyError, value: valueError } }) const headersValid = headerErrors.every((h) => !h.key && !h.value) @@ -278,64 +278,64 @@ export function DialogCustomProvider(props: Props) { <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]"> <div class="px-2.5 flex gap-4 items-center"> <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" /> - <div class="text-16-medium text-text-strong">Custom provider</div> + <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div> </div> <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6"> <p class="text-14-regular text-text-base"> - Configure an OpenAI-compatible provider. See the{" "} + {language.t("provider.custom.description.prefix")} <Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}> - provider config docs + {language.t("provider.custom.description.link")} </Link> - . + {language.t("provider.custom.description.suffix")} </p> <div class="flex flex-col gap-4"> <TextField autofocus - label="Provider ID" - placeholder="myprovider" - description="Lowercase letters, numbers, hyphens, or underscores" + label={language.t("provider.custom.field.providerID.label")} + placeholder={language.t("provider.custom.field.providerID.placeholder")} + description={language.t("provider.custom.field.providerID.description")} value={form.providerID} onChange={setForm.bind(null, "providerID")} validationState={errors.providerID ? "invalid" : undefined} error={errors.providerID} /> <TextField - label="Display name" - placeholder="My AI Provider" + label={language.t("provider.custom.field.name.label")} + placeholder={language.t("provider.custom.field.name.placeholder")} value={form.name} onChange={setForm.bind(null, "name")} validationState={errors.name ? "invalid" : undefined} error={errors.name} /> <TextField - label="Base URL" - placeholder="https://api.myprovider.com/v1" + label={language.t("provider.custom.field.baseURL.label")} + placeholder={language.t("provider.custom.field.baseURL.placeholder")} value={form.baseURL} onChange={setForm.bind(null, "baseURL")} validationState={errors.baseURL ? "invalid" : undefined} error={errors.baseURL} /> <TextField - label="API key" - placeholder="API key" - description="Optional. Leave empty if you manage auth via headers." + label={language.t("provider.custom.field.apiKey.label")} + placeholder={language.t("provider.custom.field.apiKey.placeholder")} + description={language.t("provider.custom.field.apiKey.description")} value={form.apiKey} onChange={setForm.bind(null, "apiKey")} /> </div> <div class="flex flex-col gap-3"> - <label class="text-12-medium text-text-weak">Models</label> + <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label> <For each={form.models}> {(m, i) => ( <div class="flex gap-2 items-start"> <div class="flex-1"> <TextField - label="ID" + label={language.t("provider.custom.models.id.label")} hideLabel - placeholder="model-id" + placeholder={language.t("provider.custom.models.id.placeholder")} value={m.id} onChange={(v) => setForm("models", i(), "id", v)} validationState={errors.models[i()]?.id ? "invalid" : undefined} @@ -344,9 +344,9 @@ export function DialogCustomProvider(props: Props) { </div> <div class="flex-1"> <TextField - label="Name" + label={language.t("provider.custom.models.name.label")} hideLabel - placeholder="Display Name" + placeholder={language.t("provider.custom.models.name.placeholder")} value={m.name} onChange={(v) => setForm("models", i(), "name", v)} validationState={errors.models[i()]?.name ? "invalid" : undefined} @@ -360,26 +360,26 @@ export function DialogCustomProvider(props: Props) { class="mt-1.5" onClick={() => removeModel(i())} disabled={form.models.length <= 1} - aria-label="Remove model" + aria-label={language.t("provider.custom.models.remove")} /> </div> )} </For> <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start"> - Add model + {language.t("provider.custom.models.add")} </Button> </div> <div class="flex flex-col gap-3"> - <label class="text-12-medium text-text-weak">Headers (optional)</label> + <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label> <For each={form.headers}> {(h, i) => ( <div class="flex gap-2 items-start"> <div class="flex-1"> <TextField - label="Header" + label={language.t("provider.custom.headers.key.label")} hideLabel - placeholder="Header-Name" + placeholder={language.t("provider.custom.headers.key.placeholder")} value={h.key} onChange={(v) => setForm("headers", i(), "key", v)} validationState={errors.headers[i()]?.key ? "invalid" : undefined} @@ -388,9 +388,9 @@ export function DialogCustomProvider(props: Props) { </div> <div class="flex-1"> <TextField - label="Value" + label={language.t("provider.custom.headers.value.label")} hideLabel - placeholder="value" + placeholder={language.t("provider.custom.headers.value.placeholder")} value={h.value} onChange={(v) => setForm("headers", i(), "value", v)} validationState={errors.headers[i()]?.value ? "invalid" : undefined} @@ -404,18 +404,18 @@ export function DialogCustomProvider(props: Props) { class="mt-1.5" onClick={() => removeHeader(i())} disabled={form.headers.length <= 1} - aria-label="Remove header" + aria-label={language.t("provider.custom.headers.remove")} /> </div> )} </For> <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start"> - Add header + {language.t("provider.custom.headers.add")} </Button> </div> <Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}> - {form.saving ? "Saving..." : language.t("common.submit")} + {form.saving ? language.t("common.saving") : language.t("common.submit")} </Button> </form> </div> diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 3d0d6c793..26021f06a 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -87,11 +87,13 @@ const ModelList: Component<{ ) } -export function ModelSelectorPopover<T extends ValidComponent = "div">(props: { +type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref"> + +export function ModelSelectorPopover(props: { provider?: string children?: JSX.Element - triggerAs?: T - triggerProps?: ComponentProps<T> + triggerAs?: ValidComponent + triggerProps?: ModelSelectorTriggerProps }) { const [store, setStore] = createStore<{ open: boolean @@ -176,11 +178,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: { placement="top-start" gutter={8} > - <Kobalte.Trigger - ref={(el) => setStore("trigger", el)} - as={props.triggerAs ?? "div"} - {...(props.triggerProps as any)} - > + <Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}> {props.children} </Kobalte.Trigger> <Kobalte.Portal> diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 3d8f5b846..65b679f70 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,4 +1,4 @@ -import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js" +import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -6,17 +6,15 @@ import { List } from "@opencode-ai/ui/list" import { Button } from "@opencode-ai/ui/button" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" -import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { normalizeServerUrl, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { useNavigate } from "@solidjs/router" import { useLanguage } from "@/context/language" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Tooltip } from "@opencode-ai/ui/tooltip" import { useGlobalSDK } from "@/context/global-sdk" import { showToast } from "@opencode-ai/ui/toast" - -type ServerStatus = { healthy: boolean; version?: string } +import { ServerRow } from "@/components/server/server-row" +import { checkServerHealth, type ServerHealth } from "@/utils/server-health" interface AddRowProps { value: string @@ -40,19 +38,6 @@ interface EditRowProps { onBlur: () => void } -async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> { - const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) - const sdk = createOpencodeClient({ - baseUrl: url, - fetch: platform.fetch, - signal, - }) - return sdk.global - .health() - .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) - .catch(() => ({ healthy: false })) -} - function AddRow(props: AddRowProps) { return ( <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1"> @@ -131,7 +116,7 @@ export function DialogSelectServer() { const globalSDK = useGlobalSDK() const language = useLanguage() const [store, setStore] = createStore({ - status: {} as Record<string, ServerStatus | undefined>, + status: {} as Record<string, ServerHealth | undefined>, addServer: { url: "", adding: false, @@ -165,6 +150,7 @@ export function DialogSelectServer() { { initialValue: null }, ) const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) + const fetcher = platform.fetch ?? globalThis.fetch const looksComplete = (value: string) => { const normalized = normalizeServerUrl(value) @@ -180,7 +166,7 @@ export function DialogSelectServer() { if (!looksComplete(value)) return const normalized = normalizeServerUrl(value) if (!normalized) return - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStatus(result.healthy) } @@ -227,7 +213,7 @@ export function DialogSelectServer() { if (!list.length) return list const active = current() const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerStatus) => { + const rank = (value?: ServerHealth) => { if (value?.healthy === true) return 0 if (value?.healthy === false) return 2 return 1 @@ -242,10 +228,10 @@ export function DialogSelectServer() { }) async function refreshHealth() { - const results: Record<string, ServerStatus> = {} + const results: Record<string, ServerHealth> = {} await Promise.all( items().map(async (url) => { - results[url] = await checkHealth(url, platform) + results[url] = await checkServerHealth(url, fetcher) }), ) setStore("status", reconcile(results)) @@ -300,7 +286,7 @@ export function DialogSelectServer() { setStore("addServer", { adding: true, error: "" }) - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStore("addServer", { adding: false }) if (!result.healthy) { @@ -327,7 +313,7 @@ export function DialogSelectServer() { setStore("editServer", { busy: true, error: "" }) - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStore("editServer", { busy: false }) if (!result.healthy) { @@ -413,35 +399,6 @@ export function DialogSelectServer() { } > {(i) => { - const [truncated, setTruncated] = createSignal(false) - let nameRef: HTMLSpanElement | undefined - let versionRef: HTMLSpanElement | undefined - - const check = () => { - const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false - const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false - setTruncated(nameTruncated || versionTruncated) - } - - createEffect(() => { - check() - window.addEventListener("resize", check) - onCleanup(() => window.removeEventListener("resize", check)) - }) - - const tooltipValue = () => { - const name = serverDisplayName(i) - const version = store.status[i]?.version - return ( - <span class="flex items-center gap-2"> - <span>{name}</span> - <Show when={version}> - <span class="text-text-invert-base">{version}</span> - </Show> - </span> - ) - } - return ( <div class="flex items-center gap-3 min-w-0 flex-1 group/item"> <Show @@ -459,34 +416,19 @@ export function DialogSelectServer() { /> } > - <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}> - <div - class="flex items-center gap-3 px-4 min-w-0 flex-1" - classList={{ "opacity-50": store.status[i]?.healthy === false }} - > - <div - classList={{ - "size-1.5 rounded-full shrink-0": true, - "bg-icon-success-base": store.status[i]?.healthy === true, - "bg-icon-critical-base": store.status[i]?.healthy === false, - "bg-border-weak-base": store.status[i] === undefined, - }} - /> - <span ref={nameRef} class="truncate"> - {serverDisplayName(i)} - </span> - <Show when={store.status[i]?.version}> - <span ref={versionRef} class="text-text-weak text-14-regular truncate"> - {store.status[i]?.version} - </span> - </Show> + <ServerRow + url={i} + status={store.status[i]} + dimmed={store.status[i]?.healthy === false} + class="flex items-center gap-3 px-4 min-w-0 flex-1" + badge={ <Show when={defaultUrl() === i}> <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs"> {language.t("dialog.server.status.default")} </span> </Show> - </div> - </Tooltip> + } + /> </Show> <Show when={store.editServer.id !== i}> <div class="flex items-center justify-center gap-5 pl-4"> diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 3f0ba314e..46d7f93eb 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -19,7 +19,6 @@ import { useSDK } from "@/context/sdk" import { useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" -import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" @@ -27,9 +26,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" @@ -42,6 +39,12 @@ import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" import { createPromptSubmit } from "./prompt-input/submit" +import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" +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 { ImagePreview } from "@opencode-ai/ui/image-preview" interface PromptInputProps { class?: string @@ -79,16 +82,6 @@ const EXAMPLES = [ "prompt.example.25", ] as const -interface SlashCommand { - id: string - trigger: string - title: string - description?: string - keybind?: string - type: "builtin" | "custom" - source?: "command" | "mcp" | "skill" -} - export const PromptInput: Component<PromptInputProps> = (props) => { const sdk = useSDK() const sync = useSync() @@ -203,8 +196,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { }, ) const working = createMemo(() => status()?.type !== "idle") - const imageAttachments = createMemo( - () => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[], + const imageAttachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) const [store, setStore] = createStore<{ @@ -224,6 +217,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => { mode: "normal", applyingHistory: false, }) + const placeholder = createMemo(() => + promptPlaceholder({ + mode: store.mode, + commentCount: commentCount(), + example: language.t(EXAMPLES[store.placeholder]), + t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never), + }), + ) const MAX_HISTORY = 100 const [history, setHistory] = persisted( @@ -296,10 +297,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (!isFocused()) setComposing(false) }) - type AtOption = - | { type: "agent"; name: string; display: string } - | { type: "file"; path: string; display: string; recent?: boolean } - const agentList = createMemo(() => sync.data.agent .filter((agent) => !agent.hidden && agent.mode !== "primary") @@ -509,7 +506,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { on( () => prompt.current(), (currentParts) => { - const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt + const inputParts = currentParts.filter((part) => part.type !== "image") if (mirror.input) { mirror.input = false @@ -928,110 +925,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => { return ( <div class="relative size-full _max-h-[320px] flex flex-col gap-3"> - <Show when={store.popover}> - <div - ref={(el) => { - if (store.popover === "slash") slashPopoverRef = el - }} - class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 rounded-md - border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" - onMouseDown={(e) => e.preventDefault()} - > - <Switch> - <Match when={store.popover === "at"}> - <Show - when={atFlat().length > 0} - fallback={<div class="text-text-weak px-2 py-1">{language.t("prompt.popover.emptyResults")}</div>} - > - <For each={atFlat().slice(0, 10)}> - {(item) => ( - <button - classList={{ - "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true, - "bg-surface-raised-base-hover": atActive() === atKey(item), - }} - onClick={() => handleAtSelect(item)} - onMouseEnter={() => setAtActive(atKey(item))} - > - <Show - when={item.type === "agent"} - fallback={ - <> - <FileIcon - node={{ path: (item as { type: "file"; path: string }).path, type: "file" }} - class="shrink-0 size-4" - /> - <div class="flex items-center text-14-regular min-w-0"> - <span class="text-text-weak whitespace-nowrap truncate min-w-0"> - {(() => { - const path = (item as { type: "file"; path: string }).path - return path.endsWith("/") ? path : getDirectory(path) - })()} - </span> - <Show when={!(item as { type: "file"; path: string }).path.endsWith("/")}> - <span class="text-text-strong whitespace-nowrap"> - {getFilename((item as { type: "file"; path: string }).path)} - </span> - </Show> - </div> - </> - } - > - <Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> - <span class="text-14-regular text-text-strong whitespace-nowrap"> - @{(item as { type: "agent"; name: string }).name} - </span> - </Show> - </button> - )} - </For> - </Show> - </Match> - <Match when={store.popover === "slash"}> - <Show - when={slashFlat().length > 0} - fallback={<div class="text-text-weak px-2 py-1">{language.t("prompt.popover.emptyCommands")}</div>} - > - <For each={slashFlat()}> - {(cmd) => ( - <button - data-slash-id={cmd.id} - classList={{ - "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true, - "bg-surface-raised-base-hover": slashActive() === cmd.id, - }} - onClick={() => handleSlashSelect(cmd)} - onMouseEnter={() => setSlashActive(cmd.id)} - > - <div class="flex items-center gap-2 min-w-0"> - <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span> - <Show when={cmd.description}> - <span class="text-14-regular text-text-weak truncate">{cmd.description}</span> - </Show> - </div> - <div class="flex items-center gap-2 shrink-0"> - <Show when={cmd.type === "custom" && cmd.source !== "command"}> - <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded"> - {cmd.source === "skill" - ? language.t("prompt.slash.badge.skill") - : cmd.source === "mcp" - ? language.t("prompt.slash.badge.mcp") - : language.t("prompt.slash.badge.custom")} - </span> - </Show> - <Show when={command.keybind(cmd.id)}> - <span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span> - </Show> - </div> - </button> - )} - </For> - </Show> - </Match> - </Switch> - </div> - </Show> + <PromptPopover + popover={store.popover} + setSlashPopoverRef={(el) => (slashPopoverRef = el)} + atFlat={atFlat()} + atActive={atActive() ?? undefined} + atKey={atKey} + setAtActive={setAtActive} + onAtSelect={handleAtSelect} + slashFlat={slashFlat()} + slashActive={slashActive() ?? undefined} + setSlashActive={setSlashActive} + onSlashSelect={handleSlashSelect} + commandKeybind={command.keybind} + t={(key) => language.t(key as Parameters<typeof language.t>[0])} + /> <form onSubmit={handleSubmit} classList={{ @@ -1042,124 +950,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => { [props.class ?? ""]: !!props.class, }} > - <Show when={store.dragging}> - <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none"> - <div class="flex flex-col items-center gap-2 text-text-weak"> - <Icon name="photo" class="size-8" /> - <span class="text-14-regular">{language.t("prompt.dropzone.label")}</span> - </div> - </div> - </Show> - <Show when={prompt.context.items().length > 0}> - <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar"> - <For each={prompt.context.items()}> - {(item) => { - const active = () => { - const a = comments.active() - return !!item.commentID && item.commentID === a?.id && item.path === a?.file - } - return ( - <Tooltip - value={ - <span class="flex max-w-[300px]"> - <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0"> - {getDirectory(item.path)} - </span> - <span class="shrink-0">{getFilename(item.path)}</span> - </span> - } - placement="top" - openDelay={2000} - > - <div - classList={{ - "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true, - "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !active(), - "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": - active(), - "bg-background-stronger": !active(), - }} - onClick={() => { - openComment(item) - }} - > - <div class="flex items-center gap-1.5"> - <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> - <div class="flex items-center text-11-regular min-w-0 font-medium"> - <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span> - <Show when={item.selection}> - {(sel) => ( - <span class="text-text-weak whitespace-nowrap shrink-0"> - {sel().startLine === sel().endLine - ? `:${sel().startLine}` - : `:${sel().startLine}-${sel().endLine}`} - </span> - )} - </Show> - </div> - <IconButton - type="button" - icon="close-small" - variant="ghost" - class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all" - onClick={(e) => { - e.stopPropagation() - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - aria-label={language.t("prompt.context.removeFile")} - /> - </div> - <Show when={item.comment}> - {(comment) => ( - <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div> - )} - </Show> - </div> - </Tooltip> - ) - }} - </For> - </div> - </Show> - <Show when={imageAttachments().length > 0}> - <div class="flex flex-wrap gap-2 px-3 pt-3"> - <For each={imageAttachments()}> - {(attachment) => ( - <div class="relative group"> - <Show - when={attachment.mime.startsWith("image/")} - fallback={ - <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"> - <Icon name="folder" class="size-6 text-text-weak" /> - </div> - } - > - <img - src={attachment.dataUrl} - alt={attachment.filename} - class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" - onClick={() => - dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />) - } - /> - </Show> - <button - type="button" - onClick={() => removeImageAttachment(attachment.id)} - class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" - aria-label={language.t("prompt.attachment.remove")} - > - <Icon name="close" class="size-3 text-text-weak" /> - </button> - <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"> - <span class="text-10-regular text-white truncate block">{attachment.filename}</span> - </div> - </div> - )} - </For> - </div> - </Show> + <PromptDragOverlay dragging={store.dragging} label={language.t("prompt.dropzone.label")} /> + <PromptContextItems + items={prompt.context.items()} + active={(item) => { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + openComment={openComment} + remove={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + t={(key) => language.t(key as Parameters<typeof language.t>[0])} + /> + <PromptImageAttachments + attachments={imageAttachments()} + onOpen={(attachment) => + dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />) + } + onRemove={removeImageAttachment} + removeLabel={language.t("prompt.attachment.remove")} + /> <div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}> <div data-component="prompt-input" @@ -1169,15 +981,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { }} role="textbox" aria-multiline="true" - aria-label={ - store.mode === "shell" - ? language.t("prompt.placeholder.shell") - : commentCount() > 1 - ? language.t("prompt.placeholder.summarizeComments") - : commentCount() === 1 - ? language.t("prompt.placeholder.summarizeComment") - : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) }) - } + aria-label={placeholder()} contenteditable="true" onInput={handleInput} onPaste={handlePaste} @@ -1194,13 +998,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { /> <Show when={!prompt.dirty()}> <div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"> - {store.mode === "shell" - ? language.t("prompt.placeholder.shell") - : commentCount() > 1 - ? language.t("prompt.placeholder.summarizeComments") - : commentCount() === 1 - ? language.t("prompt.placeholder.summarizeComment") - : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })} + {placeholder()} </div> </Show> </div> diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts new file mode 100644 index 000000000..b284c3884 --- /dev/null +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import type { Prompt } from "@/context/prompt" +import { buildRequestParts } from "./build-request-parts" + +describe("buildRequestParts", () => { + test("builds typed request and optimistic parts without cast path", () => { + const prompt: Prompt = [ + { type: "text", content: "hello", start: 0, end: 5 }, + { + type: "file", + path: "src/foo.ts", + content: "@src/foo.ts", + start: 5, + end: 16, + selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 }, + }, + { type: "agent", name: "planner", content: "@planner", start: 16, end: 24 }, + ] + + const result = buildRequestParts({ + prompt, + context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }], + images: [ + { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" }, + ], + text: "hello @src/foo.ts @planner", + messageID: "msg_1", + sessionID: "ses_1", + sessionDirectory: "/repo", + }) + + expect(result.requestParts[0]?.type).toBe("text") + expect(result.requestParts.some((part) => part.type === "agent")).toBe(true) + expect( + result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")), + ).toBe(true) + expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true) + + expect(result.optimisticParts).toHaveLength(result.requestParts.length) + expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) + }) + + test("deduplicates context files when prompt already includes same path", () => { + const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] + + const result = buildRequestParts({ + prompt, + context: [ + { key: "ctx:dup", type: "file", path: "src/foo.ts" }, + { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" }, + ], + images: [], + text: "@src/foo.ts", + messageID: "msg_2", + sessionID: "ses_2", + sessionDirectory: "/repo", + }) + + const fooFiles = result.requestParts.filter( + (part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"), + ) + const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic) + + expect(fooFiles).toHaveLength(2) + expect(synthetic).toHaveLength(1) + }) +}) diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts new file mode 100644 index 000000000..4cf2f29ac --- /dev/null +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -0,0 +1,174 @@ +import { getFilename } from "@opencode-ai/util/path" +import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" +import type { FileSelection } from "@/context/file" +import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" +import { Identifier } from "@/utils/id" + +type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string } + +type ContextFile = { + key: string + type: "file" + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +type BuildRequestPartsInput = { + prompt: Prompt + context: ContextFile[] + images: ImageAttachmentPart[] + text: string + messageID: string + sessionID: string + sessionDirectory: string +} + +const absolute = (directory: string, path: string) => + path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/") + +const fileQuery = (selection: FileSelection | undefined) => + selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" + +const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" +const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" + +const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { + const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined + const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined + const range = + start === undefined || end === undefined + ? "this file" + : start === end + ? `line ${start}` + : `lines ${start} through ${end}` + return `The user made the following comment regarding ${range} of ${path}: ${comment}` +} + +const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => { + if (part.type === "text") { + return { + id: part.id, + type: "text", + text: part.text, + synthetic: part.synthetic, + ignored: part.ignored, + time: part.time, + metadata: part.metadata, + sessionID, + messageID, + } + } + if (part.type === "file") { + return { + id: part.id, + type: "file", + mime: part.mime, + filename: part.filename, + url: part.url, + source: part.source, + sessionID, + messageID, + } + } + return { + id: part.id, + type: "agent", + name: part.name, + source: part.source, + sessionID, + messageID, + } +} + +export function buildRequestParts(input: BuildRequestPartsInput) { + const requestParts: PromptRequestPart[] = [ + { + id: Identifier.ascending("part"), + type: "text", + text: input.text, + }, + ] + + const files = input.prompt.filter(isFileAttachment).map((attachment) => { + const path = absolute(input.sessionDirectory, attachment.path) + return { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url: `file://${path}${fileQuery(attachment.selection)}`, + filename: getFilename(attachment.path), + source: { + type: "file", + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + path, + }, + } satisfies PromptRequestPart + }) + + const agents = input.prompt.filter(isAgentAttachment).map((attachment) => { + return { + id: Identifier.ascending("part"), + type: "agent", + name: attachment.name, + source: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + } satisfies PromptRequestPart + }) + + const used = new Set(files.map((part) => part.url)) + const context = input.context.flatMap((item) => { + const path = absolute(input.sessionDirectory, item.path) + const url = `file://${path}${fileQuery(item.selection)}` + const comment = item.comment?.trim() + if (!comment && used.has(url)) return [] + used.add(url) + + const filePart = { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url, + filename: getFilename(item.path), + } satisfies PromptRequestPart + + if (!comment) return [filePart] + + return [ + { + id: Identifier.ascending("part"), + type: "text", + text: commentNote(item.path, item.selection, comment), + synthetic: true, + } satisfies PromptRequestPart, + filePart, + ] + }) + + const images = input.images.map((attachment) => { + return { + id: Identifier.ascending("part"), + type: "file", + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + } satisfies PromptRequestPart + }) + + requestParts.push(...files, ...context, ...agents, ...images) + + return { + requestParts, + optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)), + } +} diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx new file mode 100644 index 000000000..a843e109d --- /dev/null +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -0,0 +1,82 @@ +import { Component, For, Show } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import type { ContextItem } from "@/context/prompt" + +type PromptContextItem = ContextItem & { key: string } + +type ContextItemsProps = { + items: PromptContextItem[] + active: (item: PromptContextItem) => boolean + openComment: (item: PromptContextItem) => void + remove: (item: PromptContextItem) => void + t: (key: string) => string +} + +export const PromptContextItems: Component<ContextItemsProps> = (props) => { + return ( + <Show when={props.items.length > 0}> + <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar"> + <For each={props.items}> + {(item) => ( + <Tooltip + value={ + <span class="flex max-w-[300px]"> + <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0"> + {getDirectory(item.path)} + </span> + <span class="shrink-0">{getFilename(item.path)}</span> + </span> + } + placement="top" + openDelay={2000} + > + <div + classList={{ + "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true, + "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item), + "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": + props.active(item), + "bg-background-stronger": !props.active(item), + }} + onClick={() => props.openComment(item)} + > + <div class="flex items-center gap-1.5"> + <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> + <div class="flex items-center text-11-regular min-w-0 font-medium"> + <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span> + <Show when={item.selection}> + {(sel) => ( + <span class="text-text-weak whitespace-nowrap shrink-0"> + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + </span> + )} + </Show> + </div> + <IconButton + type="button" + icon="close-small" + variant="ghost" + class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all" + onClick={(e) => { + e.stopPropagation() + props.remove(item) + }} + aria-label={props.t("prompt.context.removeFile")} + /> + </div> + <Show when={item.comment}> + {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>} + </Show> + </div> + </Tooltip> + )} + </For> + </div> + </Show> + ) +} diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx new file mode 100644 index 000000000..f5a4d399e --- /dev/null +++ b/packages/app/src/components/prompt-input/drag-overlay.tsx @@ -0,0 +1,20 @@ +import { Component, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" + +type PromptDragOverlayProps = { + dragging: boolean + label: string +} + +export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => { + return ( + <Show when={props.dragging}> + <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none"> + <div class="flex flex-col items-center gap-2 text-text-weak"> + <Icon name="photo" class="size-8" /> + <span class="text-14-regular">{props.label}</span> + </div> + </div> + </Show> + ) +} diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx new file mode 100644 index 000000000..ba3addf0a --- /dev/null +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -0,0 +1,51 @@ +import { Component, For, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import type { ImageAttachmentPart } from "@/context/prompt" + +type PromptImageAttachmentsProps = { + attachments: ImageAttachmentPart[] + onOpen: (attachment: ImageAttachmentPart) => void + onRemove: (id: string) => void + removeLabel: string +} + +export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => { + return ( + <Show when={props.attachments.length > 0}> + <div class="flex flex-wrap gap-2 px-3 pt-3"> + <For each={props.attachments}> + {(attachment) => ( + <div class="relative group"> + <Show + when={attachment.mime.startsWith("image/")} + fallback={ + <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"> + <Icon name="folder" class="size-6 text-text-weak" /> + </div> + } + > + <img + src={attachment.dataUrl} + alt={attachment.filename} + class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" + onClick={() => props.onOpen(attachment)} + /> + </Show> + <button + type="button" + onClick={() => props.onRemove(attachment.id)} + class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" + aria-label={props.removeLabel} + > + <Icon name="close" class="size-3 text-text-weak" /> + </button> + <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"> + <span class="text-10-regular text-white truncate block">{attachment.filename}</span> + </div> + </div> + )} + </For> + </div> + </Show> + ) +} diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts new file mode 100644 index 000000000..b633df829 --- /dev/null +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { promptPlaceholder } from "./placeholder" + +describe("promptPlaceholder", () => { + const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}` + + test("returns shell placeholder in shell mode", () => { + const value = promptPlaceholder({ + mode: "shell", + commentCount: 0, + example: "example", + t, + }) + expect(value).toBe("prompt.placeholder.shell") + }) + + test("returns summarize placeholders for comment context", () => { + expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe( + "prompt.placeholder.summarizeComment", + ) + expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe( + "prompt.placeholder.summarizeComments", + ) + }) + + test("returns default placeholder with example", () => { + const value = promptPlaceholder({ + mode: "normal", + commentCount: 0, + example: "translated-example", + t, + }) + expect(value).toBe("prompt.placeholder.normal:translated-example") + }) +}) diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts new file mode 100644 index 000000000..07f6a43b5 --- /dev/null +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -0,0 +1,13 @@ +type PromptPlaceholderInput = { + mode: "normal" | "shell" + commentCount: number + example: string + t: (key: string, params?: Record<string, string>) => string +} + +export function promptPlaceholder(input: PromptPlaceholderInput) { + if (input.mode === "shell") return input.t("prompt.placeholder.shell") + if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") + if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") + return input.t("prompt.placeholder.normal", { example: input.example }) +} diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx new file mode 100644 index 000000000..b97bb6752 --- /dev/null +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -0,0 +1,144 @@ +import { Component, For, Match, Show, Switch } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" + +export type AtOption = + | { type: "agent"; name: string; display: string } + | { type: "file"; path: string; display: string; recent?: boolean } + +export interface SlashCommand { + id: string + trigger: string + title: string + description?: string + keybind?: string + type: "builtin" | "custom" + source?: "command" | "mcp" | "skill" +} + +type PromptPopoverProps = { + popover: "at" | "slash" | null + setSlashPopoverRef: (el: HTMLDivElement) => void + atFlat: AtOption[] + atActive?: string + atKey: (item: AtOption) => string + setAtActive: (id: string) => void + onAtSelect: (item: AtOption) => void + slashFlat: SlashCommand[] + slashActive?: string + setSlashActive: (id: string) => void + onSlashSelect: (item: SlashCommand) => void + commandKeybind: (id: string) => string | undefined + t: (key: string) => string +} + +export const PromptPopover: Component<PromptPopoverProps> = (props) => { + return ( + <Show when={props.popover}> + <div + ref={(el) => { + if (props.popover === "slash") props.setSlashPopoverRef(el) + }} + class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 + overflow-auto no-scrollbar flex flex-col p-2 rounded-md + border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" + onMouseDown={(e) => e.preventDefault()} + > + <Switch> + <Match when={props.popover === "at"}> + <Show + when={props.atFlat.length > 0} + fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>} + > + <For each={props.atFlat.slice(0, 10)}> + {(item) => ( + <button + classList={{ + "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true, + "bg-surface-raised-base-hover": props.atActive === props.atKey(item), + }} + onClick={() => props.onAtSelect(item)} + onMouseEnter={() => props.setAtActive(props.atKey(item))} + > + <Show + when={item.type === "agent"} + fallback={ + <> + <FileIcon + node={{ path: item.type === "file" ? item.path : "", type: "file" }} + class="shrink-0 size-4" + /> + <div class="flex items-center text-14-regular min-w-0"> + <span class="text-text-weak whitespace-nowrap truncate min-w-0"> + {item.type === "file" + ? item.path.endsWith("/") + ? item.path + : getDirectory(item.path) + : ""} + </span> + <Show when={item.type === "file" && !item.path.endsWith("/")}> + <span class="text-text-strong whitespace-nowrap"> + {item.type === "file" ? getFilename(item.path) : ""} + </span> + </Show> + </div> + </> + } + > + <Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> + <span class="text-14-regular text-text-strong whitespace-nowrap"> + @{item.type === "agent" ? item.name : ""} + </span> + </Show> + </button> + )} + </For> + </Show> + </Match> + <Match when={props.popover === "slash"}> + <Show + when={props.slashFlat.length > 0} + fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>} + > + <For each={props.slashFlat}> + {(cmd) => ( + <button + data-slash-id={cmd.id} + classList={{ + "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true, + "bg-surface-raised-base-hover": props.slashActive === cmd.id, + }} + onClick={() => props.onSlashSelect(cmd)} + onMouseEnter={() => props.setSlashActive(cmd.id)} + > + <div class="flex items-center gap-2 min-w-0"> + <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span> + <Show when={cmd.description}> + <span class="text-14-regular text-text-weak truncate">{cmd.description}</span> + </Show> + </div> + <div class="flex items-center gap-2 shrink-0"> + <Show when={cmd.type === "custom" && cmd.source !== "command"}> + <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded"> + {cmd.source === "skill" + ? props.t("prompt.slash.badge.skill") + : cmd.source === "mcp" + ? props.t("prompt.slash.badge.mcp") + : props.t("prompt.slash.badge.custom")} + </span> + </Show> + <Show when={props.commandKeybind(cmd.id)}> + <span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span> + </Show> + </div> + </button> + )} + </For> + </Show> + </Match> + </Switch> + </div> + </Show> + ) +} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 1e5ebe4cb..5ed5eedad 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,19 +1,10 @@ import { Accessor } from "solid-js" -import { produce } from "solid-js/store" import { useNavigate, useParams } from "@solidjs/router" -import { getFilename } from "@opencode-ai/util/path" -import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client" -import { Binary } from "@opencode-ai/util/binary" +import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useLocal } from "@/context/local" -import { - usePrompt, - type AgentPart, - type FileAttachmentPart, - type ImageAttachmentPart, - type Prompt, -} from "@/context/prompt" +import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" @@ -24,6 +15,7 @@ import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" import type { FileSelection } from "@/context/file" import { setCursorPosition } from "./editor-dom" +import { buildRequestParts } from "./build-request-parts" type PendingPrompt = { abort: AbortController @@ -290,138 +282,19 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const toAbsolutePath = (path: string) => - path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/") - - const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[] - const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[] - - const fileAttachmentParts = fileAttachments.map((attachment) => { - const absolute = toAbsolutePath(attachment.path) - const query = attachment.selection - ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` - : "" - return { - id: Identifier.ascending("part"), - type: "file" as const, - mime: "text/plain", - url: `file://${absolute}${query}`, - filename: getFilename(attachment.path), - source: { - type: "file" as const, - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - path: absolute, - }, - } - }) - - const agentAttachmentParts = agentAttachments.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "agent" as const, - name: attachment.name, - source: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - })) - - const usedUrls = new Set(fileAttachmentParts.map((part) => part.url)) - const context = prompt.context.items().slice() const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) - const contextParts: Array< - | { - id: string - type: "text" - text: string - synthetic?: boolean - } - | { - id: string - type: "file" - mime: string - url: string - filename?: string - } - > = [] - - const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { - const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined - const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined - const range = - start === undefined || end === undefined - ? "this file" - : start === end - ? `line ${start}` - : `lines ${start} through ${end}` - - return `The user made the following comment regarding ${range} of ${path}: ${comment}` - } - - const addContextFile = (item: { path: string; selection?: FileSelection; comment?: string }) => { - const absolute = toAbsolutePath(item.path) - const query = item.selection ? `?start=${item.selection.startLine}&end=${item.selection.endLine}` : "" - const url = `file://${absolute}${query}` - - const comment = item.comment?.trim() - if (!comment && usedUrls.has(url)) return - usedUrls.add(url) - - if (comment) { - contextParts.push({ - id: Identifier.ascending("part"), - type: "text", - text: commentNote(item.path, item.selection, comment), - synthetic: true, - }) - } - - contextParts.push({ - id: Identifier.ascending("part"), - type: "file", - mime: "text/plain", - url, - filename: getFilename(item.path), - }) - } - - for (const item of context) { - if (item.type !== "file") continue - addContextFile({ path: item.path, selection: item.selection, comment: item.comment }) - } - - const imageAttachmentParts = images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, - })) - const messageID = Identifier.ascending("message") - const requestParts = [ - { - id: Identifier.ascending("part"), - type: "text" as const, - text, - }, - ...fileAttachmentParts, - ...contextParts, - ...agentAttachmentParts, - ...imageAttachmentParts, - ] - - const optimisticParts = requestParts.map((part) => ({ - ...part, + const { requestParts, optimisticParts } = buildRequestParts({ + prompt: currentPrompt, + context, + images, + text, sessionID: session.id, messageID, - })) as unknown as Part[] + sessionDirectory, + }) const optimisticMessage: Message = { id: messageID, @@ -432,69 +305,20 @@ export function createPromptSubmit(input: PromptSubmitInput) { model, } - const addOptimisticMessage = () => { - if (sessionDirectory === projectDirectory) { - sync.set( - produce((draft) => { - const messages = draft.message[session.id] - if (!messages) { - draft.message[session.id] = [optimisticMessage] - } else { - const result = Binary.search(messages, messageID, (m) => m.id) - messages.splice(result.index, 0, optimisticMessage) - } - draft.part[messageID] = optimisticParts - .filter((part) => !!part?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }), - ) - return - } - - globalSync.child(sessionDirectory)[1]( - produce((draft) => { - const messages = draft.message[session.id] - if (!messages) { - draft.message[session.id] = [optimisticMessage] - } else { - const result = Binary.search(messages, messageID, (m) => m.id) - messages.splice(result.index, 0, optimisticMessage) - } - draft.part[messageID] = optimisticParts - .filter((part) => !!part?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }), - ) - } - - const removeOptimisticMessage = () => { - if (sessionDirectory === projectDirectory) { - sync.set( - produce((draft) => { - const messages = draft.message[session.id] - if (messages) { - const result = Binary.search(messages, messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[messageID] - }), - ) - return - } + const addOptimisticMessage = () => + sync.session.optimistic.add({ + directory: sessionDirectory, + sessionID: session.id, + message: optimisticMessage, + parts: optimisticParts, + }) - globalSync.child(sessionDirectory)[1]( - produce((draft) => { - const messages = draft.message[session.id] - if (messages) { - const result = Binary.search(messages, messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[messageID] - }), - ) - } + const removeOptimisticMessage = () => + sync.session.optimistic.remove({ + directory: sessionDirectory, + sessionID: session.id, + messageID, + }) removeCommentItems(commentItems) clearInput() diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx new file mode 100644 index 000000000..b43c07882 --- /dev/null +++ b/packages/app/src/components/server/server-row.tsx @@ -0,0 +1,77 @@ +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { serverDisplayName } from "@/context/server" +import type { ServerHealth } from "@/utils/server-health" + +interface ServerRowProps extends ParentProps { + url: string + status?: ServerHealth + class?: string + nameClass?: string + versionClass?: string + dimmed?: boolean + badge?: JSXElement +} + +export function ServerRow(props: ServerRowProps) { + const [truncated, setTruncated] = createSignal(false) + let nameRef: HTMLSpanElement | undefined + let versionRef: HTMLSpanElement | undefined + + const check = () => { + const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false + const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false + setTruncated(nameTruncated || versionTruncated) + } + + createEffect(() => { + props.url + props.status?.version + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(check) + return + } + check() + }) + + onMount(() => { + check() + if (typeof window === "undefined") return + window.addEventListener("resize", check) + onCleanup(() => window.removeEventListener("resize", check)) + }) + + const tooltipValue = () => ( + <span class="flex items-center gap-2"> + <span>{serverDisplayName(props.url)}</span> + <Show when={props.status?.version}> + <span class="text-text-invert-base">{props.status?.version}</span> + </Show> + </span> + ) + + return ( + <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}> + <div class={props.class} classList={{ "opacity-50": props.dimmed }}> + <div + classList={{ + "size-1.5 rounded-full shrink-0": true, + "bg-icon-success-base": props.status?.healthy === true, + "bg-icon-critical-base": props.status?.healthy === false, + "bg-border-weak-base": props.status === undefined, + }} + /> + <span ref={nameRef} class={props.nameClass ?? "truncate"}> + {serverDisplayName(props.url)} + </span> + <Show when={props.status?.version}> + <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}> + {props.status?.version} + </span> + </Show> + {props.badge} + {props.children} + </div> + </Tooltip> + ) +} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 102c477a1..3354c3d36 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -7,30 +7,15 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { Button } from "@opencode-ai/ui/button" import { Switch } from "@opencode-ai/ui/switch" import { Icon } from "@opencode-ai/ui/icon" -import { Tooltip } from "@opencode-ai/ui/tooltip" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" -import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { normalizeServerUrl, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { DialogSelectServer } from "./dialog-select-server" import { showToast } from "@opencode-ai/ui/toast" - -type ServerStatus = { healthy: boolean; version?: string } - -async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> { - const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) - const sdk = createOpencodeClient({ - baseUrl: url, - fetch: platform.fetch, - signal, - }) - return sdk.global - .health() - .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) - .catch(() => ({ healthy: false })) -} +import { ServerRow } from "@/components/server/server-row" +import { checkServerHealth, type ServerHealth } from "@/utils/server-health" export function StatusPopover() { const sync = useSync() @@ -42,10 +27,11 @@ export function StatusPopover() { const navigate = useNavigate() const [store, setStore] = createStore({ - status: {} as Record<string, ServerStatus | undefined>, + status: {} as Record<string, ServerHealth | undefined>, loading: null as string | null, defaultServerUrl: undefined as string | undefined, }) + const fetcher = platform.fetch ?? globalThis.fetch const servers = createMemo(() => { const current = server.url @@ -60,7 +46,7 @@ export function StatusPopover() { if (!list.length) return list const active = server.url const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerStatus) => { + const rank = (value?: ServerHealth) => { if (value?.healthy === true) return 0 if (value?.healthy === false) return 2 return 1 @@ -75,10 +61,10 @@ export function StatusPopover() { }) async function refreshHealth() { - const results: Record<string, ServerStatus> = {} + const results: Record<string, ServerHealth> = {} await Promise.all( servers().map(async (url) => { - results[url] = await checkHealth(url, platform) + results[url] = await checkServerHealth(url, fetcher) }), ) setStore("status", reconcile(results)) @@ -213,78 +199,43 @@ export function StatusPopover() { const isDefault = () => url === store.defaultServerUrl const status = () => store.status[url] const isBlocked = () => status()?.healthy === false - const [truncated, setTruncated] = createSignal(false) - let nameRef: HTMLSpanElement | undefined - let versionRef: HTMLSpanElement | undefined - - onMount(() => { - const check = () => { - const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false - const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false - setTruncated(nameTruncated || versionTruncated) - } - check() - window.addEventListener("resize", check) - onCleanup(() => window.removeEventListener("resize", check)) - }) - - const tooltipValue = () => { - const name = serverDisplayName(url) - const version = status()?.version - return ( - <span class="flex items-center gap-2"> - <span>{name}</span> - <Show when={version}> - <span class="text-text-invert-base">{version}</span> - </Show> - </span> - ) - } return ( - <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}> - <button - type="button" - class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left" - classList={{ - "opacity-50": isBlocked(), - "hover:bg-surface-raised-base-hover": !isBlocked(), - "cursor-not-allowed": isBlocked(), - }} - aria-disabled={isBlocked()} - onClick={() => { - if (isBlocked()) return - server.setActive(url) - navigate("/") - }} + <button + type="button" + class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left" + classList={{ + "hover:bg-surface-raised-base-hover": !isBlocked(), + "cursor-not-allowed": isBlocked(), + }} + aria-disabled={isBlocked()} + onClick={() => { + if (isBlocked()) return + server.setActive(url) + navigate("/") + }} + > + <ServerRow + url={url} + status={status()} + dimmed={isBlocked()} + class="flex items-center gap-2 w-full min-w-0" + nameClass="text-14-regular text-text-base truncate" + versionClass="text-12-regular text-text-weak truncate" + badge={ + <Show when={isDefault()}> + <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> + {language.t("common.default")} + </span> + </Show> + } > - <div - classList={{ - "size-1.5 rounded-full shrink-0": true, - "bg-icon-success-base": status()?.healthy === true, - "bg-icon-critical-base": status()?.healthy === false, - "bg-border-weak-base": status() === undefined, - }} - /> - <span ref={nameRef} class="text-14-regular text-text-base truncate"> - {serverDisplayName(url)} - </span> - <Show when={status()?.version}> - <span ref={versionRef} class="text-12-regular text-text-weak truncate"> - {status()?.version} - </span> - </Show> - <Show when={isDefault()}> - <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> - {language.t("common.default")} - </span> - </Show> <div class="flex-1" /> <Show when={isActive()}> <Icon name="check" size="small" class="text-icon-weak shrink-0" /> </Show> - </button> - </Tooltip> + </ServerRow> + </button> ) }} </For> diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 4d44d5f7e..2ee2e074e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -8,6 +8,7 @@ import { LocalPTY } from "@/context/terminal" import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" import { useLanguage } from "@/context/language" import { showToast } from "@opencode-ai/ui/toast" +import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY @@ -111,17 +112,13 @@ export const Terminal = (props: TerminalProps) => { const colors = getTerminalColors() setTerminalColors(colors) if (!term) return - const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption - if (!setOption) return - setOption("theme", colors) + setOptionIfSupported(term, "theme", colors) }) createEffect(() => { const font = monoFontFamily(settings.appearance.font()) if (!term) return - const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption - if (!setOption) return - setOption("fontFamily", font) + setOptionIfSupported(term, "fontFamily", font) }) const focusTerminal = () => { @@ -146,12 +143,12 @@ export const Terminal = (props: TerminalProps) => { const t = term if (!t) return - const link = (t as unknown as { currentHoveredLink?: { text: string } }).currentHoveredLink - if (!link?.text) return + const text = getHoveredLinkText(t) + if (!text) return event.preventDefault() event.stopImmediatePropagation() - platform.openLink(link.text) + platform.openLink(text) } onMount(() => { @@ -250,7 +247,7 @@ export const Terminal = (props: TerminalProps) => { const fit = new mod.FitAddon() const serializer = new SerializeAddon() - cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(fit)) t.loadAddon(serializer) t.loadAddon(fit) fitAddon = fit @@ -303,19 +300,19 @@ export const Terminal = (props: TerminalProps) => { .catch(() => {}) } }) - cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(onResize)) const onData = t.onData((data) => { if (socket.readyState === WebSocket.OPEN) { socket.send(data) } }) - cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(onData)) const onKey = t.onKey((key) => { if (key.key == "Enter") { props.onSubmit?.() } }) - cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(onKey)) // t.onScroll((ydisp) => { // console.log("Scroll position:", ydisp) // }) |
