@jonsoc/app 1.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +51 -0
  3. package/bunfig.toml +2 -0
  4. package/e2e/context.spec.ts +45 -0
  5. package/e2e/file-open.spec.ts +23 -0
  6. package/e2e/file-viewer.spec.ts +35 -0
  7. package/e2e/fixtures.ts +40 -0
  8. package/e2e/home.spec.ts +21 -0
  9. package/e2e/model-picker.spec.ts +43 -0
  10. package/e2e/navigation.spec.ts +9 -0
  11. package/e2e/palette.spec.ts +15 -0
  12. package/e2e/prompt-mention.spec.ts +26 -0
  13. package/e2e/prompt-slash-open.spec.ts +22 -0
  14. package/e2e/prompt.spec.ts +62 -0
  15. package/e2e/session.spec.ts +21 -0
  16. package/e2e/settings.spec.ts +44 -0
  17. package/e2e/sidebar.spec.ts +21 -0
  18. package/e2e/terminal-init.spec.ts +25 -0
  19. package/e2e/terminal.spec.ts +16 -0
  20. package/e2e/tsconfig.json +8 -0
  21. package/e2e/utils.ts +38 -0
  22. package/happydom.ts +75 -0
  23. package/index.html +23 -0
  24. package/package.json +72 -0
  25. package/playwright.config.ts +43 -0
  26. package/public/_headers +17 -0
  27. package/public/apple-touch-icon-v3.png +1 -0
  28. package/public/apple-touch-icon.png +1 -0
  29. package/public/favicon-96x96-v3.png +1 -0
  30. package/public/favicon-96x96.png +1 -0
  31. package/public/favicon-v3.ico +1 -0
  32. package/public/favicon-v3.svg +1 -0
  33. package/public/favicon.ico +1 -0
  34. package/public/favicon.svg +1 -0
  35. package/public/oc-theme-preload.js +28 -0
  36. package/public/site.webmanifest +1 -0
  37. package/public/social-share-zen.png +1 -0
  38. package/public/social-share.png +1 -0
  39. package/public/web-app-manifest-192x192.png +1 -0
  40. package/public/web-app-manifest-512x512.png +1 -0
  41. package/script/e2e-local.ts +143 -0
  42. package/src/addons/serialize.test.ts +319 -0
  43. package/src/addons/serialize.ts +591 -0
  44. package/src/app.tsx +150 -0
  45. package/src/components/dialog-connect-provider.tsx +428 -0
  46. package/src/components/dialog-edit-project.tsx +259 -0
  47. package/src/components/dialog-fork.tsx +104 -0
  48. package/src/components/dialog-manage-models.tsx +59 -0
  49. package/src/components/dialog-select-directory.tsx +208 -0
  50. package/src/components/dialog-select-file.tsx +196 -0
  51. package/src/components/dialog-select-mcp.tsx +96 -0
  52. package/src/components/dialog-select-model-unpaid.tsx +130 -0
  53. package/src/components/dialog-select-model.tsx +162 -0
  54. package/src/components/dialog-select-provider.tsx +70 -0
  55. package/src/components/dialog-select-server.tsx +249 -0
  56. package/src/components/dialog-settings.tsx +112 -0
  57. package/src/components/file-tree.tsx +112 -0
  58. package/src/components/link.tsx +17 -0
  59. package/src/components/model-tooltip.tsx +91 -0
  60. package/src/components/prompt-input.tsx +2076 -0
  61. package/src/components/session/index.ts +5 -0
  62. package/src/components/session/session-context-tab.tsx +428 -0
  63. package/src/components/session/session-header.tsx +343 -0
  64. package/src/components/session/session-new-view.tsx +93 -0
  65. package/src/components/session/session-sortable-tab.tsx +56 -0
  66. package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
  67. package/src/components/session-context-usage.tsx +113 -0
  68. package/src/components/session-lsp-indicator.tsx +42 -0
  69. package/src/components/session-mcp-indicator.tsx +34 -0
  70. package/src/components/settings-agents.tsx +15 -0
  71. package/src/components/settings-commands.tsx +15 -0
  72. package/src/components/settings-general.tsx +306 -0
  73. package/src/components/settings-keybinds.tsx +437 -0
  74. package/src/components/settings-mcp.tsx +15 -0
  75. package/src/components/settings-models.tsx +15 -0
  76. package/src/components/settings-permissions.tsx +234 -0
  77. package/src/components/settings-providers.tsx +15 -0
  78. package/src/components/terminal.tsx +315 -0
  79. package/src/components/titlebar.tsx +156 -0
  80. package/src/context/command.tsx +308 -0
  81. package/src/context/comments.tsx +140 -0
  82. package/src/context/file.tsx +409 -0
  83. package/src/context/global-sdk.tsx +106 -0
  84. package/src/context/global-sync.tsx +898 -0
  85. package/src/context/language.tsx +161 -0
  86. package/src/context/layout-scroll.test.ts +73 -0
  87. package/src/context/layout-scroll.ts +118 -0
  88. package/src/context/layout.tsx +648 -0
  89. package/src/context/local.tsx +578 -0
  90. package/src/context/notification.tsx +173 -0
  91. package/src/context/permission.tsx +167 -0
  92. package/src/context/platform.tsx +59 -0
  93. package/src/context/prompt.tsx +245 -0
  94. package/src/context/sdk.tsx +48 -0
  95. package/src/context/server.tsx +214 -0
  96. package/src/context/settings.tsx +166 -0
  97. package/src/context/sync.tsx +320 -0
  98. package/src/context/terminal.tsx +267 -0
  99. package/src/custom-elements.d.ts +17 -0
  100. package/src/entry.tsx +76 -0
  101. package/src/env.d.ts +8 -0
  102. package/src/hooks/use-providers.ts +31 -0
  103. package/src/i18n/ar.ts +656 -0
  104. package/src/i18n/br.ts +667 -0
  105. package/src/i18n/da.ts +582 -0
  106. package/src/i18n/de.ts +591 -0
  107. package/src/i18n/en.ts +665 -0
  108. package/src/i18n/es.ts +585 -0
  109. package/src/i18n/fr.ts +592 -0
  110. package/src/i18n/ja.ts +579 -0
  111. package/src/i18n/ko.ts +580 -0
  112. package/src/i18n/no.ts +602 -0
  113. package/src/i18n/pl.ts +661 -0
  114. package/src/i18n/ru.ts +664 -0
  115. package/src/i18n/zh.ts +574 -0
  116. package/src/i18n/zht.ts +570 -0
  117. package/src/index.css +57 -0
  118. package/src/index.ts +2 -0
  119. package/src/pages/directory-layout.tsx +57 -0
  120. package/src/pages/error.tsx +290 -0
  121. package/src/pages/home.tsx +125 -0
  122. package/src/pages/layout.tsx +2599 -0
  123. package/src/pages/session.tsx +2505 -0
  124. package/src/sst-env.d.ts +10 -0
  125. package/src/utils/dom.ts +51 -0
  126. package/src/utils/id.ts +99 -0
  127. package/src/utils/index.ts +1 -0
  128. package/src/utils/perf.ts +135 -0
  129. package/src/utils/persist.ts +377 -0
  130. package/src/utils/prompt.ts +203 -0
  131. package/src/utils/same.ts +6 -0
  132. package/src/utils/solid-dnd.tsx +55 -0
  133. package/src/utils/sound.ts +110 -0
  134. package/src/utils/speech.ts +302 -0
  135. package/src/utils/worktree.ts +58 -0
  136. package/sst-env.d.ts +9 -0
  137. package/tsconfig.json +26 -0
  138. package/vite.config.ts +15 -0
  139. package/vite.js +26 -0
@@ -0,0 +1,196 @@
1
+ import { useDialog } from "@jonsoc/ui/context/dialog"
2
+ import { Dialog } from "@jonsoc/ui/dialog"
3
+ import { FileIcon } from "@jonsoc/ui/file-icon"
4
+ import { Keybind } from "@jonsoc/ui/keybind"
5
+ import { List } from "@jonsoc/ui/list"
6
+ import { getDirectory, getFilename } from "@jonsoc/util/path"
7
+ import { useParams } from "@solidjs/router"
8
+ import { createMemo, createSignal, onCleanup, Show } from "solid-js"
9
+ import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
10
+ import { useLayout } from "@/context/layout"
11
+ import { useFile } from "@/context/file"
12
+ import { useLanguage } from "@/context/language"
13
+
14
+ type EntryType = "command" | "file"
15
+
16
+ type Entry = {
17
+ id: string
18
+ type: EntryType
19
+ title: string
20
+ description?: string
21
+ keybind?: string
22
+ category: string
23
+ option?: CommandOption
24
+ path?: string
25
+ }
26
+
27
+ export function DialogSelectFile() {
28
+ const command = useCommand()
29
+ const language = useLanguage()
30
+ const layout = useLayout()
31
+ const file = useFile()
32
+ const dialog = useDialog()
33
+ const params = useParams()
34
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
35
+ const tabs = createMemo(() => layout.tabs(sessionKey))
36
+ const view = createMemo(() => layout.view(sessionKey))
37
+ const state = { cleanup: undefined as (() => void) | void, committed: false }
38
+ const [grouped, setGrouped] = createSignal(false)
39
+ const common = [
40
+ "session.new",
41
+ "workspace.new",
42
+ "session.previous",
43
+ "session.next",
44
+ "terminal.toggle",
45
+ "review.toggle",
46
+ ]
47
+ const limit = 5
48
+
49
+ const allowed = createMemo(() =>
50
+ command.options.filter(
51
+ (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
52
+ ),
53
+ )
54
+
55
+ const commandItem = (option: CommandOption): Entry => ({
56
+ id: "command:" + option.id,
57
+ type: "command",
58
+ title: option.title,
59
+ description: option.description,
60
+ keybind: option.keybind,
61
+ category: language.t("palette.group.commands"),
62
+ option,
63
+ })
64
+
65
+ const fileItem = (path: string): Entry => ({
66
+ id: "file:" + path,
67
+ type: "file",
68
+ title: path,
69
+ category: language.t("palette.group.files"),
70
+ path,
71
+ })
72
+
73
+ const list = createMemo(() => allowed().map(commandItem))
74
+
75
+ const picks = createMemo(() => {
76
+ const all = allowed()
77
+ const order = new Map(common.map((id, index) => [id, index]))
78
+ const picked = all.filter((option) => order.has(option.id))
79
+ const base = picked.length ? picked : all.slice(0, limit)
80
+ const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
81
+ return sorted.map(commandItem)
82
+ })
83
+
84
+ const recent = createMemo(() => {
85
+ const all = tabs().all()
86
+ const active = tabs().active()
87
+ const order = active ? [active, ...all.filter((item) => item !== active)] : all
88
+ const seen = new Set<string>()
89
+ const items: Entry[] = []
90
+
91
+ for (const item of order) {
92
+ const path = file.pathFromTab(item)
93
+ if (!path) continue
94
+ if (seen.has(path)) continue
95
+ seen.add(path)
96
+ items.push(fileItem(path))
97
+ }
98
+
99
+ return items.slice(0, limit)
100
+ })
101
+
102
+ const items = async (filter: string) => {
103
+ const query = filter.trim()
104
+ setGrouped(query.length > 0)
105
+ if (!query) return [...picks(), ...recent()]
106
+ const files = await file.searchFiles(query)
107
+ const entries = files.map(fileItem)
108
+ return [...list(), ...entries]
109
+ }
110
+
111
+ const handleMove = (item: Entry | undefined) => {
112
+ state.cleanup?.()
113
+ if (!item) return
114
+ if (item.type !== "command") return
115
+ state.cleanup = item.option?.onHighlight?.()
116
+ }
117
+
118
+ const open = (path: string) => {
119
+ const value = file.tab(path)
120
+ tabs().open(value)
121
+ file.load(path)
122
+ view().reviewPanel.open()
123
+ }
124
+
125
+ const handleSelect = (item: Entry | undefined) => {
126
+ if (!item) return
127
+ state.committed = true
128
+ state.cleanup = undefined
129
+ dialog.close()
130
+
131
+ if (item.type === "command") {
132
+ item.option?.onSelect?.("palette")
133
+ return
134
+ }
135
+
136
+ if (!item.path) return
137
+ open(item.path)
138
+ }
139
+
140
+ onCleanup(() => {
141
+ if (state.committed) return
142
+ state.cleanup?.()
143
+ })
144
+
145
+ return (
146
+ <Dialog class="pt-3 pb-0 !max-h-[480px]">
147
+ <List
148
+ search={{
149
+ placeholder: language.t("palette.search.placeholder"),
150
+ autofocus: true,
151
+ hideIcon: true,
152
+ class: "pl-3 pr-2 !mb-0",
153
+ }}
154
+ emptyMessage={language.t("palette.empty")}
155
+ loadingMessage={language.t("common.loading")}
156
+ items={items}
157
+ key={(item) => item.id}
158
+ filterKeys={["title", "description", "category"]}
159
+ groupBy={(item) => item.category}
160
+ onMove={handleMove}
161
+ onSelect={handleSelect}
162
+ >
163
+ {(item) => (
164
+ <Show
165
+ when={item.type === "command"}
166
+ fallback={
167
+ <div class="w-full flex items-center justify-between rounded-md pl-1">
168
+ <div class="flex items-center gap-x-3 grow min-w-0">
169
+ <FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
170
+ <div class="flex items-center text-14-regular">
171
+ <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
172
+ {getDirectory(item.path ?? "")}
173
+ </span>
174
+ <span class="text-text-strong whitespace-nowrap">{getFilename(item.path ?? "")}</span>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ }
179
+ >
180
+ <div class="w-full flex items-center justify-between gap-4 pl-1">
181
+ <div class="flex items-center gap-2 min-w-0">
182
+ <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
183
+ <Show when={item.description}>
184
+ <span class="text-14-regular text-text-weak truncate">{item.description}</span>
185
+ </Show>
186
+ </div>
187
+ <Show when={item.keybind}>
188
+ <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
189
+ </Show>
190
+ </div>
191
+ </Show>
192
+ )}
193
+ </List>
194
+ </Dialog>
195
+ )
196
+ }
@@ -0,0 +1,96 @@
1
+ import { Component, createMemo, createSignal, Show } from "solid-js"
2
+ import { useSync } from "@/context/sync"
3
+ import { useSDK } from "@/context/sdk"
4
+ import { Dialog } from "@jonsoc/ui/dialog"
5
+ import { List } from "@jonsoc/ui/list"
6
+ import { Switch } from "@jonsoc/ui/switch"
7
+ import { useLanguage } from "@/context/language"
8
+
9
+ export const DialogSelectMcp: Component = () => {
10
+ const sync = useSync()
11
+ const sdk = useSDK()
12
+ const language = useLanguage()
13
+ const [loading, setLoading] = createSignal<string | null>(null)
14
+
15
+ const items = createMemo(() =>
16
+ Object.entries(sync.data.mcp ?? {})
17
+ .map(([name, status]) => ({ name, status: status.status }))
18
+ .sort((a, b) => a.name.localeCompare(b.name)),
19
+ )
20
+
21
+ const toggle = async (name: string) => {
22
+ if (loading()) return
23
+ setLoading(name)
24
+ const status = sync.data.mcp[name]
25
+ if (status?.status === "connected") {
26
+ await sdk.client.mcp.disconnect({ name })
27
+ } else {
28
+ await sdk.client.mcp.connect({ name })
29
+ }
30
+ const result = await sdk.client.mcp.status()
31
+ if (result.data) sync.set("mcp", result.data)
32
+ setLoading(null)
33
+ }
34
+
35
+ const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
36
+ const totalCount = createMemo(() => items().length)
37
+
38
+ return (
39
+ <Dialog
40
+ title={language.t("dialog.mcp.title")}
41
+ description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
42
+ >
43
+ <List
44
+ search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
45
+ emptyMessage={language.t("dialog.mcp.empty")}
46
+ key={(x) => x?.name ?? ""}
47
+ items={items}
48
+ filterKeys={["name", "status"]}
49
+ sortBy={(a, b) => a.name.localeCompare(b.name)}
50
+ onSelect={(x) => {
51
+ if (x) toggle(x.name)
52
+ }}
53
+ >
54
+ {(i) => {
55
+ const mcpStatus = () => sync.data.mcp[i.name]
56
+ const status = () => mcpStatus()?.status
57
+ const error = () => {
58
+ const s = mcpStatus()
59
+ return s?.status === "failed" ? s.error : undefined
60
+ }
61
+ const enabled = () => status() === "connected"
62
+ return (
63
+ <div class="w-full flex items-center justify-between gap-x-3">
64
+ <div class="flex flex-col gap-0.5 min-w-0">
65
+ <div class="flex items-center gap-2">
66
+ <span class="truncate">{i.name}</span>
67
+ <Show when={status() === "connected"}>
68
+ <span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span>
69
+ </Show>
70
+ <Show when={status() === "failed"}>
71
+ <span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span>
72
+ </Show>
73
+ <Show when={status() === "needs_auth"}>
74
+ <span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span>
75
+ </Show>
76
+ <Show when={status() === "disabled"}>
77
+ <span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span>
78
+ </Show>
79
+ <Show when={loading() === i.name}>
80
+ <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
81
+ </Show>
82
+ </div>
83
+ <Show when={error()}>
84
+ <span class="text-11-regular text-text-weaker truncate">{error()}</span>
85
+ </Show>
86
+ </div>
87
+ <div onClick={(e) => e.stopPropagation()}>
88
+ <Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
89
+ </div>
90
+ </div>
91
+ )
92
+ }}
93
+ </List>
94
+ </Dialog>
95
+ )
96
+ }
@@ -0,0 +1,130 @@
1
+ import { Button } from "@jonsoc/ui/button"
2
+ import { useDialog } from "@jonsoc/ui/context/dialog"
3
+ import { Dialog } from "@jonsoc/ui/dialog"
4
+ import type { IconName } from "@jonsoc/ui/icons/provider"
5
+ import { List, type ListRef } from "@jonsoc/ui/list"
6
+ import { ProviderIcon } from "@jonsoc/ui/provider-icon"
7
+ import { Tag } from "@jonsoc/ui/tag"
8
+ import { Tooltip } from "@jonsoc/ui/tooltip"
9
+ import { type Component, onCleanup, onMount, Show } from "solid-js"
10
+ import { useLocal } from "@/context/local"
11
+ import { popularProviders, useProviders } from "@/hooks/use-providers"
12
+ import { DialogConnectProvider } from "./dialog-connect-provider"
13
+ import { DialogSelectProvider } from "./dialog-select-provider"
14
+ import { ModelTooltip } from "./model-tooltip"
15
+ import { useLanguage } from "@/context/language"
16
+
17
+ export const DialogSelectModelUnpaid: Component = () => {
18
+ const local = useLocal()
19
+ const dialog = useDialog()
20
+ const providers = useProviders()
21
+ const language = useLanguage()
22
+
23
+ let listRef: ListRef | undefined
24
+ const handleKey = (e: KeyboardEvent) => {
25
+ if (e.key === "Escape") return
26
+ listRef?.onKeyDown(e)
27
+ }
28
+
29
+ onMount(() => {
30
+ document.addEventListener("keydown", handleKey)
31
+ onCleanup(() => {
32
+ document.removeEventListener("keydown", handleKey)
33
+ })
34
+ })
35
+
36
+ return (
37
+ <Dialog title={language.t("dialog.model.select.title")}>
38
+ <div class="flex flex-col gap-3 px-2.5">
39
+ <div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
40
+ <List
41
+ ref={(ref) => (listRef = ref)}
42
+ items={local.model.list}
43
+ current={local.model.current()}
44
+ key={(x) => `${x.provider.id}:${x.id}`}
45
+ itemWrapper={(item, node) => (
46
+ <Tooltip
47
+ class="w-full"
48
+ placement="right-start"
49
+ gutter={12}
50
+ value={
51
+ <ModelTooltip
52
+ model={item}
53
+ latest={item.latest}
54
+ free={item.provider.id === "jonsoc" && (!item.cost || item.cost.input === 0)}
55
+ />
56
+ }
57
+ >
58
+ {node}
59
+ </Tooltip>
60
+ )}
61
+ onSelect={(x) => {
62
+ local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
63
+ recent: true,
64
+ })
65
+ dialog.close()
66
+ }}
67
+ >
68
+ {(i) => (
69
+ <div class="w-full flex items-center gap-x-2.5">
70
+ <span>{i.name}</span>
71
+ <Tag>{language.t("model.tag.free")}</Tag>
72
+ <Show when={i.latest}>
73
+ <Tag>{language.t("model.tag.latest")}</Tag>
74
+ </Show>
75
+ </div>
76
+ )}
77
+ </List>
78
+ <div />
79
+ <div />
80
+ </div>
81
+ <div class="px-1.5 pb-1.5">
82
+ <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
83
+ <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
84
+ <div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
85
+ <div class="w-full">
86
+ <List
87
+ class="w-full px-0"
88
+ key={(x) => x?.id}
89
+ items={providers.popular}
90
+ activeIcon="plus-small"
91
+ sortBy={(a, b) => {
92
+ if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
93
+ return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
94
+ return a.name.localeCompare(b.name)
95
+ }}
96
+ onSelect={(x) => {
97
+ if (!x) return
98
+ dialog.show(() => <DialogConnectProvider provider={x.id} />)
99
+ }}
100
+ >
101
+ {(i) => (
102
+ <div class="w-full flex items-center gap-x-3">
103
+ <ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
104
+ <span>{i.name}</span>
105
+ <Show when={i.id === "jonsoc"}>
106
+ <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
107
+ </Show>
108
+ <Show when={i.id === "anthropic"}>
109
+ <div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
110
+ </Show>
111
+ </div>
112
+ )}
113
+ </List>
114
+ <Button
115
+ variant="ghost"
116
+ class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
117
+ icon="dot-grid"
118
+ onClick={() => {
119
+ dialog.show(() => <DialogSelectProvider />)
120
+ }}
121
+ >
122
+ {language.t("dialog.provider.viewAll")}
123
+ </Button>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </Dialog>
129
+ )
130
+ }
@@ -0,0 +1,162 @@
1
+ import { Popover as Kobalte } from "@kobalte/core/popover"
2
+ import { Component, ComponentProps, createMemo, createSignal, JSX, Show, ValidComponent } from "solid-js"
3
+ import { useLocal } from "@/context/local"
4
+ import { useDialog } from "@jonsoc/ui/context/dialog"
5
+ import { popularProviders } from "@/hooks/use-providers"
6
+ import { Button } from "@jonsoc/ui/button"
7
+ import { IconButton } from "@jonsoc/ui/icon-button"
8
+ import { Tag } from "@jonsoc/ui/tag"
9
+ import { Dialog } from "@jonsoc/ui/dialog"
10
+ import { List } from "@jonsoc/ui/list"
11
+ import { Tooltip } from "@jonsoc/ui/tooltip"
12
+ import { DialogSelectProvider } from "./dialog-select-provider"
13
+ import { DialogManageModels } from "./dialog-manage-models"
14
+ import { ModelTooltip } from "./model-tooltip"
15
+ import { useLanguage } from "@/context/language"
16
+
17
+ const ModelList: Component<{
18
+ provider?: string
19
+ class?: string
20
+ onSelect: () => void
21
+ action?: JSX.Element
22
+ }> = (props) => {
23
+ const local = useLocal()
24
+ const language = useLanguage()
25
+
26
+ const models = createMemo(() =>
27
+ local.model
28
+ .list()
29
+ .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
30
+ .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
31
+ )
32
+
33
+ return (
34
+ <List
35
+ class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
36
+ search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
37
+ emptyMessage={language.t("dialog.model.empty")}
38
+ key={(x) => `${x.provider.id}:${x.id}`}
39
+ items={models}
40
+ current={local.model.current()}
41
+ filterKeys={["provider.name", "name", "id"]}
42
+ sortBy={(a, b) => a.name.localeCompare(b.name)}
43
+ groupBy={(x) => x.provider.name}
44
+ sortGroupsBy={(a, b) => {
45
+ const aProvider = a.items[0].provider.id
46
+ const bProvider = b.items[0].provider.id
47
+ if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
48
+ if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
49
+ return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
50
+ }}
51
+ itemWrapper={(item, node) => (
52
+ <Tooltip
53
+ class="w-full"
54
+ placement="right-start"
55
+ gutter={12}
56
+ value={
57
+ <ModelTooltip
58
+ model={item}
59
+ latest={item.latest}
60
+ free={item.provider.id === "jonsoc" && (!item.cost || item.cost.input === 0)}
61
+ />
62
+ }
63
+ >
64
+ {node}
65
+ </Tooltip>
66
+ )}
67
+ onSelect={(x) => {
68
+ local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
69
+ recent: true,
70
+ })
71
+ props.onSelect()
72
+ }}
73
+ >
74
+ {(i) => (
75
+ <div class="w-full flex items-center gap-x-2 text-13-regular">
76
+ <span class="truncate">{i.name}</span>
77
+ <Show when={i.provider.id === "jonsoc" && (!i.cost || i.cost?.input === 0)}>
78
+ <Tag>{language.t("model.tag.free")}</Tag>
79
+ </Show>
80
+ <Show when={i.latest}>
81
+ <Tag>{language.t("model.tag.latest")}</Tag>
82
+ </Show>
83
+ </div>
84
+ )}
85
+ </List>
86
+ )
87
+ }
88
+
89
+ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
90
+ provider?: string
91
+ children?: JSX.Element
92
+ triggerAs?: T
93
+ triggerProps?: ComponentProps<T>
94
+ }) {
95
+ const [open, setOpen] = createSignal(false)
96
+ const dialog = useDialog()
97
+
98
+ const handleManage = () => {
99
+ setOpen(false)
100
+ dialog.show(() => <DialogManageModels />)
101
+ }
102
+ const language = useLanguage()
103
+
104
+ return (
105
+ <Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
106
+ <Kobalte.Trigger as={props.triggerAs ?? "div"} {...(props.triggerProps as any)}>
107
+ {props.children}
108
+ </Kobalte.Trigger>
109
+ <Kobalte.Portal>
110
+ <Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
111
+ <Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
112
+ <ModelList
113
+ provider={props.provider}
114
+ onSelect={() => setOpen(false)}
115
+ class="p-1"
116
+ action={
117
+ <IconButton
118
+ icon="sliders"
119
+ variant="ghost"
120
+ iconSize="normal"
121
+ class="size-6"
122
+ aria-label={language.t("dialog.model.manage")}
123
+ title={language.t("dialog.model.manage")}
124
+ onClick={handleManage}
125
+ />
126
+ }
127
+ />
128
+ </Kobalte.Content>
129
+ </Kobalte.Portal>
130
+ </Kobalte>
131
+ )
132
+ }
133
+
134
+ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
135
+ const dialog = useDialog()
136
+ const language = useLanguage()
137
+
138
+ return (
139
+ <Dialog
140
+ title={language.t("dialog.model.select.title")}
141
+ action={
142
+ <Button
143
+ class="h-7 -my-1 text-14-medium"
144
+ icon="plus-small"
145
+ tabIndex={-1}
146
+ onClick={() => dialog.show(() => <DialogSelectProvider />)}
147
+ >
148
+ {language.t("command.provider.connect")}
149
+ </Button>
150
+ }
151
+ >
152
+ <ModelList provider={props.provider} onSelect={() => dialog.close()} />
153
+ <Button
154
+ variant="ghost"
155
+ class="ml-3 mt-5 mb-6 text-text-base self-start"
156
+ onClick={() => dialog.show(() => <DialogManageModels />)}
157
+ >
158
+ {language.t("dialog.model.manage")}
159
+ </Button>
160
+ </Dialog>
161
+ )
162
+ }
@@ -0,0 +1,70 @@
1
+ import { Component, Show } from "solid-js"
2
+ import { useDialog } from "@jonsoc/ui/context/dialog"
3
+ import { popularProviders, useProviders } from "@/hooks/use-providers"
4
+ import { Dialog } from "@jonsoc/ui/dialog"
5
+ import { List } from "@jonsoc/ui/list"
6
+ import { Tag } from "@jonsoc/ui/tag"
7
+ import { ProviderIcon } from "@jonsoc/ui/provider-icon"
8
+ import { IconName } from "@jonsoc/ui/icons/provider"
9
+ import { DialogConnectProvider } from "./dialog-connect-provider"
10
+ import { useLanguage } from "@/context/language"
11
+
12
+ export const DialogSelectProvider: Component = () => {
13
+ const dialog = useDialog()
14
+ const providers = useProviders()
15
+ const language = useLanguage()
16
+
17
+ const popularGroup = () => language.t("dialog.provider.group.popular")
18
+ const otherGroup = () => language.t("dialog.provider.group.other")
19
+
20
+ return (
21
+ <Dialog title={language.t("command.provider.connect")}>
22
+ <List
23
+ search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
24
+ emptyMessage={language.t("dialog.provider.empty")}
25
+ activeIcon="plus-small"
26
+ key={(x) => x?.id}
27
+ items={() => {
28
+ language.locale()
29
+ return providers.all()
30
+ }}
31
+ filterKeys={["id", "name"]}
32
+ groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
33
+ sortBy={(a, b) => {
34
+ if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
35
+ return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
36
+ return a.name.localeCompare(b.name)
37
+ }}
38
+ sortGroupsBy={(a, b) => {
39
+ const popular = popularGroup()
40
+ if (a.category === popular && b.category !== popular) return -1
41
+ if (b.category === popular && a.category !== popular) return 1
42
+ return 0
43
+ }}
44
+ onSelect={(x) => {
45
+ if (!x) return
46
+ dialog.show(() => <DialogConnectProvider provider={x.id} />)
47
+ }}
48
+ >
49
+ {(i) => (
50
+ <div class="px-1.25 w-full flex items-center gap-x-3">
51
+ <ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
52
+ <span>{i.name}</span>
53
+ <Show when={i.id === "jonsoc"}>
54
+ <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
55
+ </Show>
56
+ <Show when={i.id === "anthropic"}>
57
+ <div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
58
+ </Show>
59
+ <Show when={i.id === "openai"}>
60
+ <div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
61
+ </Show>
62
+ <Show when={i.id.startsWith("github-copilot")}>
63
+ <div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
64
+ </Show>
65
+ </div>
66
+ )}
67
+ </List>
68
+ </Dialog>
69
+ )
70
+ }