summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
authorAaron Iker <[email protected]>2026-01-30 18:57:49 +0100
committerGitHub <[email protected]>2026-01-30 17:57:49 +0000
commit20619a6a26ec0cfc2707b7ed13387715e9f9cdaa (patch)
tree50b8aa69da65642789c3fa92f65034a9dd1361df /packages/app/src/components
parent1bbe84ed8d0eceb96af06b971690ebd0b0213580 (diff)
downloadopencode-20619a6a26ec0cfc2707b7ed13387715e9f9cdaa.tar.gz
opencode-20619a6a26ec0cfc2707b7ed13387715e9f9cdaa.zip
feat: Transitions, spacing, scroll fade, prompt area update (#11168)
Co-authored-by: Github Action <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: aaroniker <[email protected]>
Diffstat (limited to 'packages/app/src/components')
-rw-r--r--packages/app/src/components/dialog-select-model.tsx7
-rw-r--r--packages/app/src/components/prompt-input.tsx148
-rw-r--r--packages/app/src/components/session-context-usage.tsx6
-rw-r--r--packages/app/src/components/settings-general.tsx42
-rw-r--r--packages/app/src/components/settings-keybinds.tsx10
5 files changed, 132 insertions, 81 deletions
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx
index 4f0dcc3ee..4d96c6c5f 100644
--- a/packages/app/src/components/dialog-select-model.tsx
+++ b/packages/app/src/components/dialog-select-model.tsx
@@ -90,7 +90,7 @@ const ModelList: Component<{
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
provider?: string
- children?: JSX.Element
+ children?: JSX.Element | ((open: boolean) => JSX.Element)
triggerAs?: T
triggerProps?: ComponentProps<T>
}) {
@@ -182,12 +182,13 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
as={props.triggerAs ?? "div"}
{...(props.triggerProps as any)}
>
- {props.children}
+ {typeof props.children === "function" ? props.children(store.open) : props.children}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
+ class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
+ data-component="model-popover-content"
ref={(el) => setStore("content", el)}
- class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => {
setStore("dismiss", "escape")
setStore("open", false)
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 5c1d417eb..313f0402d 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -32,7 +32,9 @@ import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
import { Button } from "@opencode-ai/ui/button"
+import { CycleLabel } from "@opencode-ai/ui/cycle-label"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
@@ -42,6 +44,7 @@ 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 { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
@@ -922,7 +925,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.abort({
sessionID,
})
- .catch(() => {})
+ .catch(() => { })
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
@@ -1252,7 +1255,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput()
client.session
.shell({
- sessionID: session.id,
+ sessionID: session?.id || "",
agent,
model,
command: text,
@@ -1275,7 +1278,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput()
client.session
.command({
- sessionID: session.id,
+ sessionID: session?.id || "",
command: commandName,
arguments: args.join(" "),
agent,
@@ -1348,18 +1351,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const contextParts: Array<
| {
- id: string
- type: "text"
- text: string
- synthetic?: boolean
- }
+ id: string
+ type: "text"
+ text: string
+ synthetic?: boolean
+ }
| {
- id: string
- type: "file"
- mime: string
- url: string
- filename?: string
- }
+ id: string
+ type: "file"
+ mime: string
+ url: string
+ filename?: string
+ }
> = []
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
@@ -1431,13 +1434,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const optimisticParts = requestParts.map((part) => ({
...part,
- sessionID: session.id,
+ sessionID: session?.id || "",
messageID,
})) as unknown as Part[]
const optimisticMessage: Message = {
id: messageID,
- sessionID: session.id,
+ sessionID: session?.id || "",
role: "user",
time: { created: Date.now() },
agent,
@@ -1448,9 +1451,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (sessionDirectory === projectDirectory) {
sync.set(
produce((draft) => {
- const messages = draft.message[session.id]
+ const messages = draft.message[session?.id || ""]
if (!messages) {
- draft.message[session.id] = [optimisticMessage]
+ draft.message[session?.id || ""] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
@@ -1466,9 +1469,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1](
produce((draft) => {
- const messages = draft.message[session.id]
+ const messages = draft.message[session?.id || ""]
if (!messages) {
- draft.message[session.id] = [optimisticMessage]
+ draft.message[session?.id || ""] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
@@ -1485,7 +1488,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (sessionDirectory === projectDirectory) {
sync.set(
produce((draft) => {
- const messages = draft.message[session.id]
+ 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)
@@ -1498,7 +1501,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1](
produce((draft) => {
- const messages = draft.message[session.id]
+ 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)
@@ -1519,15 +1522,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const worktree = WorktreeState.get(sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "busy" })
+ if (sessionDirectory === projectDirectory && session?.id) {
+ sync.set("session_status", session?.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "idle" })
+ if (sessionDirectory === projectDirectory && session?.id) {
+ sync.set("session_status", session?.id, { type: "idle" })
}
removeOptimisticMessage()
for (const item of commentItems) {
@@ -1544,7 +1547,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreInput()
}
- pending.set(session.id, { abort: controller, cleanup })
+ pending.set(session?.id || "", { abort: controller, cleanup })
const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
@@ -1572,7 +1575,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
- pending.delete(session.id)
+ pending.delete(session?.id || "")
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
@@ -1582,7 +1585,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.prompt({
- sessionID: session.id,
+ sessionID: session?.id || "",
agent,
model,
messageID,
@@ -1592,9 +1595,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
void send().catch((err) => {
- pending.delete(session.id)
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "idle" })
+ pending.delete(session?.id || "")
+ if (sessionDirectory === projectDirectory && session?.id) {
+ sync.set("session_status", session?.id, { type: "idle" })
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
@@ -1616,6 +1619,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
}
+ const currrentModelVariant = createMemo(() => {
+ const modelVariant = local.model.variant.current() ?? ""
+ return modelVariant === "xhigh"
+ ? "xHigh"
+ : modelVariant.length > 0
+ ? modelVariant[0].toUpperCase() + modelVariant.slice(1)
+ : "Default"
+ })
+
+ const reasoningPercentage = createMemo(() => {
+ const variants = local.model.variant.list()
+ const current = local.model.variant.current()
+ const totalEntries = variants.length + 1
+
+ if (totalEntries <= 2 || current === "Default") {
+ return 0
+ }
+
+ const currentIndex = current ? variants.indexOf(current) + 1 : 0
+ return ((currentIndex + 1) / totalEntries) * 100
+ }, [local.model.variant])
+
return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popover}>
@@ -1668,7 +1693,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</>
}
>
- <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
+ <Icon name="brain" size="normal" 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>
@@ -1729,9 +1754,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
>
<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="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 mr-1 pointer-events-none">
<div class="flex flex-col items-center gap-2 text-text-weak">
- <Icon name="photo" class="size-8" />
+ <Icon name="photo" size={18} class="text-icon-base stroke-1.5" />
<span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
</div>
</div>
@@ -1770,7 +1795,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
>
<div class="flex items-center gap-1.5">
- <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
+ <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-7" />
<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}>
@@ -1787,7 +1812,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
icon="close-small"
variant="ghost"
- class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all"
+ class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID)
@@ -1817,7 +1842,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
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" />
+ <Icon name="folder" size="normal" class="size-6 text-text-base" />
</div>
}
>
@@ -1891,7 +1916,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show>
</div>
<div class="relative p-3 flex items-center justify-between">
- <div class="flex items-center justify-start gap-0.5">
+ <div class="flex items-center justify-start gap-2">
<Switch>
<Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6">
@@ -1922,12 +1947,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
- <Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
+ <Button
+ as="div"
+ variant="ghost"
+ class="px-2"
+ onClick={() => dialog.render(<DialogSelectModelUnpaid />, "select-model")}
+ >
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
- <Icon name="chevron-down" size="small" />
+ <MorphChevron expanded={dialog.isActive("select-model")} />
</Button>
</TooltipKeybind>
}
@@ -1938,11 +1968,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }}>
- <Show when={local.model.current()?.provider?.id}>
- <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
- </Show>
- {local.model.current()?.name ?? language.t("dialog.model.select.title")}
- <Icon name="chevron-down" size="small" />
+ {(open) => (
+ <>
+ <Show when={local.model.current()?.provider?.id}>
+ <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
+ </Show>
+ {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+ <MorphChevron expanded={open} class="text-text-weak" />
+ </>
+ )}
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
@@ -1955,10 +1989,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Button
data-action="model-variant-cycle"
variant="ghost"
- class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
+ class="text-text-strong text-12-regular"
onClick={() => local.model.variant.cycle()}
>
- {local.model.variant.current() ?? language.t("common.default")}
+ <Show when={local.model.variant.list().length > 1}>
+ <ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
+ </Show>
+ <CycleLabel value={currrentModelVariant()} />
</Button>
</TooltipKeybind>
</Show>
@@ -1972,7 +2009,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
- "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
+ "_hidden group-hover/prompt-input:flex items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
@@ -1994,7 +2031,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Match>
</Switch>
</div>
- <div class="flex items-center gap-3 absolute right-3 bottom-3">
+ <div class="flex items-center gap-1 absolute right-3 bottom-3">
<input
ref={fileInputRef}
type="file"
@@ -2006,18 +2043,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = ""
}}
/>
- <div class="flex items-center gap-2">
+ <div class="flex items-center gap-1.5 mr-1.5">
<SessionContextUsage />
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
<Button
type="button"
variant="ghost"
- class="size-6"
+ size="small"
+ class="px-1"
onClick={() => fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")}
>
- <Icon name="photo" class="size-4.5" />
+ <Icon name="photo" class="size-6 text-icon-base" />
</Button>
</Tooltip>
</Show>
@@ -2036,7 +2074,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
- <Icon name="enter" size="small" class="text-icon-base" />
+ <Icon name="enter" size="normal" class="text-icon-base" />
</div>
</Match>
</Switch>
@@ -2047,7 +2085,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
- class="h-6 w-4.5"
+ class="h-6 w-5.5"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
index 1e37d8f6a..92b060212 100644
--- a/packages/app/src/components/session-context-usage.tsx
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -64,8 +64,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}
const circle = () => (
- <div class="p-1">
- <ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
+ <div class="text-icon-base">
+ <ProgressCircle size={18} percentage={context()?.percentage ?? 0} />
</div>
)
@@ -101,7 +101,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<Button
type="button"
variant="ghost"
- class="size-6"
+ class="size-7 text-icon-base"
onClick={openContext}
aria-label={language.t("context.usage.view")}
>
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx
index 3b08652bb..3010cbc40 100644
--- a/packages/app/src/components/settings-general.tsx
+++ b/packages/app/src/components/settings-general.tsx
@@ -10,6 +10,7 @@ import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
+import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
@@ -60,24 +61,24 @@ export const SettingsGeneral: Component = () => {
const actions =
platform.update && platform.restart
? [
- {
- label: language.t("toast.update.action.installRestart"),
- onClick: async () => {
- await platform.update!()
- await platform.restart!()
- },
+ {
+ label: language.t("toast.update.action.installRestart"),
+ onClick: async () => {
+ await platform.update!()
+ await platform.restart!()
},
- {
- label: language.t("toast.update.action.notYet"),
- onClick: "dismiss" as const,
- },
- ]
+ },
+ {
+ label: language.t("toast.update.action.notYet"),
+ onClick: "dismiss" as const,
+ },
+ ]
: [
- {
- label: language.t("toast.update.action.notYet"),
- onClick: "dismiss" as const,
- },
- ]
+ {
+ label: language.t("toast.update.action.notYet"),
+ onClick: "dismiss" as const,
+ },
+ ]
showToast({
persistent: true,
@@ -130,7 +131,12 @@ export const SettingsGeneral: Component = () => {
const soundOptions = [...SOUND_OPTIONS]
return (
- <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
+ <ScrollFade
+ direction="vertical"
+ fadeStartSize={0}
+ fadeEndSize={16}
+ class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
+ >
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
@@ -394,7 +400,7 @@ export const SettingsGeneral: Component = () => {
</div>
</div>
</div>
- </div>
+ </ScrollFade>
)
}
diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx
index 393da0c2a..efd18c8bd 100644
--- a/packages/app/src/components/settings-keybinds.tsx
+++ b/packages/app/src/components/settings-keybinds.tsx
@@ -9,6 +9,7 @@ import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
+import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
@@ -352,7 +353,12 @@ export const SettingsKeybinds: Component = () => {
})
return (
- <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
+ <ScrollFade
+ direction="vertical"
+ fadeStartSize={0}
+ fadeEndSize={16}
+ class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
+ >
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
@@ -429,6 +435,6 @@ export const SettingsKeybinds: Component = () => {
</div>
</Show>
</div>
- </div>
+ </ScrollFade>
)
}