@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,249 @@
1
+ import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
2
+ import { createStore, reconcile } from "solid-js/store"
3
+ import { useDialog } from "@jonsoc/ui/context/dialog"
4
+ import { Dialog } from "@jonsoc/ui/dialog"
5
+ import { List } from "@jonsoc/ui/list"
6
+ import { TextField } from "@jonsoc/ui/text-field"
7
+ import { Button } from "@jonsoc/ui/button"
8
+ import { IconButton } from "@jonsoc/ui/icon-button"
9
+ import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
10
+ import { usePlatform } from "@/context/platform"
11
+ import { createOpencodeClient } from "@jonsoc/sdk/v2/client"
12
+ import { useNavigate } from "@solidjs/router"
13
+ import { useLanguage } from "@/context/language"
14
+
15
+ type ServerStatus = { healthy: boolean; version?: string }
16
+
17
+ async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> {
18
+ const sdk = createOpencodeClient({
19
+ baseUrl: url,
20
+ fetch,
21
+ signal: AbortSignal.timeout(3000),
22
+ })
23
+ return sdk.global
24
+ .health()
25
+ .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
26
+ .catch(() => ({ healthy: false }))
27
+ }
28
+
29
+ export function DialogSelectServer() {
30
+ const navigate = useNavigate()
31
+ const dialog = useDialog()
32
+ const server = useServer()
33
+ const platform = usePlatform()
34
+ const language = useLanguage()
35
+ const [store, setStore] = createStore({
36
+ url: "",
37
+ adding: false,
38
+ error: "",
39
+ status: {} as Record<string, ServerStatus | undefined>,
40
+ })
41
+ const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
42
+ const isDesktop = platform.platform === "desktop"
43
+
44
+ const items = createMemo(() => {
45
+ const current = server.url
46
+ const list = server.list
47
+ if (!current) return list
48
+ if (!list.includes(current)) return [current, ...list]
49
+ return [current, ...list.filter((x) => x !== current)]
50
+ })
51
+
52
+ const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
53
+
54
+ const sortedItems = createMemo(() => {
55
+ const list = items()
56
+ if (!list.length) return list
57
+ const active = current()
58
+ const order = new Map(list.map((url, index) => [url, index] as const))
59
+ const rank = (value?: ServerStatus) => {
60
+ if (value?.healthy === true) return 0
61
+ if (value?.healthy === false) return 2
62
+ return 1
63
+ }
64
+ return list.slice().sort((a, b) => {
65
+ if (a === active) return -1
66
+ if (b === active) return 1
67
+ const diff = rank(store.status[a]) - rank(store.status[b])
68
+ if (diff !== 0) return diff
69
+ return (order.get(a) ?? 0) - (order.get(b) ?? 0)
70
+ })
71
+ })
72
+
73
+ async function refreshHealth() {
74
+ const results: Record<string, ServerStatus> = {}
75
+ await Promise.all(
76
+ items().map(async (url) => {
77
+ results[url] = await checkHealth(url, platform.fetch)
78
+ }),
79
+ )
80
+ setStore("status", reconcile(results))
81
+ }
82
+
83
+ createEffect(() => {
84
+ items()
85
+ refreshHealth()
86
+ const interval = setInterval(refreshHealth, 10_000)
87
+ onCleanup(() => clearInterval(interval))
88
+ })
89
+
90
+ function select(value: string, persist?: boolean) {
91
+ if (!persist && store.status[value]?.healthy === false) return
92
+ dialog.close()
93
+ if (persist) {
94
+ server.add(value)
95
+ navigate("/")
96
+ return
97
+ }
98
+ server.setActive(value)
99
+ navigate("/")
100
+ }
101
+
102
+ async function handleSubmit(e: SubmitEvent) {
103
+ e.preventDefault()
104
+ const value = normalizeServerUrl(store.url)
105
+ if (!value) return
106
+
107
+ setStore("adding", true)
108
+ setStore("error", "")
109
+
110
+ const result = await checkHealth(value, platform.fetch)
111
+ setStore("adding", false)
112
+
113
+ if (!result.healthy) {
114
+ setStore("error", language.t("dialog.server.add.error"))
115
+ return
116
+ }
117
+
118
+ setStore("url", "")
119
+ select(value, true)
120
+ }
121
+
122
+ async function handleRemove(url: string) {
123
+ server.remove(url)
124
+ }
125
+
126
+ return (
127
+ <Dialog title={language.t("dialog.server.title")} description={language.t("dialog.server.description")}>
128
+ <div class="flex flex-col gap-4 pb-4">
129
+ <List
130
+ search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: true }}
131
+ emptyMessage={language.t("dialog.server.empty")}
132
+ items={sortedItems}
133
+ key={(x) => x}
134
+ current={current()}
135
+ onSelect={(x) => {
136
+ if (x) select(x)
137
+ }}
138
+ >
139
+ {(i) => (
140
+ <div class="flex items-center gap-2 min-w-0 flex-1 group/item">
141
+ <div
142
+ class="flex items-center gap-2 min-w-0 flex-1"
143
+ classList={{ "opacity-50": store.status[i]?.healthy === false }}
144
+ >
145
+ <div
146
+ classList={{
147
+ "size-1.5 rounded-full shrink-0": true,
148
+ "bg-icon-success-base": store.status[i]?.healthy === true,
149
+ "bg-icon-critical-base": store.status[i]?.healthy === false,
150
+ "bg-border-weak-base": store.status[i] === undefined,
151
+ }}
152
+ />
153
+ <span class="truncate">{serverDisplayName(i)}</span>
154
+ <span class="text-text-weak">{store.status[i]?.version}</span>
155
+ </div>
156
+ <Show when={current() !== i && server.list.includes(i)}>
157
+ <IconButton
158
+ icon="circle-x"
159
+ variant="ghost"
160
+ class="bg-transparent transition-opacity shrink-0 hover:scale-110"
161
+ aria-label={language.t("dialog.server.action.remove")}
162
+ onClick={(e) => {
163
+ e.stopPropagation()
164
+ handleRemove(i)
165
+ }}
166
+ />
167
+ </Show>
168
+ </div>
169
+ )}
170
+ </List>
171
+
172
+ <div class="mt-6 px-3 flex flex-col gap-1.5">
173
+ <div class="px-3">
174
+ <h3 class="text-14-regular text-text-weak">{language.t("dialog.server.add.title")}</h3>
175
+ </div>
176
+ <form onSubmit={handleSubmit}>
177
+ <div class="flex items-start gap-2">
178
+ <div class="flex-1 min-w-0 h-auto">
179
+ <TextField
180
+ type="text"
181
+ label={language.t("dialog.server.add.url")}
182
+ hideLabel
183
+ placeholder={language.t("dialog.server.add.placeholder")}
184
+ value={store.url}
185
+ onChange={(v) => {
186
+ setStore("url", v)
187
+ setStore("error", "")
188
+ }}
189
+ validationState={store.error ? "invalid" : "valid"}
190
+ error={store.error}
191
+ />
192
+ </div>
193
+ <Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
194
+ {store.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
195
+ </Button>
196
+ </div>
197
+ </form>
198
+ </div>
199
+
200
+ <Show when={isDesktop}>
201
+ <div class="mt-6 px-3 flex flex-col gap-1.5">
202
+ <div class="px-3">
203
+ <h3 class="text-14-regular text-text-weak">{language.t("dialog.server.default.title")}</h3>
204
+ <p class="text-12-regular text-text-weak mt-1">{language.t("dialog.server.default.description")}</p>
205
+ </div>
206
+ <div class="flex items-center gap-2 px-3 py-2">
207
+ <Show
208
+ when={defaultUrl()}
209
+ fallback={
210
+ <Show
211
+ when={server.url}
212
+ fallback={
213
+ <span class="text-14-regular text-text-weak">{language.t("dialog.server.default.none")}</span>
214
+ }
215
+ >
216
+ <Button
217
+ variant="secondary"
218
+ size="small"
219
+ onClick={async () => {
220
+ await platform.setDefaultServerUrl?.(server.url)
221
+ defaultUrlActions.refetch(server.url)
222
+ }}
223
+ >
224
+ {language.t("dialog.server.default.set")}
225
+ </Button>
226
+ </Show>
227
+ }
228
+ >
229
+ <div class="flex items-center gap-2 flex-1 min-w-0">
230
+ <span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
231
+ </div>
232
+ <Button
233
+ variant="ghost"
234
+ size="small"
235
+ onClick={async () => {
236
+ await platform.setDefaultServerUrl?.(null)
237
+ defaultUrlActions.refetch()
238
+ }}
239
+ >
240
+ {language.t("dialog.server.default.clear")}
241
+ </Button>
242
+ </Show>
243
+ </div>
244
+ </div>
245
+ </Show>
246
+ </div>
247
+ </Dialog>
248
+ )
249
+ }
@@ -0,0 +1,112 @@
1
+ import { Component } from "solid-js"
2
+ import { Dialog } from "@jonsoc/ui/dialog"
3
+ import { Tabs } from "@jonsoc/ui/tabs"
4
+ import { Icon } from "@jonsoc/ui/icon"
5
+ import { useLanguage } from "@/context/language"
6
+ import { usePlatform } from "@/context/platform"
7
+ import { SettingsGeneral } from "./settings-general"
8
+ import { SettingsKeybinds } from "./settings-keybinds"
9
+ import { SettingsPermissions } from "./settings-permissions"
10
+ import { SettingsProviders } from "./settings-providers"
11
+ import { SettingsModels } from "./settings-models"
12
+ import { SettingsAgents } from "./settings-agents"
13
+ import { SettingsCommands } from "./settings-commands"
14
+ import { SettingsMcp } from "./settings-mcp"
15
+
16
+ export const DialogSettings: Component = () => {
17
+ const language = useLanguage()
18
+ const platform = usePlatform()
19
+
20
+ return (
21
+ <Dialog size="x-large">
22
+ <Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
23
+ <Tabs.List>
24
+ <div
25
+ style={{
26
+ display: "flex",
27
+ "flex-direction": "column",
28
+ "justify-content": "space-between",
29
+ height: "100%",
30
+ width: "100%",
31
+ }}
32
+ >
33
+ <div
34
+ style={{
35
+ display: "flex",
36
+ "flex-direction": "column",
37
+ gap: "12px",
38
+ width: "100%",
39
+ "padding-top": "12px",
40
+ }}
41
+ >
42
+ <Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
43
+ <div style={{ display: "flex", "flex-direction": "column", gap: "6px", width: "100%" }}>
44
+ <Tabs.Trigger value="general">
45
+ <Icon name="sliders" />
46
+ {language.t("settings.tab.general")}
47
+ </Tabs.Trigger>
48
+ <Tabs.Trigger value="shortcuts">
49
+ <Icon name="keyboard" />
50
+ {language.t("settings.tab.shortcuts")}
51
+ </Tabs.Trigger>
52
+ </div>
53
+ </div>
54
+ <div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
55
+ <span>JonsOC Desktop</span>
56
+ <span class="text-11-regular">v{platform.version}</span>
57
+ </div>
58
+ </div>
59
+ {/* <Tabs.SectionTitle>Server</Tabs.SectionTitle> */}
60
+ {/* <Tabs.Trigger value="permissions"> */}
61
+ {/* <Icon name="checklist" /> */}
62
+ {/* Permissions */}
63
+ {/* </Tabs.Trigger> */}
64
+ {/* <Tabs.Trigger value="providers"> */}
65
+ {/* <Icon name="server" /> */}
66
+ {/* Providers */}
67
+ {/* </Tabs.Trigger> */}
68
+ {/* <Tabs.Trigger value="models"> */}
69
+ {/* <Icon name="brain" /> */}
70
+ {/* Models */}
71
+ {/* </Tabs.Trigger> */}
72
+ {/* <Tabs.Trigger value="agents"> */}
73
+ {/* <Icon name="task" /> */}
74
+ {/* Agents */}
75
+ {/* </Tabs.Trigger> */}
76
+ {/* <Tabs.Trigger value="commands"> */}
77
+ {/* <Icon name="console" /> */}
78
+ {/* Commands */}
79
+ {/* </Tabs.Trigger> */}
80
+ {/* <Tabs.Trigger value="mcp"> */}
81
+ {/* <Icon name="mcp" /> */}
82
+ {/* MCP */}
83
+ {/* </Tabs.Trigger> */}
84
+ </Tabs.List>
85
+ <Tabs.Content value="general" class="no-scrollbar">
86
+ <SettingsGeneral />
87
+ </Tabs.Content>
88
+ <Tabs.Content value="shortcuts" class="no-scrollbar">
89
+ <SettingsKeybinds />
90
+ </Tabs.Content>
91
+ {/* <Tabs.Content value="permissions" class="no-scrollbar"> */}
92
+ {/* <SettingsPermissions /> */}
93
+ {/* </Tabs.Content> */}
94
+ {/* <Tabs.Content value="providers" class="no-scrollbar"> */}
95
+ {/* <SettingsProviders /> */}
96
+ {/* </Tabs.Content> */}
97
+ {/* <Tabs.Content value="models" class="no-scrollbar"> */}
98
+ {/* <SettingsModels /> */}
99
+ {/* </Tabs.Content> */}
100
+ {/* <Tabs.Content value="agents" class="no-scrollbar"> */}
101
+ {/* <SettingsAgents /> */}
102
+ {/* </Tabs.Content> */}
103
+ {/* <Tabs.Content value="commands" class="no-scrollbar"> */}
104
+ {/* <SettingsCommands /> */}
105
+ {/* </Tabs.Content> */}
106
+ {/* <Tabs.Content value="mcp" class="no-scrollbar"> */}
107
+ {/* <SettingsMcp /> */}
108
+ {/* </Tabs.Content> */}
109
+ </Tabs>
110
+ </Dialog>
111
+ )
112
+ }
@@ -0,0 +1,112 @@
1
+ import { useLocal, type LocalFile } from "@/context/local"
2
+ import { Collapsible } from "@jonsoc/ui/collapsible"
3
+ import { FileIcon } from "@jonsoc/ui/file-icon"
4
+ import { Tooltip } from "@jonsoc/ui/tooltip"
5
+ import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js"
6
+ import { Dynamic } from "solid-js/web"
7
+
8
+ export default function FileTree(props: {
9
+ path: string
10
+ class?: string
11
+ nodeClass?: string
12
+ level?: number
13
+ onFileClick?: (file: LocalFile) => void
14
+ }) {
15
+ const local = useLocal()
16
+ const level = props.level ?? 0
17
+
18
+ const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => (
19
+ <Dynamic
20
+ component={p.as ?? "div"}
21
+ classList={{
22
+ "p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
23
+ // "bg-background-element": local.file.active()?.path === p.node.path,
24
+ [props.nodeClass ?? ""]: !!props.nodeClass,
25
+ }}
26
+ style={`padding-left: ${level * 10}px`}
27
+ draggable={true}
28
+ onDragStart={(e: any) => {
29
+ const evt = e as globalThis.DragEvent
30
+ evt.dataTransfer!.effectAllowed = "copy"
31
+ evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`)
32
+
33
+ // Create custom drag image without margins
34
+ const dragImage = document.createElement("div")
35
+ dragImage.className =
36
+ "flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1"
37
+ dragImage.style.position = "absolute"
38
+ dragImage.style.top = "-1000px"
39
+
40
+ // Copy only the icon and text content without padding
41
+ const icon = e.currentTarget.querySelector("svg")
42
+ const text = e.currentTarget.querySelector("span")
43
+ if (icon && text) {
44
+ dragImage.innerHTML = icon.outerHTML + text.outerHTML
45
+ }
46
+
47
+ document.body.appendChild(dragImage)
48
+ evt.dataTransfer!.setDragImage(dragImage, 0, 12)
49
+ setTimeout(() => document.body.removeChild(dragImage), 0)
50
+ }}
51
+ {...p}
52
+ >
53
+ {p.children}
54
+ <span
55
+ classList={{
56
+ "text-xs whitespace-nowrap truncate": true,
57
+ "text-text-muted/40": p.node.ignored,
58
+ "text-text-muted/80": !p.node.ignored,
59
+ // "!text-text": local.file.active()?.path === p.node.path,
60
+ // "!text-primary": local.file.changed(p.node.path),
61
+ }}
62
+ >
63
+ {p.node.name}
64
+ </span>
65
+ {/* <Show when={local.file.changed(p.node.path)}> */}
66
+ {/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
67
+ {/* </Show> */}
68
+ </Dynamic>
69
+ )
70
+
71
+ return (
72
+ <div class={`flex flex-col ${props.class}`}>
73
+ <For each={local.file.children(props.path)}>
74
+ {(node) => (
75
+ <Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right">
76
+ <Switch>
77
+ <Match when={node.type === "directory"}>
78
+ <Collapsible
79
+ variant="ghost"
80
+ class="w-full"
81
+ forceMount={false}
82
+ // open={local.file.node(node.path)?.expanded}
83
+ onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
84
+ >
85
+ <Collapsible.Trigger>
86
+ <Node node={node}>
87
+ <Collapsible.Arrow class="text-text-muted/60 ml-1" />
88
+ <FileIcon
89
+ node={node}
90
+ // expanded={local.file.node(node.path).expanded}
91
+ class="text-text-muted/60 -ml-1"
92
+ />
93
+ </Node>
94
+ </Collapsible.Trigger>
95
+ <Collapsible.Content>
96
+ <FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} />
97
+ </Collapsible.Content>
98
+ </Collapsible>
99
+ </Match>
100
+ <Match when={node.type === "file"}>
101
+ <Node node={node} as="button" onClick={() => props.onFileClick?.(node)}>
102
+ <div class="w-4 shrink-0" />
103
+ <FileIcon node={node} class="text-primary" />
104
+ </Node>
105
+ </Match>
106
+ </Switch>
107
+ </Tooltip>
108
+ )}
109
+ </For>
110
+ </div>
111
+ )
112
+ }
@@ -0,0 +1,17 @@
1
+ import { ComponentProps, splitProps } from "solid-js"
2
+ import { usePlatform } from "@/context/platform"
3
+
4
+ export interface LinkProps extends ComponentProps<"button"> {
5
+ href: string
6
+ }
7
+
8
+ export function Link(props: LinkProps) {
9
+ const platform = usePlatform()
10
+ const [local, rest] = splitProps(props, ["href", "children"])
11
+
12
+ return (
13
+ <button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
14
+ {local.children}
15
+ </button>
16
+ )
17
+ }
@@ -0,0 +1,91 @@
1
+ import { Show, type Component } from "solid-js"
2
+ import { useLanguage } from "@/context/language"
3
+
4
+ type InputKey = "text" | "image" | "audio" | "video" | "pdf"
5
+ type InputMap = Record<InputKey, boolean>
6
+
7
+ type ModelInfo = {
8
+ id: string
9
+ name: string
10
+ provider: {
11
+ name: string
12
+ }
13
+ capabilities?: {
14
+ reasoning: boolean
15
+ input: InputMap
16
+ }
17
+ modalities?: {
18
+ input: Array<string>
19
+ }
20
+ reasoning?: boolean
21
+ limit: {
22
+ context: number
23
+ }
24
+ }
25
+
26
+ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => {
27
+ const language = useLanguage()
28
+ const sourceName = (model: ModelInfo) => {
29
+ const value = `${model.id} ${model.name}`.toLowerCase()
30
+
31
+ if (/claude|anthropic/.test(value)) return language.t("model.provider.anthropic")
32
+ if (/gpt|o[1-4]|codex|openai/.test(value)) return language.t("model.provider.openai")
33
+ if (/gemini|palm|bard|google/.test(value)) return language.t("model.provider.google")
34
+ if (/grok|xai/.test(value)) return language.t("model.provider.xai")
35
+ if (/llama|meta/.test(value)) return language.t("model.provider.meta")
36
+
37
+ return model.provider.name
38
+ }
39
+ const inputLabel = (value: string) => {
40
+ if (value === "text") return language.t("model.input.text")
41
+ if (value === "image") return language.t("model.input.image")
42
+ if (value === "audio") return language.t("model.input.audio")
43
+ if (value === "video") return language.t("model.input.video")
44
+ if (value === "pdf") return language.t("model.input.pdf")
45
+ return value
46
+ }
47
+ const title = () => {
48
+ const tags: Array<string> = []
49
+ if (props.latest) tags.push(language.t("model.tag.latest"))
50
+ if (props.free) tags.push(language.t("model.tag.free"))
51
+ const suffix = tags.length ? ` (${tags.join(", ")})` : ""
52
+ return `${sourceName(props.model)} ${props.model.name}${suffix}`
53
+ }
54
+ const inputs = () => {
55
+ if (props.model.capabilities) {
56
+ const input = props.model.capabilities.input
57
+ const order: Array<InputKey> = ["text", "image", "audio", "video", "pdf"]
58
+ const entries = order.filter((key) => input[key]).map((key) => inputLabel(key))
59
+ return entries.length ? entries.join(", ") : undefined
60
+ }
61
+ const raw = props.model.modalities?.input
62
+ if (!raw) return
63
+ const entries = raw.map((value) => inputLabel(value))
64
+ return entries.length ? entries.join(", ") : undefined
65
+ }
66
+ const reasoning = () => {
67
+ if (props.model.capabilities)
68
+ return props.model.capabilities.reasoning
69
+ ? language.t("model.tooltip.reasoning.allowed")
70
+ : language.t("model.tooltip.reasoning.none")
71
+ return props.model.reasoning
72
+ ? language.t("model.tooltip.reasoning.allowed")
73
+ : language.t("model.tooltip.reasoning.none")
74
+ }
75
+ const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
76
+
77
+ return (
78
+ <div class="flex flex-col gap-1 py-1">
79
+ <div class="text-13-medium">{title()}</div>
80
+ <Show when={inputs()}>
81
+ {(value) => (
82
+ <div class="text-12-regular text-text-invert-base">
83
+ {language.t("model.tooltip.allows", { inputs: value() })}
84
+ </div>
85
+ )}
86
+ </Show>
87
+ <div class="text-12-regular text-text-invert-base">{reasoning()}</div>
88
+ <div class="text-12-regular text-text-invert-base">{context()}</div>
89
+ </div>
90
+ )
91
+ }