status-popover-body.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. import { Button } from "@kirincode-ai/ui/button"
  2. import { useDialog } from "@kirincode-ai/ui/context/dialog"
  3. import { Icon } from "@kirincode-ai/ui/icon"
  4. import { Switch } from "@kirincode-ai/ui/switch"
  5. import { Tabs } from "@kirincode-ai/ui/tabs"
  6. import { showToast } from "@/utils/toast"
  7. import { useNavigate } from "@solidjs/router"
  8. import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
  9. import { createStore } from "solid-js/store"
  10. import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
  11. import { useLanguage } from "@/context/language"
  12. import { usePlatform } from "@/context/platform"
  13. import { ServerConnection, useServer } from "@/context/server"
  14. import { useSync } from "@/context/sync"
  15. import { type ServerHealth } from "@/utils/server-health"
  16. import { useGlobal } from "@/context/global"
  17. import { useSettings } from "@/context/settings"
  18. import { useMcpToggle } from "@/context/mcp"
  19. const pluginEmptyMessage = (value: string, file: string): JSXElement => {
  20. const parts = value.split(file)
  21. if (parts.length === 1) return value
  22. return (
  23. <>
  24. {parts[0]}
  25. <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
  26. {parts.slice(1).join(file)}
  27. </>
  28. )
  29. }
  30. const listServersByHealth = (
  31. list: ServerConnection.Any[],
  32. active: ServerConnection.Key | undefined,
  33. status: Record<ServerConnection.Key, ServerHealth | undefined>,
  34. ) => {
  35. if (!list.length) return list
  36. const order = new Map(list.map((url, index) => [url, index] as const))
  37. const rank = (value?: ServerHealth) => {
  38. if (value?.healthy === true) return 0
  39. if (value?.healthy === false) return 2
  40. return 1
  41. }
  42. return list.slice().sort((a, b) => {
  43. if (ServerConnection.key(a) === active) return -1
  44. if (ServerConnection.key(b) === active) return 1
  45. const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
  46. if (diff !== 0) return diff
  47. return (order.get(a) ?? 0) - (order.get(b) ?? 0)
  48. })
  49. }
  50. const useDefaultServerKey = (
  51. get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
  52. ) => {
  53. const [state, setState] = createStore({
  54. key: undefined as ServerConnection.Key | undefined,
  55. tick: 0,
  56. })
  57. createEffect(() => {
  58. state.tick
  59. let dead = false
  60. const result = get?.()
  61. if (!result) {
  62. setState("key", undefined)
  63. onCleanup(() => {
  64. dead = true
  65. })
  66. return
  67. }
  68. if (result instanceof Promise) {
  69. void result.then((next) => {
  70. if (dead) return
  71. setState("key", next ?? undefined)
  72. })
  73. onCleanup(() => {
  74. dead = true
  75. })
  76. return
  77. }
  78. setState("key", ServerConnection.Key.make(result))
  79. onCleanup(() => {
  80. dead = true
  81. })
  82. })
  83. return {
  84. key: () => {
  85. return state.key
  86. },
  87. refresh: () => setState("tick", (value) => value + 1),
  88. }
  89. }
  90. type ServerStatusState = {
  91. servers: () => ServerStatusItem[]
  92. defaultKey: () => ServerConnection.Key | undefined
  93. ariaLabel: string
  94. serversLabel: string
  95. defaultLabel: string
  96. manageLabel: string
  97. onManage: () => void
  98. }
  99. type ServerStatusItem = {
  100. key: ServerConnection.Key
  101. conn: ServerConnection.Any
  102. health?: ServerHealth
  103. blocked: boolean
  104. active: boolean
  105. onSelect: () => void
  106. }
  107. export function StatusPopoverServerBody() {
  108. const global = useGlobal()
  109. const server = useServer()
  110. const platform = usePlatform()
  111. const dialog = useDialog()
  112. const language = useLanguage()
  113. const navigate = useNavigate()
  114. let dialogRun = 0
  115. let dialogDead = false
  116. onCleanup(() => {
  117. dialogDead = true
  118. dialogRun += 1
  119. })
  120. const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
  121. const defaultServer = useDefaultServerKey(platform.getDefaultServer)
  122. const serverItems = createMemo(() =>
  123. sortedServers().map((conn) => {
  124. const key = ServerConnection.key(conn)
  125. return {
  126. key,
  127. conn,
  128. health: global.servers.health[key],
  129. blocked: global.servers.health[key]?.healthy === false,
  130. active: !!server.current && key === ServerConnection.key(server.current),
  131. onSelect: () => {
  132. navigate("/")
  133. queueMicrotask(() => server.setActive(key))
  134. },
  135. }
  136. }),
  137. )
  138. return (
  139. <ServerStatusPopoverView
  140. state={{
  141. servers: serverItems,
  142. defaultKey: defaultServer.key,
  143. ariaLabel: language.t("status.popover.ariaLabel"),
  144. serversLabel: language.t("status.popover.tab.servers"),
  145. defaultLabel: language.t("common.default"),
  146. manageLabel: language.t("status.popover.action.manageServers"),
  147. onManage: () => {
  148. const run = ++dialogRun
  149. void import("./dialog-select-server").then((x) => {
  150. if (dialogDead || dialogRun !== run) return
  151. dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
  152. })
  153. },
  154. }}
  155. />
  156. )
  157. }
  158. function ServerStatusPopoverView(props: { state: ServerStatusState }) {
  159. return (
  160. <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
  161. <Tabs
  162. aria-label={props.state.ariaLabel}
  163. class="tabs bg-background-strong rounded-xl overflow-hidden"
  164. data-component="tabs"
  165. data-active="servers"
  166. defaultValue="servers"
  167. variant="alt"
  168. >
  169. <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
  170. <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
  171. {props.state.servers().length > 0 ? `${props.state.servers().length} ` : ""}
  172. {props.state.serversLabel}
  173. </Tabs.Trigger>
  174. </Tabs.List>
  175. <Tabs.Content value="servers">
  176. <ServerStatusList state={props.state} />
  177. </Tabs.Content>
  178. </Tabs>
  179. </div>
  180. )
  181. }
  182. function ServerStatusList(props: { state: ServerStatusState }) {
  183. return (
  184. <div class="flex flex-col px-2 pb-2">
  185. <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
  186. <For each={props.state.servers()}>
  187. {(item) => {
  188. return (
  189. <button
  190. type="button"
  191. class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
  192. classList={{
  193. "hover:bg-surface-raised-base-hover": !item.blocked,
  194. "cursor-not-allowed": item.blocked,
  195. }}
  196. aria-disabled={item.blocked}
  197. onClick={() => {
  198. if (item.blocked) return
  199. item.onSelect()
  200. }}
  201. >
  202. <ServerHealthIndicator health={item.health} />
  203. <ServerRow
  204. conn={item.conn}
  205. dimmed={item.blocked}
  206. status={item.health}
  207. class="flex items-center gap-2 w-full min-w-0"
  208. nameClass="text-14-regular text-text-base truncate"
  209. versionClass="text-12-regular text-text-weak truncate"
  210. badge={
  211. <Show when={item.key === props.state.defaultKey()}>
  212. <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
  213. {props.state.defaultLabel}
  214. </span>
  215. </Show>
  216. }
  217. >
  218. <div class="flex-1" />
  219. <Show when={item.active}>
  220. <Icon name="check" size="small" class="text-icon-weak shrink-0" />
  221. </Show>
  222. </ServerRow>
  223. </button>
  224. )
  225. }}
  226. </For>
  227. <Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" onClick={props.state.onManage}>
  228. {props.state.manageLabel}
  229. </Button>
  230. </div>
  231. </div>
  232. )
  233. }
  234. export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
  235. const sync = useSync()
  236. const global = useGlobal()
  237. const server = useServer()
  238. const platform = usePlatform()
  239. const dialog = useDialog()
  240. const language = useLanguage()
  241. const navigate = useNavigate()
  242. const settings = useSettings()
  243. const fail = (err: unknown) => {
  244. showToast({
  245. variant: "error",
  246. title: language.t("common.requestFailed"),
  247. description: err instanceof Error ? err.message : String(err),
  248. })
  249. }
  250. createEffect(() => {
  251. if (!props.shown()) return
  252. })
  253. let dialogRun = 0
  254. let dialogDead = false
  255. onCleanup(() => {
  256. dialogDead = true
  257. dialogRun += 1
  258. })
  259. const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
  260. const toggleMcp = useMcpToggle()
  261. const defaultServer = useDefaultServerKey(platform.getDefaultServer)
  262. const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
  263. const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
  264. const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
  265. const lspItems = createMemo(() => sync().data.lsp ?? [])
  266. const lspCount = createMemo(() => lspItems().length)
  267. const plugins = createMemo(() =>
  268. (sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
  269. )
  270. const pluginCount = createMemo(() => plugins().length)
  271. const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "kirincode.json"))
  272. return (
  273. <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
  274. <Tabs
  275. aria-label={language.t("status.popover.ariaLabel")}
  276. class="tabs bg-background-strong rounded-xl overflow-hidden"
  277. data-component="tabs"
  278. data-active={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
  279. defaultValue={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
  280. variant="alt"
  281. >
  282. <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
  283. {!settings.general.newLayoutDesigns() && (
  284. <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
  285. {global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""}
  286. {language.t("status.popover.tab.servers")}
  287. </Tabs.Trigger>
  288. )}
  289. <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
  290. {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
  291. {language.t("status.popover.tab.mcp")}
  292. </Tabs.Trigger>
  293. <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
  294. {lspCount() > 0 ? `${lspCount()} ` : ""}
  295. {language.t("status.popover.tab.lsp")}
  296. </Tabs.Trigger>
  297. <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
  298. {pluginCount() > 0 ? `${pluginCount()} ` : ""}
  299. {language.t("status.popover.tab.plugins")}
  300. </Tabs.Trigger>
  301. </Tabs.List>
  302. {!settings.general.newLayoutDesigns() && (
  303. <Tabs.Content value="servers">
  304. <div class="flex flex-col px-2 pb-2">
  305. <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
  306. <For each={sortedServers()}>
  307. {(s) => {
  308. const key = ServerConnection.key(s)
  309. const blocked = () => global.servers.health[key]?.healthy === false
  310. return (
  311. <button
  312. type="button"
  313. class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
  314. classList={{
  315. "hover:bg-surface-raised-base-hover": !blocked(),
  316. "cursor-not-allowed": blocked(),
  317. }}
  318. aria-disabled={blocked()}
  319. onClick={() => {
  320. if (blocked()) return
  321. navigate("/")
  322. queueMicrotask(() => server.setActive(key))
  323. }}
  324. >
  325. <ServerHealthIndicator health={global.servers.health[key]} />
  326. <ServerRow
  327. conn={s}
  328. dimmed={blocked()}
  329. status={global.servers.health[key]}
  330. class="flex items-center gap-2 w-full min-w-0"
  331. nameClass="text-14-regular text-text-base truncate"
  332. versionClass="text-12-regular text-text-weak truncate"
  333. badge={
  334. <Show when={key === defaultServer.key()}>
  335. <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
  336. {language.t("common.default")}
  337. </span>
  338. </Show>
  339. }
  340. >
  341. <div class="flex-1" />
  342. <Show when={server.current && key === ServerConnection.key(server.current)}>
  343. <Icon name="check" size="small" class="text-icon-weak shrink-0" />
  344. </Show>
  345. </ServerRow>
  346. </button>
  347. )
  348. }}
  349. </For>
  350. <Button
  351. variant="secondary"
  352. class="mt-3 self-start h-8 px-3 py-1.5"
  353. onClick={() => {
  354. const run = ++dialogRun
  355. void import("./dialog-select-server").then((x) => {
  356. if (dialogDead || dialogRun !== run) return
  357. dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
  358. })
  359. }}
  360. >
  361. {language.t("status.popover.action.manageServers")}
  362. </Button>
  363. </div>
  364. </div>
  365. </Tabs.Content>
  366. )}
  367. <Tabs.Content value="mcp">
  368. <div class="flex flex-col px-2 pb-2">
  369. <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
  370. <Show
  371. when={mcpNames().length > 0}
  372. fallback={
  373. <div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
  374. }
  375. >
  376. <For each={mcpNames()}>
  377. {(name) => {
  378. const status = () => mcpStatus(name)
  379. const enabled = () => status() === "connected"
  380. return (
  381. <button
  382. type="button"
  383. class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
  384. onClick={() => {
  385. if (toggleMcp.isPending) return
  386. toggleMcp.mutate(name)
  387. }}
  388. disabled={toggleMcp.isPending && toggleMcp.variables === name}
  389. >
  390. <div
  391. classList={{
  392. "size-1.5 rounded-full shrink-0": true,
  393. "bg-icon-success-base": status() === "connected",
  394. "bg-icon-critical-base": status() === "failed",
  395. "bg-border-weak-base": status() === "disabled",
  396. "bg-icon-warning-base":
  397. status() === "needs_auth" || status() === "needs_client_registration",
  398. }}
  399. />
  400. <span class="flex flex-col min-w-0 flex-1">
  401. <span class="flex items-center gap-2 min-w-0">
  402. <span class="text-14-regular text-text-base truncate">{name}</span>
  403. </span>
  404. <Show when={status() === "needs_auth"}>
  405. <span class="text-11-regular text-text-weaker truncate">
  406. {language.t("mcp.auth.clickToAuthenticate")}
  407. </span>
  408. </Show>
  409. </span>
  410. <div onClick={(event) => event.stopPropagation()}>
  411. <Switch
  412. checked={enabled()}
  413. disabled={toggleMcp.isPending && toggleMcp.variables === name}
  414. onChange={() => {
  415. if (toggleMcp.isPending) return
  416. toggleMcp.mutate(name)
  417. }}
  418. />
  419. </div>
  420. </button>
  421. )
  422. }}
  423. </For>
  424. </Show>
  425. </div>
  426. </div>
  427. </Tabs.Content>
  428. <Tabs.Content value="lsp">
  429. <div class="flex flex-col px-2 pb-2">
  430. <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
  431. <Show
  432. when={lspItems().length > 0}
  433. fallback={
  434. <div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
  435. }
  436. >
  437. <For each={lspItems()}>
  438. {(item) => (
  439. <div class="flex items-center gap-2 w-full px-2 py-1">
  440. <div
  441. classList={{
  442. "size-1.5 rounded-full shrink-0": true,
  443. "bg-icon-success-base": item.status === "connected",
  444. "bg-icon-critical-base": item.status === "error",
  445. }}
  446. />
  447. <span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
  448. </div>
  449. )}
  450. </For>
  451. </Show>
  452. </div>
  453. </div>
  454. </Tabs.Content>
  455. <Tabs.Content value="plugins">
  456. <div class="flex flex-col px-2 pb-2">
  457. <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
  458. <Show
  459. when={plugins().length > 0}
  460. fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
  461. >
  462. <For each={plugins()}>
  463. {(plugin) => (
  464. <div class="flex items-center gap-2 w-full px-2 py-1">
  465. <div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
  466. <span class="text-14-regular text-text-base truncate">{plugin}</span>
  467. </div>
  468. )}
  469. </For>
  470. </Show>
  471. </div>
  472. </div>
  473. </Tabs.Content>
  474. </Tabs>
  475. </div>
  476. )
  477. }