From 590a11ed9870fb0f64e8f99ccbaa8c9565b793fa Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:40:04 +0800 Subject: [PATCH 001/271] fix(desktop): include wslServers loading in ready state (#37218) --- packages/desktop/src/renderer/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 2f14f43481..13c122cb2e 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -382,7 +382,8 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { function App() { const wslServers = useWslServers() const ready = createMemo( - () => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading, + () => + !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading && !wslServers.isLoading, ) const servers = createMemo(() => { const data = initializationData(sidecar) From 958e08e109b2adf263c78d4b6edbfa328c72ecb8 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:21:20 +0530 Subject: [PATCH 002/271] feat(app): cmd+k menu shows sessions on home page (#36686) Co-authored-by: Brendan Allan --- .../app/src/components/command-palette.ts | 224 +++++++----------- .../components/dialog-command-palette-v2.tsx | 164 +++++++++++-- .../app/src/components/settings-keybinds.tsx | 3 +- packages/app/src/context/command.test.ts | 16 +- packages/app/src/context/command.tsx | 14 +- packages/app/src/i18n/ar.ts | 1 + packages/app/src/i18n/br.ts | 1 + packages/app/src/i18n/bs.ts | 1 + packages/app/src/i18n/da.ts | 1 + packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 1 + packages/app/src/i18n/fr.ts | 1 + packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 1 + packages/app/src/i18n/no.ts | 1 + packages/app/src/i18n/pl.ts | 1 + packages/app/src/i18n/ru.ts | 1 + packages/app/src/i18n/th.ts | 1 + packages/app/src/i18n/tr.ts | 1 + packages/app/src/i18n/uk.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/i18n/zht.ts | 1 + packages/app/src/pages/home.tsx | 28 +++ packages/app/src/pages/new-session.tsx | 11 + packages/app/src/pages/session.tsx | 10 + .../pages/session/use-session-commands.tsx | 2 +- .../app/test-browser/command-palette.test.ts | 76 ++++++ 28 files changed, 409 insertions(+), 157 deletions(-) create mode 100644 packages/app/test-browser/command-palette.test.ts diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index b61a61977e..95e03351ae 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -1,17 +1,18 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" import { getFilename } from "@opencode-ai/core/util/path" +import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useNavigate } from "@solidjs/router" import { createMemo, onCleanup } from "solid-js" -import { useCommand, type CommandOption } from "@/context/command" +import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" import { useFile } from "@/context/file" +import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" -import { useLayout } from "@/context/layout" -import { useServerSDK, type ServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" +import { useLayout, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" +import { useTabs } from "@/context/tabs" +import { displayName, projectForSession } from "@/pages/layout/helpers" import { createSessionTabs } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" -import { decode64 } from "@/utils/base64" export type CommandPaletteEntry = { id: string @@ -24,6 +25,8 @@ export type CommandPaletteEntry = { path?: string directory?: string sessionID?: string + server?: ServerConnection.Key + project?: LocalProject archived?: number updated?: number } @@ -75,28 +78,25 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) { const command = useCommand() + const global = useGlobal() const language = useLanguage() - const layout = useLayout() const file = useFile() const dialog = useDialog() - const navigate = useNavigate() const serverSDK = useServerSDK()() - const serverSync = useServerSync() - const { params, tabs } = useSessionLayout() + const serverCtx = global.ensureServerCtx(serverSDK.server) + const appTabs = useTabs() + const { tabs: sessionTabs } = useSessionLayout() const openFile = createCommandPaletteFileOpener(props.onOpenFile) const state = { cleanup: undefined as (() => void) | void, committed: false } const filesOnly = () => props.filesOnly?.() ?? false const allowedCommands = createMemo(() => { if (filesOnly()) return [] - return command.options.filter( - (option) => - !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", - ) + return commandPaletteOptions(command.options) }) const commandEntries = createMemo(() => { const category = language.t("palette.group.commands") - return allowedCommands().map((option) => createCommandEntry(option, category)) + return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category)) }) const preferredCommandEntries = createMemo(() => { const all = allowedCommands() @@ -105,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base const category = language.t("palette.group.commands") - return sorted.map((option) => createCommandEntry(option, category)) + return sorted.map((option) => createCommandPaletteCommandEntry(option, category)) }) const tabState = createSessionTabs({ - tabs, + tabs: sessionTabs, pathFromTab: file.pathFromTab, normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), }) @@ -140,36 +140,12 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on .map((path) => createCommandPaletteFileEntry(path, category)) }) - const projectDirectory = createMemo(() => decode64(params.dir) ?? "") - const project = createMemo(() => { - const directory = projectDirectory() - if (!directory) return undefined - return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) - }) - const workspaces = createMemo(() => { - const directory = projectDirectory() - const current = project() - if (!current) return directory ? [directory] : [] - const dirs = [current.worktree, ...(current.sandboxes ?? [])] - if (directory && !dirs.includes(directory)) return [...dirs, directory] - return dirs - }) - const homedir = createMemo(() => serverSync().data.path.home) - const sessions = createSessionEntries({ - workspaces, - label: (directory) => { - const current = project() - const kind = - current && directory === current.worktree - ? language.t("workspace.type.local") - : language.t("workspace.type.sandbox") - const [store] = serverSync().child(directory, { bootstrap: false }) - const home = homedir() - const path = home ? directory.replace(home, "~") : directory - const name = store.vcs?.branch ?? getFilename(directory) - return `${kind} : ${name || path}` - }, - load: (directory) => serverSDK.client.session.list({ directory, roots: true }), + const sessions = createServerSessionEntries({ + server: ServerConnection.key(serverSDK.server), + opened: serverCtx.projects.list, + stored: () => serverCtx.sync.data.project, + load: (search, signal) => + serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) @@ -191,8 +167,17 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on return } if (item.type === "session") { - if (!item.directory || !item.sessionID) return - navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) + if (!item.sessionID || !item.server) return + const directory = item.project?.worktree ?? item.directory + if (directory) { + serverCtx.projects.open(directory) + serverCtx.projects.touch(directory) + } + const tab = appTabs.addSessionTab({ + server: item.server, + sessionId: item.sessionID, + }) + appTabs.select(tab) return } if (!item.path) return @@ -218,7 +203,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on } } -function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { +export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { return { id: "command:" + option.id, type: "command", @@ -230,96 +215,65 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal } } -function createSessionEntries(props: { - workspaces: () => string[] - label: (directory: string) => string - load: (directory: string) => ReturnType +export function createServerSessionEntries(props: { + server: ServerConnection.Key + opened: () => LocalProject[] + stored: () => Project[] + load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }> untitled: () => string category: () => string }) { - const state: { - token: number - inflight: Promise | undefined - cached: CommandPaletteEntry[] | undefined - } = { token: 0, inflight: undefined, cached: undefined } + let abort: AbortController | undefined - return (text: string) => { - if (!text.trim()) { - state.token += 1 - state.inflight = undefined - state.cached = undefined - return [] as CommandPaletteEntry[] + onCleanup(() => abort?.abort()) + + return async (text: string): Promise => { + const search = text.trim() + if (!search) { + abort?.abort() + return [] } - if (state.cached) return state.cached - if (state.inflight) return state.inflight - - const current = state.token - const dirs = props.workspaces() - if (dirs.length === 0) return [] as CommandPaletteEntry[] - - state.inflight = Promise.all( - dirs.map((directory) => { - const description = props.label(directory) - return props - .load(directory) - .then((result) => - (result.data ?? []) - .filter((session) => !!session?.id) - .map((session) => ({ - id: session.id, - title: session.title ?? props.untitled(), - description, - directory, - archived: session.time?.archived, - updated: session.time?.updated, - })), - ) - .catch(() => [] as SessionEntryInput[]) - }), + abort?.abort() + const current = new AbortController() + abort = current + await new Promise((resolve) => { + const timer = setTimeout(resolve, 100) + current.signal.addEventListener( + "abort", + () => { + clearTimeout(timer) + resolve() + }, + { once: true }, + ) + }) + if (current.signal.aborted) return [] + const opened = props.opened() + const openedByID = new Map( + opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])), ) - .then((results) => { - if (state.token !== current) return [] as CommandPaletteEntry[] - const seen = new Set() - const next = results - .flat() - .filter((item) => { - const key = `${item.directory}:${item.id}` - if (seen.has(key)) return false - seen.add(key) - return true - }) - .map((item) => createSessionEntry(item, props.category())) - state.cached = next - return next - }) + const stored = props.stored().map((project) => ({ ...project, expanded: false })) + const storedByID = new Map(stored.map((project) => [project.id, project] as const)) + return props + .load(search, current.signal) + .then((result) => + (result.data ?? []).filter((session) => !session.time.archived).map((session) => { + const project = + projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) + return { + id: `session:${props.server}:${session.id}`, + type: "session" as const, + title: session.title || props.untitled(), + description: project ? displayName(project) : session.project?.name || getFilename(session.directory), + category: props.category(), + directory: session.directory, + sessionID: session.id, + server: props.server, + project, + updated: session.time.updated, + } + }), + ) .catch(() => [] as CommandPaletteEntry[]) - .finally(() => { - state.inflight = undefined - }) - - return state.inflight - } -} - -type SessionEntryInput = { - directory: string - id: string - title: string - description: string - archived?: number - updated?: number -} - -function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry { - return { - id: `session:${input.directory}:${input.id}`, - type: "session", - title: input.title, - description: input.description, - category, - directory: input.directory, - sessionID: input.id, - archived: input.archived, - updated: input.updated, } } diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx index b1cafe1e7a..dba0e214fd 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -5,13 +5,20 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" -import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" -import { formatKeybindParts } from "@/context/command" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" +import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command" +import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { useTabs } from "@/context/tabs" +import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { getRelativeTime } from "@/utils/time" import { + createCommandPaletteCommandEntry, createCommandPaletteFileEntry, createCommandPaletteModel, + createServerSessionEntries, uniqueCommandPaletteEntries, type CommandPaletteEntry, } from "./command-palette" @@ -30,9 +37,6 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) { export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { const palette = createCommandPaletteModel(props) - const [query, setQuery] = createSignal("") - const [active, setActive] = createSignal(0) - const loadItems = async (text: string) => { const q = text.trim() if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] @@ -41,16 +45,115 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v const category = palette.language.t("palette.group.files") return [ ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), - ...nextSessions.filter((entry) => matchesEntry(entry, q)), + ...nextSessions, ...files.map((path) => createCommandPaletteFileEntry(path, category)), ] } - const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] }) + return ( + + ) +} + +export function DialogHomeCommandPaletteV2(props: { + server: ServerConnection.Any + onSelectSession: (entry: CommandPaletteEntry) => void +}) { + const command = useCommand() + const dialog = useDialog() + const global = useGlobal() + const language = useLanguage() + const serverCtx = global.ensureServerCtx(props.server) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const commandEntries = createMemo(() => { + const category = language.t("palette.group.commands") + return commandPaletteOptions(command.options).map((option) => + createCommandPaletteCommandEntry(option, category), + ) + }) + const sessions = createServerSessionEntries({ + server: ServerConnection.key(props.server), + opened: serverCtx.projects.list, + stored: () => serverCtx.sync.data.project, + load: (search, signal) => + serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), + untitled: () => language.t("command.session.new"), + category: () => language.t("command.category.session"), + }) + + const highlight = (item: CommandPaletteEntry | undefined) => { + state.cleanup?.() + state.cleanup = undefined + if (item?.type !== "command") return + state.cleanup = item.option?.onHighlight?.() + } + const select = (item: CommandPaletteEntry | undefined) => { + if (!item) return + state.committed = true + state.cleanup = undefined + dialog.close() + if (item.type === "command") { + item.option?.onSelect?.("palette") + return + } + if (item.type === "session") props.onSelectSession(item) + } + const loadItems = async (text: string) => { + const query = text.trim() + if (!query) return commandEntries().slice(0, 5) + return [ + ...commandEntries().filter((entry) => matchesEntry(entry, query)), + ...(await sessions(query)), + ] + } + + onCleanup(() => { + if (state.committed) return + state.cleanup?.() + }) + + return ( + dialog.close()} + /> + ) +} + +function CommandPaletteView(props: { + placeholder: string + loadItems: (text: string) => CommandPaletteEntry[] | Promise + highlight: (item: CommandPaletteEntry | undefined) => void + select: (item: CommandPaletteEntry | undefined) => void + close: () => void +}) { + const language = useLanguage() + const tabs = useTabs() + const [query, setQuery] = createSignal("") + const [active, setActive] = createSignal(0) + + const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] }) // Render stale results while a new query loads to avoid flashing "Loading" per keystroke. const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) const groupedEntries = createMemo(() => groups(visibleEntries())) const activeEntry = createMemo(() => visibleEntries()[active()]) + const openSessions = createMemo( + () => + new Set( + tabs.store.flatMap((tab) => + tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : [], + ), + ), + ) createEffect(() => { query() @@ -59,7 +162,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v }) createEffect(() => { - palette.highlight(activeEntry()) + props.highlight(activeEntry()) }) let resultsRef: HTMLDivElement | undefined @@ -86,12 +189,12 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v } if (event.key === "Enter") { event.preventDefault() - palette.select(activeEntry()) + props.select(activeEntry()) return } if (event.key === "Escape") { event.preventDefault() - palette.close() + props.close() } } @@ -105,7 +208,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v autocomplete="off" spellcheck={false} appearance="large" - placeholder={palette.language.t("palette.search.placeholder")} + placeholder={props.placeholder} leadingIcon={} onInput={(event) => setQuery(event.currentTarget.value)} onKeyDown={handleKeyDown} @@ -117,7 +220,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v when={visibleEntries().length > 0} fallback={
- {entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")} + {entries.loading ? language.t("common.loading") : language.t("palette.empty")}
} > @@ -132,9 +235,14 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} - onSelect={() => palette.select(item)} + onSelect={() => props.select(item)} /> )} @@ -153,13 +261,19 @@ function PaletteRow(props: { item: CommandPaletteEntry active: boolean language: ReturnType + sessionOpen: boolean onActive: () => void onSelect: () => void }) { + const session = () => + props.item.server && props.item.directory && props.item.sessionID + ? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID } + : undefined + return ( + ) +} + +function ProviderConnectionDialogStory(props) { + onCleanup(mockProviderAuth(props.provider, props.methods)) + const dialog = useDialog() + const controller = useProviderConnectController() + controller.select(props.provider) + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +function renderConnection(provider, methods) { + return () => ( + + + + ) +} + +export default { + title: "App/Dialogs/Connect Provider", + id: "app-dialog-connect-provider", +} + +export const V2 = { + render: () => ( + + + + ), +} + +export const ApiKey = { + render: renderConnection("openrouter", [{ type: "api", label: "API key" }]), +} + +export const OpenCodeZen = { + render: renderConnection("opencode", [{ type: "api", label: "API key" }]), +} + +export const LoginMethods = { + render: renderConnection("openai", [ + { type: "oauth", label: "ChatGPT Pro/Plus (browser)" }, + { type: "oauth", label: "ChatGPT Pro/Plus (headless)" }, + { type: "api", label: "API key" }, + ]), +} diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 6499642b38..fb5cd74cfb 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -9,6 +9,9 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { Tag } from "@opencode-ai/ui/tag" import { TextField } from "@opencode-ai/ui/text-field" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" import { type Accessor, @@ -16,6 +19,8 @@ import { createEffect, createMemo, createResource, + createUniqueId, + For, Match, onCleanup, onMount, @@ -27,6 +32,7 @@ import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" import { popularProviders, useProviders } from "@/hooks/use-providers" import { CustomProviderForm } from "./dialog-custom-provider" @@ -50,32 +56,22 @@ export const DialogConnectProvider: Component<{ const fallback = useProviderConnectController() const controller = props.controller ?? fallback const language = useLanguage() + const settings = useSettings() + const newLayout = settings.general.newLayoutDesigns const reset = controller.back const back = { current: reset } + let focusHost: HTMLDivElement | undefined + const holdFocus = () => focusHost?.focus({ preventScroll: true }) const select = (provider?: string) => { back.current = reset controller.select(provider) } - return ( - - back.current()} - aria-label={language.t("common.goBack")} - /> - - } - > + function Content() { + return ( - + {(provider) => ( @@ -88,14 +84,76 @@ export const DialogConnectProvider: Component<{ )} - + - + ) + } + + return ( + + back.current()} + aria-label={language.t("common.goBack")} + /> + + } + > + + + } + > + + + {language.t("command.provider.connect")}} + > + + + + +
+ +
+
+
+ ) } -function ProviderPicker(props: { directory?: Accessor; onSelect: (provider: string) => void }) { +function ProviderPicker(props: { + directory?: Accessor + onSelect: (provider: string) => void + onPrepare?: () => void +}) { + const settings = useSettings() + if (settings.general.newLayoutDesigns()) + return const providers = useProviders(props.directory) const language = useLanguage() const popularGroup = () => language.t("dialog.provider.group.popular") @@ -163,6 +221,171 @@ function ProviderPicker(props: { directory?: Accessor; onSel ) } +function ProviderPickerV2(props: { + directory?: Accessor + onSelect: (provider: string) => void + onPrepare?: () => void +}) { + const providers = useProviders(props.directory) + const language = useLanguage() + const serverSync = useServerSync() + const serverSDK = useServerSDK() + const [store, setStore] = createStore({ + filter: "", + active: undefined as string | undefined, + connecting: undefined as string | undefined, + }) + const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"] + const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") }) + const all = createMemo(() => { + language.locale() + const query = store.filter.trim().toLowerCase() + const values = [custom(), ...providers.all().values()] + if (!query) return values + return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) + }) + const popular = createMemo(() => + all() + .filter((provider) => featured.includes(provider.id)) + .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)), + ) + const other = createMemo(() => + all() + .filter((provider) => !featured.includes(provider.id)) + .sort((a, b) => { + if (a.id === CUSTOM_ID) return -1 + if (b.id === CUSTOM_ID) return 1 + return a.name.localeCompare(b.name) + }), + ) + const rows = createMemo(() => [...popular(), ...other()]) + let picker: HTMLDivElement | undefined + let search: HTMLInputElement | undefined + + onMount(() => search?.focus({ preventScroll: true })) + + const connect = (provider: string) => { + props.onPrepare?.() + if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) { + props.onSelect(provider) + return + } + if (store.connecting) return + setStore("connecting", provider) + void serverSDK() + .client.provider.auth() + .then((response) => { + serverSync().set("provider_auth", response.data ?? {}) + props.onSelect(provider) + }) + .catch(() => props.onSelect(provider)) + } + + const move = (event: KeyboardEvent, direction: number) => { + const items = rows() + if (items.length === 0) return + const index = items.findIndex((provider) => provider.id === store.active) + const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length + setStore("active", items[next].id) + picker + ?.querySelector(`[data-provider-id="${CSS.escape(items[next].id)}"]`) + ?.focus({ preventScroll: true }) + event.preventDefault() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowDown") return move(event, 1) + if (event.key === "ArrowUp") return move(event, -1) + if (event.key !== "Enter" || !store.active) return + connect(store.active) + event.preventDefault() + } + + return ( +
+
+ } + placeholder={language.t("dialog.provider.search.placeholder")} + value={store.filter} + onInput={(event) => { + setStore({ filter: event.currentTarget.value, active: undefined }) + }} + /> +
+
+
+ + {(group) => ( + 0}> +
+
+ {group.title} +
+ + {(provider) => ( + + )} + +
+
+ )} +
+ +
+ {language.t("dialog.provider.empty")} +
+
+
+
+
+
+ ) +} + function ProviderConnection(props: { provider: string directory?: Accessor @@ -173,6 +396,8 @@ function ProviderConnection(props: { const serverSync = useServerSync() const serverSDK = useServerSDK() const language = useLanguage() + const settings = useSettings() + const newLayout = settings.general.newLayoutDesigns const providers = useProviders(props.directory) const alive = { value: true } @@ -207,11 +432,19 @@ function ProviderConnection(props: { ) const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) + const cachedMethods = serverSync().data.provider_auth[props.provider] + const directMethod = + cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined const [store, setStore] = createStore({ - methodIndex: undefined as undefined | number, + methodIndex: directMethod as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, promptInputs: undefined as undefined | Record, - state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", + state: (directMethod === undefined ? "pending" : undefined) as + | undefined + | "pending" + | "complete" + | "error" + | "prompt", error: undefined as string | undefined, }) @@ -279,6 +512,16 @@ function ProviderConnection(props: { return value.label ?? "" } + const methodDetails = (value?: { type?: string; label?: string }) => { + const label = methodLabel(value) + const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i) + const hint = suffix?.[1] + return { + label: suffix ? label.slice(0, -suffix[0].length) : label, + hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined, + } + } + function formatError(value: unknown, fallback: string): string { if (value && typeof value === "object" && "data" in value) { const data = (value as { data?: { message?: unknown } }).data @@ -519,6 +762,37 @@ function ProviderConnection(props: { props.setBack(goBack) function MethodSelection() { + if (newLayout()) + return ( +
+
+ {language.t("provider.connect.selectMethod", { provider: provider().name })} +
+
+ + {(item, index) => { + const details = () => methodDetails(item) + return ( + + ) + }} + +
+
+ ) + return ( <>
@@ -552,11 +826,18 @@ function ProviderConnection(props: { } function ApiAuthView() { + let apiKey: HTMLInputElement | undefined + const errorID = createUniqueId() const [formStore, setFormStore] = createStore({ value: "", error: undefined as string | undefined, }) + onMount(() => { + if (!newLayout()) return + apiKey?.focus({ preventScroll: true }) + }) + async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -581,6 +862,58 @@ function ProviderConnection(props: { await complete() } + if (newLayout()) + return ( +
+ +
+
{language.t("provider.connect.opencodeZen.line1")}
+
{language.t("provider.connect.opencodeZen.line2")}
+
+ {language.t("provider.connect.opencodeZen.visit.prefix")} + + {language.t("provider.connect.opencodeZen.visit.link")} + + {language.t("provider.connect.opencodeZen.visit.suffix")} +
+
+
+
+ + + {(error) => ( + + )} + + + {language.t("common.continue")} + +
+
+ ) + return (
@@ -605,7 +938,8 @@ function ProviderConnection(props: {
{ + if (!newLayout()) return + codeInput?.focus({ preventScroll: true }) + }) + async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -657,6 +998,46 @@ function ProviderConnection(props: { setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) } + if (newLayout()) + return ( +
+
+ {language.t("provider.connect.oauth.code.visit.prefix")} + + {language.t("provider.connect.oauth.code.visit.link")} + + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} +
+ + + + {(error) => ( + + )} + + + {language.t("common.continue")} + + +
+ ) + return (
@@ -666,7 +1047,8 @@ function ProviderConnection(props: {
-
- -
+
+
+ +
{language.t("provider.connect.title.anthropicProMax")} @@ -750,8 +1149,16 @@ function ProviderConnection(props: {
-
-
+
+
diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 363a2e390a..9e04cd83ad 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) { ) } -export function CustomProviderForm() { +export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() @@ -192,7 +192,7 @@ export function CustomProviderForm() {
({ + id: name.toLowerCase().replaceAll(" ", "-"), + name, + provider: { id: "opencode", name: "OpenCode" }, + cost: { input: 0, output: 0 }, + limit: { context: 128_000 }, + capabilities: { + reasoning: index !== 5, + input: { text: true, image: false, audio: false, video: false, pdf: false }, + }, + })) + const [current, setCurrent] = createSignal(models[2]) + const model = { + list: () => models, + current, + set(value) { + setCurrent(models.find((item) => item.id === value?.modelID)) + }, + } + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +export default { + title: "App/Dialogs/Select Model", + id: "app-dialog-select-model", +} + +export const WithoutProviders = { + render: () => , +} diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx index 0ce9f18c16..de86485ef9 100644 --- a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx @@ -1,23 +1,26 @@ import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { ScrollView } from "@opencode-ai/ui/scroll-view" import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useTheme } from "@opencode-ai/ui/theme" import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" import { useLocal } from "@/context/local" -import { popularProviders, useProviders } from "@/hooks/use-providers" +import { useProviders } from "@/hooks/use-providers" import { decode64 } from "@/utils/base64" import { useLanguage } from "@/context/language" import { ModelTooltip } from "./model-tooltip" type ModelState = ReturnType["model"] +const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"] +const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "") export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => { const local = useLocal() const model = props.model ?? local.model const dialog = useDialog() + const theme = useTheme() const directory = () => decode64(local.slug()) const providers = useProviders(directory) const language = useLanguage() @@ -28,6 +31,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro }) const isFree = (item: ReturnType[number]) => item.provider.id === "opencode" && (!item.cost || item.cost.input === 0) + const freeModels = createMemo(() => model.list().filter(isFree)) const openProviders = (provider?: string) => { void import("./dialog-connect-provider").then((x) => { @@ -62,111 +66,109 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro }) return ( - + {language.t("dialog.model.select.title")} -
- - -
-
-
-
- {language.t("dialog.model.unpaid.freeModels.title")} -
+ +
+
+
+
+ {language.t("dialog.model.unpaid.freeModels.title")}
- - {(item) => ( - } - > - - - )} -
- -
-
-
-
- {language.t("dialog.model.unpaid.addMore.title")} -
-
-
- { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) { - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - } - return a.name.localeCompare(b.name) - })} - > - {(provider) => ( - - )} - + + {(item) => ( + + } + > + + )} + +
+ +
+
+
+
+ {language.t("dialog.model.unpaid.addMore.title")}
+
+ featuredProviders.includes(provider.id)) + .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))} + > + {(provider) => ( + + )} + + +
- +
) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index e1f6085454..1a2d66ecc2 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -102,6 +102,7 @@ export const dict = { "dialog.provider.empty": "No providers found", "dialog.provider.group.popular": "Popular", "dialog.provider.group.other": "Other", + "dialog.provider.custom.label": "Custom OpenAI-compatible provider", "dialog.provider.tag.recommended": "Recommended", "dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more", "dialog.provider.opencode.tagline": "Reliable optimized models", @@ -122,6 +123,7 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", "dialog.model.unpaid.addMore.title": "Add more models from popular providers", + "dialog.model.unpaid.viewMoreProviders": "See 70+ more providers", "dialog.provider.viewAll": "Show more providers", diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index c2668e1567..cd48c09fa1 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -29,7 +29,7 @@ import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" import { useTitlebarRightMount } from "@/components/titlebar" import { useCommand } from "@/context/command" import { useProviders } from "@/hooks/use-providers" -import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog" +import { useSettingsCommand } from "@/components/settings-dialog" import { Persist, persisted } from "@/utils/persist" import createPresence from "solid-presence" import { useLocal } from "@/context/local" @@ -55,7 +55,11 @@ export default function NewSessionPage() { const dialog = useDialog() const command = useCommand() const providers = useProviders(() => sdk().directory) - const openProviderSettings = useSettingsDialog("providers") + const openProviders = () => { + void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { + void dialog.show(() => sdk().directory} />) + }) + } useSettingsCommand() const route = useSessionKey() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() @@ -216,7 +220,7 @@ export default function NewSessionPage() { serverSync().child(sdk().directory)[0].provider_ready} connected={() => providers.paid().length > 0} - openProviders={openProviderSettings} + openProviders={openProviders} />
diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts index f592863e56..3fb9dae29a 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -47,6 +47,8 @@ export default defineMain({ { find: /^@\/context\/language$/, replacement: path.resolve(mocks, "app/context/language.ts") }, { find: /^@\/context\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") }, { find: /^@\/context\/global-sync$/, replacement: path.resolve(mocks, "app/context/global-sync.ts") }, + { find: /^@\/context\/server-sync$/, replacement: path.resolve(mocks, "app/context/server-sync.ts") }, + { find: /^@\/context\/server-sdk$/, replacement: path.resolve(mocks, "app/context/server-sdk.ts") }, { find: /^@\/hooks\/use-providers$/, replacement: path.resolve(mocks, "app/hooks/use-providers.ts") }, { find: /^@\/components\/dialog-select-model$/, diff --git a/packages/storybook/.storybook/mocks/app/context/language.ts b/packages/storybook/.storybook/mocks/app/context/language.ts index 2510acc6f9..c851f40755 100644 --- a/packages/storybook/.storybook/mocks/app/context/language.ts +++ b/packages/storybook/.storybook/mocks/app/context/language.ts @@ -22,6 +22,69 @@ const dict: Record = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "dialog.model.select.title": "Select model", + "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", + "dialog.model.unpaid.addMore.title": "Add more models from popular providers", + "dialog.model.unpaid.viewMoreProviders": "See 70+ more providers", + "dialog.provider.opencode.tagline": "Reliable optimized models", + "dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone", + "dialog.provider.custom.label": "Custom OpenAI-compatible provider", + "dialog.provider.search.placeholder": "Search providers", + "dialog.provider.empty": "No providers found", + "dialog.provider.group.popular": "Popular", + "dialog.provider.group.other": "Other", + "dialog.provider.tag.recommended": "Recommended", + "settings.providers.tag.custom": "Custom", + "command.provider.connect": "Connect provider", + "provider.connect.title": "Connect {{provider}}", + "provider.connect.selectMethod": "Select login method for {{provider}}.", + "provider.connect.method.apiKey": "API key", + "provider.connect.apiKey.description": + "Enter your {{provider}} API key to connect your account and use {{provider}} models in OpenCode.", + "provider.connect.apiKey.label": "{{provider}} API key", + "provider.connect.apiKey.placeholder": "API key", + "provider.connect.apiKey.required": "API key is required", + "provider.connect.opencodeZen.line1": + "OpenCode Zen gives you access to a curated set of reliable optimized models for coding agents.", + "provider.connect.opencodeZen.line2": + "With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.", + "provider.connect.opencodeZen.visit.prefix": "Visit ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " to collect your API key.", + "provider.connect.oauth.code.visit.prefix": "Visit ", + "provider.connect.oauth.code.visit.link": "this link", + "provider.connect.oauth.code.visit.suffix": + " to collect your authorization code to connect your account and use {{provider}} models in OpenCode.", + "provider.connect.oauth.code.label": "{{method}} authorization code", + "provider.connect.oauth.code.placeholder": "Authorization code", + "provider.connect.oauth.code.required": "Authorization code is required", + "provider.connect.oauth.code.invalid": "Invalid authorization code", + "provider.connect.oauth.auto.visit.prefix": "Visit ", + "provider.connect.oauth.auto.visit.link": "this link", + "provider.connect.oauth.auto.visit.suffix": + " and enter the code below to connect your account and use {{provider}} models in OpenCode.", + "provider.connect.oauth.auto.confirmationCode": "Confirmation code", + "provider.connect.status.inProgress": "Authorization in progress...", + "provider.connect.status.waiting": "Waiting for authorization...", + "provider.connect.status.failed": "Authorization failed: {{error}}", + "provider.connect.toast.connected.title": "{{provider}} connected", + "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", + "common.continue": "Continue", + "model.tag.free": "Free", + "model.tag.latest": "Latest", + "model.input.text": "text", + "model.input.image": "image", + "model.input.audio": "audio", + "model.input.video": "video", + "model.input.pdf": "pdf", + "model.tooltip.context.label": "Context", + "model.tooltip.inputs": "Inputs", + "model.tooltip.model": "Model", + "model.tooltip.provider": "Provider", + "model.tooltip.reasoning": "Reasoning", + "model.tooltip.reasoning.allowed": "Allows reasoning", + "model.tooltip.reasoning.none": "No reasoning", + "common.close": "Close", + "common.goBack": "Go back", "common.default": "Default", "common.key.esc": "Esc", "command.category.file": "File", @@ -73,6 +136,7 @@ function render(template: string, params?: Record) { export function useLanguage() { return { locale: () => "en" as const, + intl: () => "en-US", t(key: string, params?: Record) { return render(dict[key] ?? key, params) }, diff --git a/packages/storybook/.storybook/mocks/app/context/server-sdk.ts b/packages/storybook/.storybook/mocks/app/context/server-sdk.ts new file mode 100644 index 0000000000..d0026dcff8 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/server-sdk.ts @@ -0,0 +1,47 @@ +const providers = [ + "opencode", + "opencode-go", + "anthropic", + "openai", + "google", + "openrouter", + "vercel", + "github-copilot", + "302ai", + "abacus", + "abliteration", + "alibaba", + "alibaba-cn", + "alibaba-coding-plan", +] + +const client = { + provider: { + auth: async () => ({ + data: Object.fromEntries(providers.map((provider) => [provider, [{ type: "api", label: "API key" }]])), + }), + oauth: { + authorize: async (input: { method?: number }) => ({ + data: { + url: "https://example.com/oauth", + method: input.method === 1 ? ("code" as const) : ("auto" as const), + instructions: input.method === 1 ? "Paste the authorization code" : "Confirmation code: ABCD-EFGH", + }, + }), + callback: async (input: { method?: number }) => { + if (input.method === 0) return new Promise(() => {}) + return { data: undefined } + }, + }, + }, + auth: { + set: async () => ({ data: true }), + }, + global: { + dispose: async () => ({ data: true }), + }, +} + +export function useServerSDK() { + return () => ({ client }) +} diff --git a/packages/storybook/.storybook/mocks/app/context/server-sync.ts b/packages/storybook/.storybook/mocks/app/context/server-sync.ts new file mode 100644 index 0000000000..1e835afdb8 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/server-sync.ts @@ -0,0 +1,33 @@ +import type { ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" + +const data = { + provider: { + all: new Map(), + connected: [], + default: {}, + }, + provider_auth: {} as Record, + config: { disabled_providers: [] as string[] }, +} + +export function mockProviderAuth(provider: string, methods: ProviderAuthMethod[]) { + const previous = data.provider_auth[provider] + data.provider_auth[provider] = methods + return () => { + if (previous) { + data.provider_auth[provider] = previous + return + } + delete data.provider_auth[provider] + } +} + +export function useServerSync() { + return () => ({ + data, + set(key: "provider_auth", value: typeof data.provider_auth) { + data[key] = value + }, + updateConfig: async () => {}, + }) +} diff --git a/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts b/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts index caa5e759a0..64ada6421b 100644 --- a/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts +++ b/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts @@ -13,6 +13,7 @@ export const popularProviders = [ const provider = { id: "anthropic", + name: "Anthropic", models: { [model_id]: { id: model_id, @@ -23,12 +24,33 @@ const provider = { }, } +const popular = [ + { id: "opencode", name: "OpenCode Zen", models: {} }, + { id: "opencode-go", name: "OpenCode Go", models: {} }, + { id: "openai", name: "OpenAI", models: {} }, + provider, + { id: "google", name: "Google", models: {} }, + { id: "github-copilot", name: "GitHub Copilot", models: {} }, +] + +const catalog = [ + ...popular, + { id: "openrouter", name: "OpenRouter", models: {} }, + { id: "vercel", name: "Vercel AI Gateway", models: {} }, + { id: "302ai", name: "302.AI", models: {} }, + { id: "abacus", name: "Abacus", models: {} }, + { id: "abliteration", name: "abliteration.ai", models: {} }, + { id: "alibaba", name: "Alibaba", models: {} }, + { id: "alibaba-cn", name: "Alibaba (China)", models: {} }, + { id: "alibaba-coding-plan", name: "Alibaba Coding Plan", models: {} }, +] + export function useProviders() { return { - all: () => [provider], + all: () => new Map(catalog.map((item) => [item.id, item])), default: () => ({ anthropic: model_id }), connected: () => [provider], paid: () => [provider], - popular: () => [provider], + popular: () => popular, } } diff --git a/packages/ui/src/v2/components/dialog-v2.tsx b/packages/ui/src/v2/components/dialog-v2.tsx index 48a6f53712..b9dd2521e2 100644 --- a/packages/ui/src/v2/components/dialog-v2.tsx +++ b/packages/ui/src/v2/components/dialog-v2.tsx @@ -101,7 +101,7 @@ export function Dialog(props: DialogProps) { const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null if (autofocusEl) { e.preventDefault() - autofocusEl.focus() + autofocusEl.focus({ preventScroll: true }) } }} > From aca52ba1987167e41066db5194c3c26dc1e1e7bf Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Jul 2026 08:55:53 +0000 Subject: [PATCH 021/271] chore: generate --- .../src/components/dialog-connect-provider.tsx | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index fb5cd74cfb..4c58857249 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1121,18 +1121,10 @@ function ProviderConnection(props: { return (
-
+
-
+
Date: Fri, 17 Jul 2026 15:08:47 +0530 Subject: [PATCH 022/271] feat(app): update review panel tooltip to v2 (#37095) --- .../session/session-sortable-tab-v2.tsx | 18 ++++++++---- .../src/pages/session/session-side-panel.tsx | 29 ++++++++++++++----- packages/ui/src/components/tabs.css | 6 +++- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/session/session-sortable-tab-v2.tsx b/packages/app/src/components/session/session-sortable-tab-v2.tsx index 158eba9f0b..e109e5d2ec 100644 --- a/packages/app/src/components/session/session-sortable-tab-v2.tsx +++ b/packages/app/src/components/session/session-sortable-tab-v2.tsx @@ -2,7 +2,8 @@ import { createMemo, Show } from "solid-js" import type { JSX } from "solid-js" import { useSortable } from "@dnd-kit/solid/sortable" import { IconButton } from "@opencode-ai/ui/icon-button" -import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { Tabs } from "@opencode-ai/ui/tabs" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" @@ -19,6 +20,7 @@ export function SortableTabV2(props: { const file = useFile() const language = useLanguage() const command = useCommand() + const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) const sortable = useSortable({ get id() { return props.tab @@ -39,9 +41,15 @@ export function SortableTabV2(props: { + {language.t("common.closeTab")} + 0}> + + + + } placement="bottom" gutter={10} > @@ -52,7 +60,7 @@ export function SortableTabV2(props: { onClick={() => props.onTabClose(props.tab)} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 184fa684ec..c998822e58 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -237,6 +237,7 @@ export function SessionSidePanel(props: { return active !== "review" && active !== "context" && active !== "empty" }) const openFileKeybind = createMemo(() => command.keybindParts("file.open")) + const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, }) @@ -570,9 +571,15 @@ export function SessionSidePanel(props: { + {language.t("common.closeTab")} + 0}> + + + + } placement="bottom" gutter={10} > @@ -583,7 +590,7 @@ export function SessionSidePanel(props: { onClick={() => tabs().close("context")} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => tabs().close("context")} @@ -611,9 +618,15 @@ export function SessionSidePanel(props: { + {language.t("common.closeTab")} + 0}> + + + + } placement="bottom" gutter={10} > @@ -624,7 +637,7 @@ export function SessionSidePanel(props: { onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 0978ec5ad2..b92347b59c 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -723,7 +723,11 @@ body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal } &:has([data-slot="tabs-trigger-close-button"]) { - padding-right: 10px; + padding-right: 8px; + + [data-slot="tabs-trigger-close-button"] { + margin-left: 2px; + } } &:has([data-selected]) { From ed926be253848402f9fb007b712a3777994070e3 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:10:42 +0530 Subject: [PATCH 023/271] feat(app): review panel reactivity improvements (#37089) --- .../src/pages/session/session-side-panel.tsx | 2 +- .../session-review-file-preview-v2.tsx | 4 +- .../src/v2/components/session-review-v2.css | 32 ++++++++++- .../src/v2/components/session-review-v2.tsx | 1 + packages/ui/src/components/resize-handle.tsx | 26 +++++++-- packages/ui/src/components/tabs.css | 56 +++++++++++++++++++ 6 files changed, 112 insertions(+), 9 deletions(-) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index c998822e58..0a741ef98b 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -551,7 +551,7 @@ export function SessionSidePanel(props: { > {(toggle) => ( -
+
{toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
)} diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index 3e7bd83051..9f42cd90fe 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -263,7 +263,9 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop {getDirectory(props.file)}
- +
+ +
{ diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index dccc5e2538..364597e7a0 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -149,7 +149,7 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 8px 12px; + padding: 10px 12px; padding-left: 8px; flex-shrink: 0; border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); @@ -273,9 +273,37 @@ flex: 1; } -[data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] [data-component="diff-changes"] { +[data-component="session-review-v2"] [data-slot="session-review-v2-file-diff"] { + position: relative; flex-shrink: 0; margin-left: auto; + background-color: var(--v2-background-bg-base); + + &::before { + content: ""; + position: absolute; + top: -8px; + bottom: -8px; + right: 100%; + width: 16px; + z-index: 1; + pointer-events: none; + background: linear-gradient(90deg, transparent, var(--v2-background-bg-base)); + } + + &::after { + content: ""; + position: absolute; + inset: -8px -16px -8px 0; + z-index: 0; + pointer-events: none; + background-color: var(--v2-background-bg-base); + } + + [data-component="diff-changes"] { + position: relative; + z-index: 1; + } } [data-component="session-review-v2"] [data-slot="session-review-v2-file-status"] { diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index 811df52e1a..9cd7f370a1 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -139,6 +139,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { min={minWidth()} max={maxWidth()} onResize={(next) => props.onWidthChange?.(next)} + onDblClick={() => props.onWidthChange?.(SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT)} />
diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx index d7774a684b..bc89b257b1 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -8,6 +8,8 @@ export interface ResizeHandleProps extends Omit void onCollapse?: () => void + /** Called while dragging when size crosses `collapseThreshold`. */ + onCollapseChange?: (collapsed: boolean) => void collapseThreshold?: number } @@ -20,17 +22,26 @@ export function ResizeHandle(props: ResizeHandleProps) { "max", "onResize", "onCollapse", + "onCollapseChange", "collapseThreshold", "class", "classList", ]) const handleMouseDown = (e: MouseEvent) => { + if (e.detail > 1) return e.preventDefault() const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end") const start = local.direction === "horizontal" ? e.clientX : e.clientY const startSize = local.size + const min = local.min + const max = local.max + const threshold = local.collapseThreshold ?? 0 + const onResize = local.onResize + const onCollapse = local.onCollapse + const onCollapseChange = local.onCollapseChange let current = startSize + let collapsed = false document.body.style.userSelect = "none" document.body.style.overflow = "hidden" @@ -46,8 +57,12 @@ export function ResizeHandle(props: ResizeHandleProps) { ? start - pos : pos - start current = startSize + delta - const clamped = Math.min(local.max, Math.max(local.min, current)) - local.onResize(clamped) + const nextCollapsed = threshold > 0 && current < threshold + if (nextCollapsed !== collapsed) { + collapsed = nextCollapsed + onCollapseChange?.(collapsed) + } + onResize(Math.min(max, Math.max(min, current))) } const onMouseUp = () => { @@ -56,10 +71,11 @@ export function ResizeHandle(props: ResizeHandleProps) { document.removeEventListener("mousemove", onMouseMove) document.removeEventListener("mouseup", onMouseUp) - const threshold = local.collapseThreshold ?? 0 - if (local.onCollapse && threshold > 0 && current < threshold) { - local.onCollapse() + if (collapsed) { + onCollapse?.() + return } + onCollapseChange?.(false) } document.addEventListener("mousemove", onMouseMove) diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index b92347b59c..ef630e9547 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -793,6 +793,62 @@ body[data-new-layout] [data-slot="tabs-list"] > .sticky { padding-right: 0; + + [data-component="icon-button-v2"] { + position: relative; + + &::after { + content: ""; + position: absolute; + left: 100%; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 28px; + background-color: var(--v2-background-bg-base); + pointer-events: none; + } + } +} + +body[data-new-layout] + #review-panel + [data-component="tabs"] + .session-review-v2-tabs-bar + [data-slot="tabs-list"] + > .session-review-v2-sidebar-toggle-slot.sticky { + padding-right: 0; + + &::before { + content: ""; + position: absolute; + top: 50%; + bottom: auto; + left: auto; + right: 100%; + transform: translateY(-50%); + width: 12px; + height: 28px; + background-color: var(--v2-background-bg-base); + background-image: none; + pointer-events: none; + } + + &::after { + content: ""; + position: absolute; + top: 50%; + left: 100%; + transform: translateY(-50%); + width: 8px; + height: 28px; + pointer-events: none; + background: linear-gradient(90deg, var(--v2-background-bg-base), transparent); + } + + [data-component="icon-button-v2"]::after { + display: none; + } } body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot { From c0a258b22ac39e781ff8ff79a6f3b1ab41960fea Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:02:19 +0800 Subject: [PATCH 024/271] feat(session-ui): rewrite v2 prompt input (#37102) --- bun.lock | 1 + .../regression/prompt-thinking-level.spec.ts | 9 +- .../app/e2e/smoke/session-timeline.spec.ts | 2 +- packages/app/src/app.tsx | 12 +- .../app/src/components/prompt-input-v2.tsx | 585 ++++++++ .../src/components/prompt-input.stories.tsx | 12 +- packages/app/src/components/prompt-input.tsx | 1209 +++++------------ .../components/prompt-input/attachments.ts | 2 +- .../src/components/prompt-input/contracts.ts | 57 + .../components/prompt-input/history-store.ts | 51 + .../components/prompt-input/placeholder.ts | 5 + .../components/prompt-input/submit.test.ts | 9 +- .../prompt-input/transient-state.ts | 3 - .../components/prompt-project-selector.tsx | 8 +- packages/app/src/context/prompt-state.ts | 3 +- packages/app/src/context/prompt.tsx | 24 +- packages/app/src/pages/new-session.tsx | 157 +-- packages/app/src/pages/session.tsx | 195 +-- .../composer/session-composer-controls.ts | 5 +- .../composer/todo-panel-motion.stories.tsx | 1 - .../prompt-transient-state.test.ts | 2 - packages/session-ui/package.json | 6 +- .../v2/components/prompt-input/attachments.ts | 267 ++++ .../src/v2/components/prompt-input/index.tsx | 686 ++++++++++ .../v2/components/prompt-input/interaction.ts | 463 +++++++ .../components/prompt-input/machine.test.ts | 133 ++ .../src/v2/components/prompt-input/machine.ts | 266 ++++ .../prompt-input/prompt-input.stories.tsx | 221 +++ .../v2/components/prompt-input/store.test.ts | 94 ++ .../src/v2/components/prompt-input/store.ts | 114 ++ .../src/v2/components/prompt-input/types.ts | 106 ++ .../.storybook/mocks/app/context/command.ts | 3 + .../.storybook/mocks/app/context/language.ts | 2 +- .../.storybook/mocks/app/context/sync.ts | 2 + packages/storybook/package.json | 1 + 35 files changed, 3621 insertions(+), 1095 deletions(-) create mode 100644 packages/app/src/components/prompt-input-v2.tsx create mode 100644 packages/app/src/components/prompt-input/contracts.ts create mode 100644 packages/app/src/components/prompt-input/history-store.ts create mode 100644 packages/session-ui/src/v2/components/prompt-input/attachments.ts create mode 100644 packages/session-ui/src/v2/components/prompt-input/index.tsx create mode 100644 packages/session-ui/src/v2/components/prompt-input/interaction.ts create mode 100644 packages/session-ui/src/v2/components/prompt-input/machine.test.ts create mode 100644 packages/session-ui/src/v2/components/prompt-input/machine.ts create mode 100644 packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx create mode 100644 packages/session-ui/src/v2/components/prompt-input/store.test.ts create mode 100644 packages/session-ui/src/v2/components/prompt-input/store.ts create mode 100644 packages/session-ui/src/v2/components/prompt-input/types.ts diff --git a/bun.lock b/bun.lock index 3d8722b152..5a28b7cf74 100644 --- a/bun.lock +++ b/bun.lock @@ -937,6 +937,7 @@ "@types/node": "catalog:", "@types/react": "18.0.25", "react": "18.2.0", + "react-dom": "18.2.0", "solid-js": "catalog:", "storybook": "^10.2.13", "storybook-solidjs-vite": "^10.0.9", diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts index 4219699f28..9ddb484709 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -54,18 +54,15 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => { }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - const composer = page.locator('[data-component="session-composer"]') + const composer = page.locator('[data-component="prompt-input-v2"]') const input = composer.locator('[data-component="prompt-input"]') - const control = composer.locator('[data-component="prompt-variant-control"]') + const control = composer.locator('button[title="Choose model variant"]') await expectAppVisible(composer) await idleComposer(page) - await expect(control).toBeHidden() - - await composer.hover() await expect(control).toBeVisible() - await control.locator('[data-action="prompt-model-variant"]').click() + await control.click() const high = page.getByRole("menuitemradio", { name: "high" }) await expect(high).toBeVisible() await page.mouse.move(0, 0) diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index a73cc0ccdd..bdf3f55bdc 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin } async function expectSessionReady(page: Page) { - await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) + await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5592e2f4ea..b4496ba7de 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -9,7 +9,16 @@ import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" -import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router" +import { + type BaseRouterProps, + Navigate, + Route, + Router, + useLocation, + useNavigate, + useParams, + useSearchParams, +} from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Effect } from "effect" import { base64Encode } from "@opencode-ai/core/util/encode" @@ -29,6 +38,7 @@ import { Show, } from "solid-js" import { Dynamic } from "solid-js/web" +import { makeEventListener } from "@solid-primitives/event-listener" import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx new file mode 100644 index 0000000000..3f1bf9222f --- /dev/null +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -0,0 +1,585 @@ +import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import { createEffect, createMemo, on, Show } from "solid-js" +import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" +import type { PromptInputProps } from "@/components/prompt-input/contracts" +import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history" +import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store" +import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder" +import { createPromptSubmit } from "@/components/prompt-input/submit" +import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" +import { useComments } from "@/context/comments" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { usePermission } from "@/context/permission" +import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" +import { usePlatform } from "@/context/platform" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { createSessionTabs } from "@/pages/session/helpers" +import { showToast } from "@/utils/toast" +import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" +import { + createPromptInputV2Controller, + createPromptInputV2State, + type PromptInputV2Interaction, +} from "@opencode-ai/session-ui/v2/prompt-input/interaction" + +export type PromptInputV2ComposerProps = { + class?: string + controller: PromptInputV2ComposerController + edit?: PromptInputProps["edit"] + onEditLoaded?: PromptInputProps["onEditLoaded"] +} + +export type PromptInputV2ControllerProps = Omit +export type PromptInputV2ComposerController = PromptInputV2Interaction & { + readonly model: PromptInputProps["controls"]["model"] +} + +export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { + const dialog = useDialog() + const command = useCommand() + const language = useLanguage() + + useCommands(props) + useEditHandler(props) + + return ( +
+ + dialog.show(() => ) + } + /> + } + /> +
+ ) +} + +const useEditHandler = (props: PromptInputV2ComposerProps) => { + const prompt = usePrompt() + + createEffect( + on( + () => props.edit?.id, + (id) => { + const edit = props.edit + if (!id || !edit) return + prompt.context.items().forEach((item) => prompt.context.remove(item.key)) + edit.context.forEach((item) => + prompt.context.add({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }), + ) + props.controller.dispatch({ type: "mode.normal" }) + props.controller.resetHistory() + prompt.set(edit.prompt, promptLength(edit.prompt)) + props.controller.restoreFocus() + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) +} + +const useCommands = (props: PromptInputV2ComposerProps) => { + const command = useCommand() + const language = useLanguage() + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: props.controller.state.mode !== "normal", + onSelect: () => props.controller.attach(), + }, + { + id: "prompt.mode.shell", + title: language.t("command.prompt.mode.shell"), + category: language.t("command.category.session"), + keybind: "mod+shift+x", + disabled: props.controller.state.mode === "shell", + onSelect: () => props.controller.dispatch({ type: "mode.shell" }), + }, + { + id: "prompt.mode.normal", + title: language.t("command.prompt.mode.normal"), + category: language.t("command.category.session"), + keybind: "mod+shift+e", + disabled: props.controller.state.mode === "normal", + onSelect: () => props.controller.dispatch({ type: "mode.normal" }), + }, + ]) +} + +export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { + const sdk = useSDK() + const sync = useSync() + const files = useFile() + const layout = useLayout() + const comments = useComments() + const dialog = useDialog() + const command = useCommand() + const permission = usePermission() + const language = useLanguage() + const platform = usePlatform() + const prompt = props.state ?? usePrompt() + let editor: HTMLDivElement | undefined + + const interaction = createPromptInputV2State() + const mode = () => interaction[0].mode + const history = props.history ?? createPersistedPromptInputHistory() + const tabs = () => props.controls.session.tabs + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: files.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), + }).activeFileTab + const recent = createMemo(() => { + const all = tabs().all() + const active = activeFileTab() + const order = active ? [active, ...all.filter((tab) => tab !== active)] : all + return order.reduce((result, tab) => { + const path = files.pathFromTab(tab) + if (!path || result.includes(path)) return result + return [...result, path] + }, []) + }) + const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) + const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) + const attachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), + ) + const commentCount = createMemo(() => { + if (mode() === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + const blank = createMemo(() => { + const text = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0 + }) + const stopping = createMemo(() => working() && blank()) + const placeholder = createMemo(() => + promptPlaceholder({ + mode: mode(), + commentCount: commentCount(), + example: mode() === "shell" ? "git status" : "", + suggest: false, + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder()) + + const historyComments = () => { + const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) + return prompt.context.items().flatMap((item) => { + const comment = item.comment?.trim() + if (!comment) return [] + const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined + const nextSelection = + selection ?? + (item.selection + ? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange) + : undefined) + if (!nextSelection) return [] + return [ + { + id: item.commentID ?? item.key, + path: item.path, + selection: { ...nextSelection }, + comment, + time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), + origin: item.commentOrigin, + preview: item.preview, + } satisfies PromptHistoryComment, + ] + }) + } + const restoreHistoryComments = (items: PromptHistoryComment[]) => { + comments.replace( + items.map((item) => ({ + id: item.id, + file: item.path, + selection: { ...item.selection }, + comment: item.comment, + time: item.time, + })), + ) + prompt.context.replaceComments( + items.map((item) => ({ + type: "file", + path: item.path, + selection: selectionFromLines(item.selection), + comment: item.comment, + commentID: item.id, + commentOrigin: item.origin, + preview: item.preview, + })), + ) + } + + const accepting = createMemo(() => { + const id = props.controls.session.id + if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(id, sdk().directory) + }) + const submission = createPromptSubmit({ + prompt, + info, + imageAttachments: attachments, + commentCount, + autoAccept: accepting, + mode, + working, + editor: () => editor, + queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })), + promptLength, + addToHistory: (value, mode) => controller.addHistory(value, mode), + resetHistoryNavigation: () => controller.resetHistory(), + setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }), + setPopover: (popover) => { + if (!popover) controller.dispatch({ type: "popover.close" }) + }, + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + model: props.controls.model.selection, + }) + + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + const references = createMemo(() => + sync() + .data.reference.filter((reference) => !reference.hidden) + .map((reference) => ({ + id: `reference:${reference.name}`, + kind: "reference" as const, + label: `@${reference.name}`, + path: reference.path, + description: reference.description ?? referenceDescription(reference), + mention: { + type: "file" as const, + path: reference.path, + content: `@${reference.name}`, + start: 0, + end: 0, + mime: "application/x-directory", + filename: reference.name, + }, + })), + ) + const resources = createMemo(() => + Object.values(sync().data.mcp_resource).map((resource) => ({ + id: `resource:${resource.client}:${resource.uri}`, + kind: "resource" as const, + label: `@${resource.name}`, + path: resource.uri, + description: resource.description, + mention: { + type: "file" as const, + path: resource.uri, + content: `@${resource.name}`, + start: 0, + end: 0, + mime: resource.mimeType ?? "text/plain", + filename: resource.name, + url: resource.uri, + source: { + type: "resource" as const, + text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, + clientName: resource.client, + uri: resource.uri, + }, + }, + resource, + })), + ) + const context = createMemo(() => [ + ...references(), + ...props.controls.agents.available + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent) => ({ + id: `agent:${agent.name}`, + kind: "agent" as const, + label: `@${agent.name}`, + mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 }, + })), + ...resources(), + ...recent().map((path) => ({ + id: `file:${path}`, + kind: "file" as const, + label: path, + path, + recent: true, + mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 }, + })), + ]) + const slashCommands = createMemo(() => [ + ...sync().data.command.map((item) => ({ + id: `custom.${item.name}`, + trigger: item.name, + title: item.name, + description: item.description, + type: "custom" as const, + })), + ...command.options + .filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash) + .map((item) => ({ + id: item.id, + trigger: item.slash!, + title: item.title, + description: item.description, + type: "builtin" as const, + })), + ]) + const commands = createMemo(() => + slashCommands().map((item) => ({ + id: item.id, + kind: "command", + label: `/${item.trigger}`, + trigger: item.trigger, + title: item.title, + description: item.description, + keybind: command.keybindParts(item.id), + })), + ) + const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) + const controller = createPromptInputV2Controller({ + store: () => prompt.capture().store, + state: interaction, + identity: () => prompt.capture(), + history: { + entries: (mode) => + history.entries(mode).map((value) => { + const entry = normalizePromptHistoryEntry(value) + return { prompt: entry.prompt, metadata: entry.comments } + }), + add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()), + capture: historyComments, + restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]), + }, + commands, + context, + searchContextFiles: async (query) => + (await files.searchFilesAndDirectories(query)).map((path) => ({ + id: `file:${path}`, + kind: "file", + label: path, + path, + mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 }, + })), + onContextRemove(item) { + if (item?.commentID) comments.remove(item.path, item.commentID) + }, + openAttachment: (attachment) => + dialog.show(() => ), + openContext(key) { + const item = controller.contextItem(key) + if (item) openComment(item, props, sync, layout, files, comments) + }, + onEditor(element) { + editor = element as HTMLDivElement + props.ref?.(editor) + }, + onSuggestionSelect(item) { + if (item.kind !== "command") return + const selected = slashCommands().find((entry) => entry.id === item.id) + if (!selected || selected.type === "custom") return + return () => command.trigger(selected.id, "slash") + }, + attachments: { + picker: platform.openAttachmentPickerDialog, + directory: () => sdk().directory, + isDialogActive: () => !!dialog.active, + warn: () => + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }), + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + readClipboardImage: platform.readClipboardImage, + getPathForFile: platform.getPathForFile, + }, + view: { + placeholder: designPlaceholder, + agent: + props.controls.agents.visible && props.controls.agents.options.length > 0 + ? { + options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })), + current: () => props.controls.agents.current, + onSelect: props.controls.agents.select, + } + : undefined, + variant: { + options: () => variants().map((value) => ({ id: value, label: value })), + current: () => props.controls.model.selection.variant.current() ?? "default", + onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value), + }, + submit: { + stopping, + working, + onSubmit: () => void submission.handleSubmit(new Event("submit")), + onStop: () => void submission.abort(), + }, + }, + }) + Object.defineProperty(controller, "model", { get: () => props.controls.model }) + return controller as PromptInputV2ComposerController +} + +function PromptInputV2ModelControl(props: { + loading: boolean + paid: boolean + title: string + keybind: string[] + model: PromptInputV2ComposerController["model"]["selection"] + providerID?: string + modelName: string + onClose: () => void + onUnpaidClick: () => void +}) { + const shouldAnimate = createMemo((previous) => previous ?? props.loading) + const content = () => ( + <> + + {(providerID) => ( + + )} + + {props.modelName} + + + + + ) + return ( + + + {props.title} + + + } + > + + {content()} + + } + > + + {content()} + + + + + ) +} + +function openComment( + item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }, + props: PromptInputV2ControllerProps, + sync: ReturnType, + layout: ReturnType, + files: ReturnType, + comments: ReturnType, +) { + if (!item.commentID) return + const focus = { file: item.path, id: item.commentID } + comments.setActive(focus) + const queueFocus = (attempts = 6) => { + requestAnimationFrame(() => { + comments.setFocus({ ...focus }) + if (attempts <= 0) return + requestAnimationFrame(() => { + const current = comments.focus() + if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1) + }) + }) + } + const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined + const review = + item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path)) + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() + if (review) { + layout.fileTree.setTab("changes") + props.controls.session.tabs.setActive("review") + queueFocus() + return + } + layout.fileTree.setTab("all") + const tab = files.tab(item.path) + void props.controls.session.tabs.open(tab) + props.controls.session.tabs.setActive(tab) + void Promise.resolve(files.load(item.path)).finally(() => queueFocus()) +} diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index 362a741401..0b9c26ce41 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -30,8 +30,16 @@ function PromptInputExample() { activeTab: undefined as string | undefined, reviewOpen: false, }) + const storyModel = { + id: "claude-3-7-sonnet", + name: "Claude 3.7 Sonnet", + provider: { id: "anthropic", name: "Anthropic" }, + } const model = { - current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), + current: () => storyModel, + list: () => [storyModel], + visible: () => true, + set: () => {}, variant: { list: () => ["fast", "thinking"], current: () => controls.variant, @@ -67,7 +75,6 @@ function PromptInputExample() { open: () => setControls("reviewOpen", true), }, }, - newLayoutDesigns: true, } const addReviewComment = () => { const comment = controls.comments + 1 @@ -146,7 +153,6 @@ function PromptInputWithOpenDock() { }, reviewPanel: { opened: () => false, open: () => {} }, }, - newLayoutDesigns: true, } const state = { blocked: () => false, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index a820067a4e..625ebdeca3 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -13,8 +13,6 @@ import { Match, type JSX, } from "solid-js" -import { createStore, type SetStoreFunction, type Store } from "solid-js/store" -import type { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { ContentPart, @@ -49,7 +47,6 @@ import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialo import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { useCommand } from "@/context/command" -import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -60,121 +57,34 @@ import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" import { canNavigateHistoryAtCursor, navigatePromptHistory, - prependHistoryEntry, type PromptHistoryComment, type PromptHistoryEntry, - type PromptHistoryStoredEntry, promptLength, } from "./prompt-input/history" -import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" +import { + createPersistedPromptInputHistory, + createPromptInputHistory, + type PromptInputHistory, +} from "./prompt-input/history-store" +import { + type PromptInputControls, + type PromptInputProps, + type PromptInputState, + type PromptInputSubmission, +} from "./prompt-input/contracts" +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 { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder" import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" -export type PromptInputState = ReturnType - -export type PromptInputHistory = { - entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] - add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void -} - -export type PromptInputSubmission = { - abort: () => Promise | void - handleSubmit: (event: Event) => Promise | void -} - -export type PromptInputControls = { - agents: { - available: { name: string; hidden?: boolean; mode: string }[] - options: string[] - current: string - loading: boolean - visible: boolean - select: (name: string | undefined) => void - } - model: { - selection: ReturnType["model"] - paid: boolean - loading: boolean - } - session: { - id?: string - tabs: { - active: () => string | undefined - all: () => string[] - open: (tab: string) => void | Promise - setActive: (tab: string) => void - } - reviewPanel: { - opened: () => boolean - open: () => void - } - } - newLayoutDesigns: boolean -} - -export function createPromptInputHistory(): PromptInputHistory { - const [normal, setNormal] = createStore({ entries: [] }) - const [shell, setShell] = createStore({ entries: [] }) - return createPromptInputHistoryStore(normal, setNormal, shell, setShell) -} - -type PromptHistoryState = { entries: PromptHistoryStoredEntry[] } - -function createPromptInputHistoryStore( - normal: Store, - setNormal: SetStoreFunction, - shell: Store, - setShell: SetStoreFunction, -): PromptInputHistory { - return { - entries: (mode) => (mode === "shell" ? shell.entries : normal.entries), - add(prompt, mode, comments) { - const current = mode === "shell" ? shell : normal - const setCurrent = mode === "shell" ? setShell : setNormal - const next = prependHistoryEntry(current.entries, prompt, comments) - if (next === current.entries) return - setCurrent("entries", next) - }, - } -} - -function createPersistedPromptInputHistory() { - const [normal, setNormal] = persisted( - Persist.global("prompt-history", ["prompt-history.v1"]), - createStore({ entries: [] }), - ) - const [shell, setShell] = persisted( - Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), - createStore({ entries: [] }), - ) - return createPromptInputHistoryStore(normal, setNormal, shell, setShell) -} - -export interface PromptInputProps { - class?: string - variant?: "dock" | "new-session" - state?: PromptInputState - history?: PromptInputHistory - submission?: PromptInputSubmission - controls: PromptInputControls - ref?: (el: HTMLDivElement) => void - newSessionWorktree?: string - onNewSessionWorktreeReset?: () => void - edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } - onEditLoaded?: () => void - shouldQueue?: () => boolean - onQueue?: (draft: FollowupDraft) => void - onAbort?: () => void - onSubmit?: () => void - toolbar?: JSX.Element -} +export { createPromptInputHistory } +export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } const EXAMPLES = [ "prompt.example.1", @@ -1191,28 +1101,6 @@ export const PromptInput: Component = (props) => { return true } - const openCommands = () => { - const populated = prompt.dirty() || commentCount() > 0 - requestAnimationFrame(() => { - if (!populated) { - if (!addPart({ type: "text", content: "/", start: 0, end: 0 })) return - slashOnInput("") - setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" }) - return - } - slashOnInput("") - setStore({ popover: "slash", slashMenu: true, slashMenuQuery: "" }) - }) - } - - const openContext = () => { - requestAnimationFrame(() => { - if (!addPart({ type: "text", content: "@", start: 0, end: 0 })) return - atOnInput("") - setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" }) - }) - } - const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { history.add(prompt, mode, mode === "shell" ? [] : historyComments()) } @@ -1539,50 +1427,11 @@ export const PromptInput: Component = (props) => { (p) => p, ) - const designPlaceholder = () => { - if (store.mode === "shell") return placeholder() - return "Ask anything, / for commands, @ for context..." - } - - const modelControlState = createMemo(() => ({ - loading: providersLoading(), - shouldAnimate: providersShouldFadeIn(), - paid: props.controls.model.paid, - title: language.t("command.model.choose"), - keybind: command.keybindParts("model.choose"), - model: props.controls.model.selection, - providerID: props.controls.model.selection.current()?.provider?.id, - modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"), - newLayoutDesigns: props.controls.newLayoutDesigns, - style: control(), - onClose: restoreFocus, - onUnpaidClick: () => { - if (props.controls.newLayoutDesigns) { - dialog.show(() => ) - return - } - dialog.show(() => ) - }, - })) - - const newSession = () => props.variant === "new-session" const bindEditorRef = (el: HTMLDivElement) => { editorRef = el restoreEndOnFocus = true props.ref?.(el) } - const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) - const agentControlState = createMemo(() => ({ - title: language.t("command.agent.cycle"), - keybind: command.keybindParts("agent.cycle"), - options: props.controls.agents.options, - current: props.controls.agents.current, - style: control(), - onSelect: (value) => { - props.controls.agents.select(value) - restoreFocus() - }, - })) return (
{(promptReady(), null)} @@ -1607,773 +1456,335 @@ export const PromptInput: Component = (props) => { onSlashMenuKeyDown={handleSlashMenuKeyDown} commandKeybind={command.keybind} commandKeybindParts={command.keybindParts} - newLayoutDesigns={props.controls.newLayoutDesigns} + newLayoutDesigns={false} t={(key) => language.t(key as Parameters[0])} /> - - -
- - - !isCommentItem(item))} - 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) - }} - newLayoutDesigns={props.controls.newLayoutDesigns} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - newLayoutDesigns={props.controls.newLayoutDesigns} - comments={contextItems().filter(isCommentItem)} - commentActive={(item) => { - const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file - }} - onOpenComment={openComment} - onRemoveComment={(item) => { - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action^="prompt-"]')) return - editorRef?.focus() - }} - > -
(scrollRef = el)}> -
-
- {designPlaceholder()} -
-
-
-
-
- {fileAttachmentInput()} - - {language.t("prompt.menu.addImagesAndFiles")} - - - } - > - - } - variant="ghost-muted" - size="large" - style={buttons()} - disabled={store.mode !== "normal"} - tabIndex={store.mode === "normal" ? undefined : -1} - aria-label={language.t("prompt.menu.addImagesAndFiles")} - /> - - - - {language.t("prompt.menu.imagesAndFiles")} - - - - {language.t("prompt.menu.commands")} - - - {language.t("prompt.menu.context")} - - setMode("shell")} shortcut="!"> - {language.t("prompt.menu.shellCommand")} - - - - - - - - - {props.toolbar} - - -
- - {language.t("command.model.variant.cycle")} - - - } - > - setStore("variantOpen", open)} - > - - - {props.controls.model.selection.variant.current() ?? language.t("common.default")} - - - - - - - - { - props.controls.model.selection.variant.set(value === "default" ? undefined : value) - restoreFocus() - }} - > - {variants().map((value) => ( - - {value === "default" ? language.t("common.default") : value} - - ))} - - - - - -
-
-
- - - -
- -
- - - + + { + 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) + }} + newLayoutDesigns={false} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + newLayoutDesigns={false} + /> +
{ + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { + return + } + editorRef?.focus() + }} + > +
(scrollRef = el)} + style={{ "scroll-padding-bottom": space }} > - - { - const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file +
{ - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - newLayoutDesigns={props.controls.newLayoutDesigns} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - newLayoutDesigns={props.controls.newLayoutDesigns} + style={{ "padding-bottom": space }} />
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { - return - } - editorRef?.focus() + class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate" + classList={{ "font-mono!": store.mode === "shell" }} + style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }} + > + {placeholder()} +
+
+ +