From bd874f4da434d0c37738f9851862cb4e09469ef2 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:06:33 +0200 Subject: [PATCH] feat(app): align MCP and fork dialogs with v2 search lists (#56028) --- .../app/src/providers/connect/mcp-dialog.tsx | 217 ++++++++++++------ packages/app/src/runtime/i18n/en.ts | 3 + .../app/src/session/commands/fork-dialog.tsx | 117 ++++++++-- packages/app/src/shell/search-list.css | 114 +++++++++ 4 files changed, 358 insertions(+), 93 deletions(-) create mode 100644 packages/app/src/shell/search-list.css diff --git a/packages/app/src/providers/connect/mcp-dialog.tsx b/packages/app/src/providers/connect/mcp-dialog.tsx index 6be62f2fa26..28581914b84 100644 --- a/packages/app/src/providers/connect/mcp-dialog.tsx +++ b/packages/app/src/providers/connect/mcp-dialog.tsx @@ -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 ( - + - - 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 ( -
-
-
- {i.name} - - {statusLabel()} - -
- - {error()} - -
-
e.stopPropagation()}> - { - if (toggle.isPending) return - toggle.mutate(i.name) - }} - /> -
+ + + (resultsRef = el)}> + 0} + fallback={ +
+ {language.t(items().length > 0 ? "dialog.mcp.search.empty" : "dialog.mcp.empty")}
- ) - }} - + } + > +
+ + {(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 ( +
{ + kept = "" + list.setActive(item.name) + }} + onMouseLeave={() => list.setActive("")} + onMouseDown={(event) => event.preventDefault()} + onClick={() => select(item.name)} + > +
+ {item.name} + + + {detail()} + + +
+
event.stopPropagation()}> + select(item.name)} + /> +
+
+ ) + }} +
+
+
+
) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index aebb4acc455..50f72ebd3bf 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/session/commands/fork-dialog.tsx b/packages/app/src/session/commands/fork-dialog.tsx index 7305b895382..da9b361514b 100644 --- a/packages/app/src/session/commands/fork-dialog.tsx +++ b/packages/app/src/session/commands/fork-dialog.tsx @@ -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 ( - + - {language.t("command.session.fork")} + - - x.id} - items={messages} - filterKeys={["text"]} - onSelect={handleSelect} - > - {(item) => ( -
- {item.text} - {item.time} + + + (resultsRef = el)}> + 0} + fallback={ +
+ {language.t(messages().length > 0 ? "dialog.fork.search.empty" : "dialog.fork.empty")} +
+ } + > +
+ + {(item) => ( + + )} +
- )} - +
+
) diff --git a/packages/app/src/shell/search-list.css b/packages/app/src/shell/search-list.css new file mode 100644 index 00000000000..4cddf33b88c --- /dev/null +++ b/packages/app/src/shell/search-list.css @@ -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; +}