feat(app): align MCP and fork dialogs with v2 search lists (#56028)

This commit is contained in:
usrnk1
2026-10-16 12:06:33 +02:00
committed by GitHub
Unverified
parent 8d5e6e23c0
commit bd874f4da4
4 changed files with 358 additions and 93 deletions
+145 -72
View File
@@ -1,11 +1,15 @@
import { Component, createMemo, Show } from "solid-js"
import { Component, createEffect, createMemo, For, on, Show } from "solid-js"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { List } from "@opencode/ui/list"
import { useFilteredList } from "@opencode/ui/hooks"
import { Icon } from "@opencode/ui/icon"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
import "@/shell/search-list.css"
const statusLabels = {
connected: "mcp.status.connected",
@@ -18,91 +22,160 @@ export const DialogSelectMcp: Component = () => {
const data = useData()
const sdk = useWorkspaceLocation()
const language = useLanguage()
const toggle = useMcpToggle(() => sdk().directory)
const servers = createMemo(() => data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
const items = createMemo(() =>
(data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
servers()
.map((server) => ({ name: server.name, status: server.status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
const toggle = useMcpToggle(() => sdk().directory)
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length)
const select = (name: string) => {
const status = servers().find((server) => server.name === name)?.status.status
if (!status || status === "pending" || toggle.isPending) return
toggle.mutate(name)
}
// A toggle changes the server's status, which makes the list reset its highlight; keep the keyboard user's place.
let kept = ""
const list = useFilteredList({
items,
key: (x) => x.name,
filterKeys: ["name", "status"],
sortBy: (a, b) => a.name.localeCompare(b.name),
onSelect: (x) => {
if (!x) return
kept = x.name
select(x.name)
},
noInitialSelection: true,
})
createEffect(
on(
list.flat,
(items) => {
if (kept && items.some((item) => item.name === kept)) list.setActive(kept)
},
{ defer: true },
),
)
let resultsRef: HTMLDivElement | undefined
const onKeyDown = (event: KeyboardEvent) => {
list.onKeyDown(event)
const active = list.active()
if (!active) return
if (kept) kept = active
resultsRef?.querySelector(`[data-option-key="${CSS.escape(active)}"]`)?.scrollIntoView({ block: "nearest" })
}
return (
<Dialog>
<Dialog class="search-list-dialog">
<DialogHeader>
<DialogTitleGroup
title={language.t("dialog.mcp.title")}
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: items().length })}
/>
</DialogHeader>
<DialogBody>
<List
class="px-3"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.mcp.empty")}
key={(x) => x?.name ?? ""}
items={items}
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (!x || x.status === "pending" || toggle.isPending) return
toggle.mutate(x.name)
}}
>
{(i) => {
const mcpStatus = () =>
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)
?.status
const status = () => mcpStatus()?.status
const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
if (!key) return
return language.t(key)
}
const error = () => {
const s = mcpStatus()
if (s?.status === "failed") return s.error
}
const enabled = () => status() === "connected"
return (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="truncate">{i.name}</span>
<Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show>
</div>
<Show when={error()}>
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
</Show>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch
appearance="standard"
checked={enabled()}
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
</div>
<DialogBody class="search-list-body">
<div class="search-list-search">
<TextInput
type="search"
appearance="base"
class="!w-full"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
value={list.filter()}
onInput={(event) => {
kept = ""
list.onInput(event.currentTarget.value)
}}
onKeyDown={onKeyDown}
placeholder={language.t("common.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
autofocus
aria-label={language.t("common.search.placeholder")}
showClearButton={!!list.filter()}
clearIcon="circle-xmark"
clearLabel={language.t("common.clear")}
onClearClick={() => list.clear()}
/>
</div>
<ScrollView class="search-list-scroll" viewportRef={(el) => (resultsRef = el)}>
<Show
when={list.flat().length > 0}
fallback={
<div class="search-list-empty">
{language.t(items().length > 0 ? "dialog.mcp.search.empty" : "dialog.mcp.empty")}
</div>
)
}}
</List>
}
>
<div role="listbox" class="search-list-results">
<For each={list.flat()}>
{(item) => {
const status = () => servers().find((server) => server.name === item.name)?.status
const detail = () => {
const current = status()
if (current?.status === "failed") return current.error
if (!current || current.status === "pending") return
return language.t(statusLabels[current.status])
}
return (
<div
role="option"
aria-selected={list.active() === item.name}
data-option-key={item.name}
data-active={list.active() === item.name ? "" : undefined}
class="search-list-row"
onMouseMove={() => {
kept = ""
list.setActive(item.name)
}}
onMouseLeave={() => list.setActive("")}
onMouseDown={(event) => event.preventDefault()}
onClick={() => select(item.name)}
>
<div class="search-list-row-text">
<span class="search-list-title">{item.name}</span>
<Show when={detail()}>
<span class="search-list-detail" title={detail()}>
{detail()}
</span>
</Show>
</div>
<div class="search-list-trailing" onClick={(event) => event.stopPropagation()}>
<Switch
checked={status()?.status === "connected"}
disabled={
status()?.status === "pending" || (toggle.isPending && toggle.variables === item.name)
}
onChange={() => select(item.name)}
/>
</div>
</div>
)
}}
</For>
</div>
</Show>
</ScrollView>
</DialogBody>
</Dialog>
)
+3
View File
@@ -328,6 +328,9 @@ export const dict = {
"dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
"dialog.mcp.empty": "No MCPs configured",
"dialog.mcp.search.empty": "No MCPs found",
"dialog.fork.search.empty": "No messages found",
"dialog.fork.description": "Edit an earlier message in a new session",
"mcp.status.connected": "connected",
"mcp.status.failed": "failed",
@@ -1,10 +1,13 @@
import { Component, createMemo } from "solid-js"
import { Component, createMemo, For, Show } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useData } from "@/runtime/server/current"
import { useComposerState } from "@/composer/persistence"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode/ui/dialog"
import { List } from "@opencode/ui/list"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { useFilteredList } from "@opencode/ui/hooks"
import { Icon } from "@opencode/ui/icon"
import { ScrollView } from "@opencode/ui/scroll-view"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
@@ -13,6 +16,7 @@ import { extractPromptContext, extractPromptFromMessage } from "@/composer/promp
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServer } from "@/runtime/server/current"
import { sessionHref } from "@/shell/routes/session"
import "@/shell/search-list.css"
interface ForkableMessage {
id: string
@@ -90,28 +94,99 @@ export const DialogFork: Component = () => {
})
}
const list = useFilteredList({
items: messages,
key: (x) => x.id,
filterKeys: ["text"],
onSelect: handleSelect,
noInitialSelection: true,
})
let resultsRef: HTMLDivElement | undefined
const onKeyDown = (event: KeyboardEvent) => {
// Nothing is highlighted until the pointer or arrow keys pick a row, so Enter forks from the newest match.
if (event.key === "Enter" && !event.isComposing && !list.active()) {
event.preventDefault()
handleSelect(list.flat()[0])
return
}
list.onKeyDown(event)
const active = list.active()
if (!active) return
resultsRef?.querySelector(`[data-option-key="${CSS.escape(active)}"]`)?.scrollIntoView({ block: "nearest" })
}
return (
<Dialog>
<Dialog class="search-list-dialog">
<DialogHeader>
<DialogTitle>{language.t("command.session.fork")}</DialogTitle>
<DialogTitleGroup
title={language.t("command.session.fork")}
description={language.t("dialog.fork.description")}
/>
</DialogHeader>
<DialogBody>
<List
class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.fork.empty")}
key={(x) => x.id}
items={messages}
filterKeys={["text"]}
onSelect={handleSelect}
>
{(item) => (
<div class="w-full flex items-center gap-2">
<span class="truncate flex-1 min-w-0 text-left font-normal">{item.text}</span>
<span class="text-text-weak shrink-0 font-normal">{item.time}</span>
<DialogBody class="search-list-body">
<div class="search-list-search">
<TextInput
type="search"
appearance="base"
class="!w-full"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
value={list.filter()}
onInput={(event) => list.onInput(event.currentTarget.value)}
onKeyDown={onKeyDown}
placeholder={language.t("common.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
autofocus
aria-label={language.t("common.search.placeholder")}
showClearButton={!!list.filter()}
clearIcon="circle-xmark"
clearLabel={language.t("common.clear")}
onClearClick={() => list.clear()}
/>
</div>
<ScrollView class="search-list-scroll" viewportRef={(el) => (resultsRef = el)}>
<Show
when={list.flat().length > 0}
fallback={
<div class="search-list-empty">
{language.t(messages().length > 0 ? "dialog.fork.search.empty" : "dialog.fork.empty")}
</div>
}
>
<div role="listbox" class="search-list-results">
<For each={list.flat()}>
{(item) => (
<button
type="button"
role="option"
aria-selected={list.active() === item.id}
data-option-key={item.id}
data-active={list.active() === item.id ? "" : undefined}
class="search-list-row"
onMouseMove={() => list.setActive(item.id)}
onMouseLeave={() => list.setActive("")}
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelect(item)}
>
<span class="search-list-row-text">
<span class="search-list-title" data-weight="regular">
{item.text}
</span>
</span>
<span class="search-list-trailing">{item.time}</span>
</button>
)}
</For>
</div>
)}
</List>
</Show>
</ScrollView>
</DialogBody>
</Dialog>
)
+114
View File
@@ -0,0 +1,114 @@
/* Outranks the deeply nested base dialog header rule. */
[data-component="dialog-v2"]
[data-slot="dialog-container"]
.search-list-dialog[data-slot="dialog-content"]
[data-slot="dialog-header"] {
padding-bottom: 12px;
}
.search-list-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.search-list-search {
flex-shrink: 0;
/* The 1px top keeps the field's outside focus outline from clipping against the body. */
padding: 1px 12px 8px;
}
.search-list-scroll {
min-height: 0;
flex: 1;
}
.search-list-results {
display: flex;
flex-direction: column;
gap: 1px;
padding: 0 8px 8px;
}
.search-list-row {
display: flex;
width: 100%;
height: 36px;
flex-shrink: 0;
align-items: center;
gap: 12px;
padding-inline: 8px;
border: 0;
border-radius: 6px;
background: transparent;
text-align: start;
cursor: default;
scroll-margin-block: 8px;
}
.search-list-row[data-active] {
background: var(--v2-overlay-simple-overlay-hover);
}
.search-list-row:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
.search-list-row-text {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 8px;
font-size: 13px;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
.search-list-title {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-weight: 530;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-list-title[data-weight="regular"] {
flex: 1;
font-weight: 440;
}
.search-list-detail {
min-width: 0;
flex-shrink: 10;
overflow: hidden;
color: var(--v2-text-text-muted);
font-weight: 440;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-list-trailing {
display: flex;
flex-shrink: 0;
align-items: center;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
.search-list-empty {
display: grid;
min-height: 120px;
place-items: center;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}