mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): align MCP and fork dialogs with v2 search lists (#56028)
This commit is contained in:
committed by
GitHub
Unverified
parent
8d5e6e23c0
commit
bd874f4da4
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user