SaylorTwift HF Staff commited on
Commit
0dbc9de
·
verified ·
1 Parent(s): 89a2873

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. packages/app/public/apple-touch-icon.png +0 -0
  2. packages/app/public/favicon.ico +0 -0
  3. packages/app/public/social-share-zen.png +0 -0
  4. packages/app/public/web-app-manifest-192x192.png +0 -0
  5. packages/app/src/components/command-palette.ts +281 -0
  6. packages/app/src/components/command-tooltip-keybind.test.ts +22 -0
  7. packages/app/src/components/command-tooltip-keybind.ts +11 -0
  8. packages/app/src/components/debug-bar.tsx +587 -0
  9. packages/app/src/components/dialog-command-palette-v2.css +220 -0
  10. packages/app/src/components/dialog-command-palette-v2.tsx +342 -0
  11. packages/app/src/components/dialog-connect-provider.stories.tsx +73 -0
  12. packages/app/src/components/dialog-connect-provider.tsx +1191 -0
  13. packages/app/src/components/dialog-custom-provider-form.ts +158 -0
  14. packages/app/src/components/dialog-custom-provider.test.ts +80 -0
  15. packages/app/src/components/dialog-custom-provider.tsx +330 -0
  16. packages/app/src/components/dialog-edit-project-v2.tsx +156 -0
  17. packages/app/src/components/dialog-edit-project.tsx +170 -0
  18. packages/app/src/components/dialog-fork.tsx +104 -0
  19. packages/app/src/components/dialog-manage-models.tsx +265 -0
  20. packages/app/src/components/dialog-release-notes.tsx +144 -0
  21. packages/app/src/components/dialog-select-directory-v2.css +107 -0
  22. packages/app/src/components/dialog-select-directory-v2.tsx +386 -0
  23. packages/app/src/components/dialog-select-directory.tsx +200 -0
  24. packages/app/src/components/dialog-select-file.tsx +189 -0
  25. packages/app/src/components/dialog-select-mcp.tsx +92 -0
  26. packages/app/src/components/dialog-select-model-search.test.ts +19 -0
  27. packages/app/src/components/dialog-select-model-search.ts +18 -0
  28. packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx +55 -0
  29. packages/app/src/components/dialog-select-model-unpaid-v2.tsx +176 -0
  30. packages/app/src/components/dialog-select-model-unpaid.tsx +147 -0
  31. packages/app/src/components/dialog-select-server.tsx +722 -0
  32. packages/app/src/components/dialog-settings.tsx +94 -0
  33. packages/app/src/components/dialog-usage-exceeded.tsx +46 -0
  34. packages/app/src/components/directory-picker-domain.test.ts +310 -0
  35. packages/app/src/components/directory-picker-domain.ts +414 -0
  36. packages/app/src/components/directory-picker-policy.ts +7 -0
  37. packages/app/src/components/directory-picker.test.ts +21 -0
  38. packages/app/src/components/directory-picker.tsx +45 -0
  39. packages/app/src/components/edit-project.ts +133 -0
  40. packages/app/src/components/external-link.tsx +21 -0
  41. packages/app/src/components/file-tree-v2-model.test.ts +74 -0
  42. packages/app/src/components/file-tree-v2-model.ts +107 -0
  43. packages/app/src/components/file-tree-v2.tsx +301 -0
  44. packages/app/src/components/file-tree.test.ts +80 -0
  45. packages/app/src/components/file-tree.tsx +509 -0
  46. packages/app/src/components/help-button.tsx +143 -0
  47. packages/app/src/components/model-tooltip.tsx +124 -0
  48. packages/app/src/components/pierre-tree.test.ts +23 -0
  49. packages/app/src/components/prompt-input-v2.tsx +589 -0
  50. packages/app/src/components/prompt-input.stories.tsx +223 -0
packages/app/public/apple-touch-icon.png ADDED
packages/app/public/favicon.ico ADDED
packages/app/public/social-share-zen.png ADDED
packages/app/public/web-app-manifest-192x192.png ADDED
packages/app/src/components/command-palette.ts ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getFilename } from "@opencode-ai/core/util/path"
2
+ import type { Project } from "@opencode-ai/sdk/v2/client"
3
+ import type { SessionInfo } from "@opencode-ai/client/promise"
4
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
5
+ import { createMemo, onCleanup } from "solid-js"
6
+ import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
7
+ import { useFile } from "@/context/file"
8
+ import { useGlobal } from "@/context/global"
9
+ import { useLanguage } from "@/context/language"
10
+ import { useLayout, type LocalProject } from "@/context/layout"
11
+ import { ServerConnection } from "@/context/server"
12
+ import { useServerSDK } from "@/context/server-sdk"
13
+ import { useTabs } from "@/context/tabs"
14
+ import { displayName, projectForSession } from "@/pages/layout/helpers"
15
+ import { createSessionTabs } from "@/pages/session/helpers"
16
+ import { useSessionLayout } from "@/pages/session/session-layout"
17
+ import { normalizeSessionInfo } from "@/utils/session"
18
+
19
+ export type CommandPaletteEntry = {
20
+ id: string
21
+ type: "command" | "file" | "session"
22
+ title: string
23
+ description?: string
24
+ keybind?: string
25
+ category: string
26
+ option?: CommandOption
27
+ path?: string
28
+ directory?: string
29
+ sessionID?: string
30
+ server?: ServerConnection.Key
31
+ project?: LocalProject
32
+ archived?: number
33
+ updated?: number
34
+ }
35
+
36
+ const ENTRY_LIMIT = 5
37
+ const COMMON_COMMAND_IDS = [
38
+ "session.new",
39
+ "workspace.new",
40
+ "session.previous",
41
+ "session.next",
42
+ "terminal.toggle",
43
+ "review.toggle",
44
+ ] as const
45
+
46
+ export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
47
+ const seen = new Set<string>()
48
+ return items.filter((item) => {
49
+ if (seen.has(item.id)) return false
50
+ seen.add(item.id)
51
+ return true
52
+ })
53
+ }
54
+
55
+ export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
56
+ return {
57
+ id: "file:" + path,
58
+ type: "file",
59
+ title: path,
60
+ category,
61
+ path,
62
+ }
63
+ }
64
+
65
+ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
66
+ const file = useFile()
67
+ const layout = useLayout()
68
+ const { tabs, view } = useSessionLayout()
69
+
70
+ return (path: string) => {
71
+ const value = file.tab(path)
72
+ void tabs().open(value)
73
+ void file.load(path)
74
+ if (!view().reviewPanel.opened()) view().reviewPanel.open()
75
+ layout.fileTree.setTab("all")
76
+ onOpenFile?.(path)
77
+ tabs().setActive(value)
78
+ }
79
+ }
80
+
81
+ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
82
+ const command = useCommand()
83
+ const global = useGlobal()
84
+ const language = useLanguage()
85
+ const file = useFile()
86
+ const dialog = useDialog()
87
+ const serverSDK = useServerSDK()()
88
+ const serverCtx = global.ensureServerCtx(serverSDK.server)
89
+ const appTabs = useTabs()
90
+ const { tabs: sessionTabs } = useSessionLayout()
91
+ const openFile = createCommandPaletteFileOpener(props.onOpenFile)
92
+ const state = { cleanup: undefined as (() => void) | void, committed: false }
93
+ const filesOnly = () => props.filesOnly?.() ?? false
94
+
95
+ const allowedCommands = createMemo(() => {
96
+ if (filesOnly()) return []
97
+ return commandPaletteOptions(command.options)
98
+ })
99
+ const commandEntries = createMemo(() => {
100
+ const category = language.t("palette.group.commands")
101
+ return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
102
+ })
103
+ const preferredCommandEntries = createMemo(() => {
104
+ const all = allowedCommands()
105
+ const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
106
+ const picked = all.filter((option) => order.has(option.id))
107
+ const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
108
+ const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
109
+ const category = language.t("palette.group.commands")
110
+ return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
111
+ })
112
+
113
+ const tabState = createSessionTabs({
114
+ tabs: sessionTabs,
115
+ pathFromTab: file.pathFromTab,
116
+ normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
117
+ })
118
+ const recentFileEntries = createMemo(() => {
119
+ const all = tabState.openedTabs()
120
+ const active = tabState.activeFileTab()
121
+ const order = active ? [active, ...all.filter((item) => item !== active)] : all
122
+ const seen = new Set<string>()
123
+ const category = language.t("palette.group.files")
124
+ return order
125
+ .map((item) => file.pathFromTab(item))
126
+ .filter((path): path is string => {
127
+ if (!path || seen.has(path)) return false
128
+ seen.add(path)
129
+ return true
130
+ })
131
+ .slice(0, ENTRY_LIMIT)
132
+ .map((path) => createCommandPaletteFileEntry(path, category))
133
+ })
134
+ const rootFileEntries = createMemo(() => {
135
+ const category = language.t("palette.group.files")
136
+ return file.tree
137
+ .children("")
138
+ .filter((node) => node.type === "file")
139
+ .map((node) => node.path)
140
+ .sort((a, b) => a.localeCompare(b))
141
+ .slice(0, ENTRY_LIMIT)
142
+ .map((path) => createCommandPaletteFileEntry(path, category))
143
+ })
144
+
145
+ const sessions = createServerSessionEntries({
146
+ server: ServerConnection.key(serverSDK.server),
147
+ opened: serverCtx.projects.list,
148
+ stored: () => serverCtx.sync.data.project,
149
+ load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
150
+ untitled: () => language.t("command.session.new"),
151
+ category: () => language.t("command.category.session"),
152
+ })
153
+
154
+ const highlight = (item: CommandPaletteEntry | undefined) => {
155
+ state.cleanup?.()
156
+ state.cleanup = undefined
157
+ if (item?.type !== "command") return
158
+ state.cleanup = item.option?.onHighlight?.()
159
+ }
160
+
161
+ const select = (item: CommandPaletteEntry | undefined) => {
162
+ if (!item) return
163
+ state.committed = true
164
+ state.cleanup = undefined
165
+ dialog.close()
166
+ if (item.type === "command") {
167
+ item.option?.onSelect?.("palette")
168
+ return
169
+ }
170
+ if (item.type === "session") {
171
+ if (!item.sessionID || !item.server) return
172
+ const directory = item.project?.worktree ?? item.directory
173
+ if (directory) {
174
+ serverCtx.projects.open(directory)
175
+ serverCtx.projects.touch(directory)
176
+ }
177
+ const tab = appTabs.addSessionTab({
178
+ server: item.server,
179
+ sessionId: item.sessionID,
180
+ })
181
+ appTabs.select(tab)
182
+ return
183
+ }
184
+ if (!item.path) return
185
+ openFile(item.path)
186
+ }
187
+
188
+ onCleanup(() => {
189
+ if (state.committed) return
190
+ state.cleanup?.()
191
+ })
192
+
193
+ return {
194
+ language,
195
+ file,
196
+ commandEntries,
197
+ preferredCommandEntries,
198
+ recentFileEntries,
199
+ rootFileEntries,
200
+ sessions,
201
+ highlight,
202
+ select,
203
+ close: () => dialog.close(),
204
+ }
205
+ }
206
+
207
+ export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
208
+ return {
209
+ id: "command:" + option.id,
210
+ type: "command",
211
+ title: option.title,
212
+ description: option.description,
213
+ keybind: option.keybind,
214
+ category,
215
+ option,
216
+ }
217
+ }
218
+
219
+ export function createServerSessionEntries(props: {
220
+ server: ServerConnection.Key
221
+ opened: () => LocalProject[]
222
+ stored: () => Project[]
223
+ load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
224
+ untitled: () => string
225
+ category: () => string
226
+ }) {
227
+ let abort: AbortController | undefined
228
+
229
+ onCleanup(() => abort?.abort())
230
+
231
+ return async (text: string): Promise<CommandPaletteEntry[]> => {
232
+ const search = text.trim()
233
+ if (!search) {
234
+ abort?.abort()
235
+ return []
236
+ }
237
+ abort?.abort()
238
+ const current = new AbortController()
239
+ abort = current
240
+ await new Promise<void>((resolve) => {
241
+ const timer = setTimeout(resolve, 100)
242
+ current.signal.addEventListener(
243
+ "abort",
244
+ () => {
245
+ clearTimeout(timer)
246
+ resolve()
247
+ },
248
+ { once: true },
249
+ )
250
+ })
251
+ if (current.signal.aborted) return []
252
+ const opened = props.opened()
253
+ const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
254
+ const stored = props.stored().map((project) => ({ ...project, expanded: false }))
255
+ const storedByID = new Map(stored.map((project) => [project.id, project] as const))
256
+ return props
257
+ .load(search, current.signal)
258
+ .then((result) =>
259
+ result.data
260
+ .map(normalizeSessionInfo)
261
+ .filter((session) => !session.time.archived)
262
+ .map((session) => {
263
+ const project =
264
+ projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
265
+ return {
266
+ id: `session:${props.server}:${session.id}`,
267
+ type: "session" as const,
268
+ title: session.title || props.untitled(),
269
+ description: project ? displayName(project) : getFilename(session.directory),
270
+ category: props.category(),
271
+ directory: session.directory,
272
+ sessionID: session.id,
273
+ server: props.server,
274
+ project,
275
+ updated: session.time.updated,
276
+ }
277
+ }),
278
+ )
279
+ .catch(() => [] as CommandPaletteEntry[])
280
+ }
281
+ }
packages/app/src/components/command-tooltip-keybind.test.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, test } from "bun:test"
2
+ import { newTabTooltipKeybind, reviewTooltipKeybind } from "./command-tooltip-keybind"
3
+
4
+ describe("command tooltip keybinds", () => {
5
+ test("keeps localized review shortcut modifiers", () => {
6
+ const command = {
7
+ keybind: () => "Ctrl+Maj+R",
8
+ keybindParts: () => ["Ctrl", "Maj", "R"],
9
+ }
10
+
11
+ expect(reviewTooltipKeybind(command, (key) => key)).toEqual(["Ctrl", "Maj", "R"])
12
+ })
13
+
14
+ test("uses the configured new-tab shortcut", () => {
15
+ const command = {
16
+ keybind: () => "Alt+N",
17
+ keybindParts: () => ["Alt", "N"],
18
+ }
19
+
20
+ expect(newTabTooltipKeybind(command, (key) => key)).toEqual(["Alt", "N"])
21
+ })
22
+ })
packages/app/src/components/command-tooltip-keybind.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ type CommandKeybind = {
2
+ keybindParts: (id: string) => string[]
3
+ }
4
+
5
+ export function reviewTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
6
+ return command.keybindParts("review.toggle")
7
+ }
8
+
9
+ export function newTabTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
10
+ return command.keybindParts("tab.new")
11
+ }
packages/app/src/components/debug-bar.tsx ADDED
@@ -0,0 +1,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useIsRouting, useLocation } from "@solidjs/router"
2
+ import { batch, createEffect, onCleanup, onMount } from "solid-js"
3
+ import { createStore } from "solid-js/store"
4
+ import { makeEventListener } from "@solid-primitives/event-listener"
5
+ import { Tooltip } from "@opencode-ai/ui/tooltip"
6
+ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
7
+ import { useLanguage } from "@/context/language"
8
+ import { usePlatform } from "@/context/platform"
9
+
10
+ type Mem = Performance & {
11
+ memory?: {
12
+ usedJSHeapSize: number
13
+ jsHeapSizeLimit: number
14
+ }
15
+ }
16
+
17
+ type Evt = PerformanceEntry & {
18
+ interactionId?: number
19
+ processingStart?: number
20
+ }
21
+
22
+ type Shift = PerformanceEntry & {
23
+ hadRecentInput: boolean
24
+ value: number
25
+ }
26
+
27
+ type Obs = PerformanceObserverInit & {
28
+ durationThreshold?: number
29
+ }
30
+
31
+ const span = 5000
32
+
33
+ const ms = (n?: number, d = 0) => {
34
+ if (n === undefined || Number.isNaN(n)) return
35
+ return `${n.toFixed(d)}ms`
36
+ }
37
+
38
+ const time = (n?: number) => {
39
+ if (n === undefined || Number.isNaN(n)) return
40
+ return `${Math.round(n)}`
41
+ }
42
+
43
+ const mb = (n?: number) => {
44
+ if (n === undefined || Number.isNaN(n)) return
45
+ const v = n / 1024 / 1024
46
+ return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
47
+ }
48
+
49
+ const bad = (n: number | undefined, limit: number, low = false) => {
50
+ if (n === undefined || Number.isNaN(n)) return false
51
+ return low ? n < limit : n > limit
52
+ }
53
+
54
+ const session = (path: string) => path.includes("/session")
55
+
56
+ function Cell(props: {
57
+ bad?: boolean
58
+ dim?: boolean
59
+ inline?: boolean
60
+ label: string
61
+ tip: string
62
+ value: string
63
+ span?: 2 | 3
64
+ }) {
65
+ const content = () => (
66
+ <div
67
+ classList={{
68
+ "flex min-w-0 items-center": true,
69
+ "min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
70
+ "justify-center text-center": !props.inline,
71
+ "min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
72
+ "col-span-2": props.span === 2 && !props.inline,
73
+ "col-span-3": props.span === 3 && !props.inline,
74
+ }}
75
+ >
76
+ <div
77
+ classList={{
78
+ "flex min-w-0": true,
79
+ "-translate-y-px items-baseline gap-1.5": !!props.inline,
80
+ "flex-col items-center": !props.inline,
81
+ }}
82
+ >
83
+ <div
84
+ classList={{
85
+ "text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
86
+ }}
87
+ >
88
+ {props.label}
89
+ </div>
90
+ <div
91
+ classList={{
92
+ "uppercase leading-none font-bold tabular-nums": true,
93
+ "text-[11px]": !!props.inline,
94
+ "text-[13px] sm:text-[14px]": !props.inline,
95
+ "text-text-on-critical-base": !!props.bad,
96
+ "opacity-70": !!props.dim,
97
+ }}
98
+ >
99
+ {props.value}
100
+ </div>
101
+ </div>
102
+ </div>
103
+ )
104
+
105
+ if (props.inline) {
106
+ return (
107
+ <TooltipV2 value={props.tip} placement="top">
108
+ {content()}
109
+ </TooltipV2>
110
+ )
111
+ }
112
+
113
+ return (
114
+ <Tooltip value={props.tip} placement="top">
115
+ {content()}
116
+ </Tooltip>
117
+ )
118
+ }
119
+
120
+ function ToggleCell(props: {
121
+ active: boolean
122
+ inline?: boolean
123
+ label: string
124
+ onClick: () => void
125
+ tip: string
126
+ value: string
127
+ }) {
128
+ const content = () => (
129
+ <button
130
+ type="button"
131
+ aria-label={`${props.label}: ${props.value}`}
132
+ aria-pressed={props.active}
133
+ classList={{
134
+ "flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
135
+ "min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
136
+ "min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
137
+ "bg-surface-raised-base text-text-strong": props.active,
138
+ }}
139
+ onClick={props.onClick}
140
+ >
141
+ <span
142
+ classList={{
143
+ flex: true,
144
+ "-translate-y-px items-baseline gap-1.5": !!props.inline,
145
+ "flex-col items-center": !props.inline,
146
+ }}
147
+ >
148
+ <span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
149
+ <span class="text-[11px] leading-none font-bold">{props.value}</span>
150
+ </span>
151
+ </button>
152
+ )
153
+
154
+ if (props.inline) {
155
+ return (
156
+ <TooltipV2 value={props.tip} placement="top">
157
+ {content()}
158
+ </TooltipV2>
159
+ )
160
+ }
161
+
162
+ return (
163
+ <Tooltip value={props.tip} placement="top">
164
+ {content()}
165
+ </Tooltip>
166
+ )
167
+ }
168
+
169
+ export function DebugBar(props: { inline?: boolean } = {}) {
170
+ const language = useLanguage()
171
+ const platform = usePlatform()
172
+ const location = useLocation()
173
+ const routing = useIsRouting()
174
+ const [state, setState] = createStore({
175
+ cls: undefined as number | undefined,
176
+ delay: undefined as number | undefined,
177
+ fps: undefined as number | undefined,
178
+ gap: undefined as number | undefined,
179
+ focus: false,
180
+ heap: {
181
+ limit: undefined as number | undefined,
182
+ used: undefined as number | undefined,
183
+ },
184
+ inp: undefined as number | undefined,
185
+ jank: undefined as number | undefined,
186
+ long: {
187
+ block: undefined as number | undefined,
188
+ count: undefined as number | undefined,
189
+ max: undefined as number | undefined,
190
+ },
191
+ nav: {
192
+ dur: undefined as number | undefined,
193
+ pending: false,
194
+ },
195
+ })
196
+
197
+ const na = () => language.t("debugBar.na").toUpperCase()
198
+ const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
199
+ const heapv = () => {
200
+ const value = heap()
201
+ if (value === undefined) return na()
202
+ return `${Math.round(value * 100)}%`
203
+ }
204
+ const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
205
+ const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
206
+ const toggleFocus = async () => {
207
+ if (!platform.setForceFocus) return
208
+ const enabled = !state.focus
209
+ await platform.setForceFocus(enabled)
210
+ setState("focus", enabled)
211
+ }
212
+
213
+ onCleanup(() => {
214
+ if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
215
+ })
216
+
217
+ let prev = ""
218
+ let start = 0
219
+ let init = false
220
+ let one = 0
221
+ let two = 0
222
+
223
+ createEffect(() => {
224
+ const busy = routing()
225
+ const next = `${location.pathname}${location.search}`
226
+
227
+ if (!init) {
228
+ init = true
229
+ prev = next
230
+ return
231
+ }
232
+
233
+ if (busy) {
234
+ if (one !== 0) cancelAnimationFrame(one)
235
+ if (two !== 0) cancelAnimationFrame(two)
236
+ one = 0
237
+ two = 0
238
+ if (start !== 0) return
239
+ start = performance.now()
240
+ if (session(prev)) setState("nav", { dur: undefined, pending: true })
241
+ return
242
+ }
243
+
244
+ if (start === 0) {
245
+ prev = next
246
+ return
247
+ }
248
+
249
+ const at = start
250
+ const from = prev
251
+ start = 0
252
+ prev = next
253
+
254
+ if (!(session(from) || session(next))) return
255
+
256
+ if (one !== 0) cancelAnimationFrame(one)
257
+ if (two !== 0) cancelAnimationFrame(two)
258
+ one = requestAnimationFrame(() => {
259
+ one = 0
260
+ two = requestAnimationFrame(() => {
261
+ two = 0
262
+ setState("nav", { dur: performance.now() - at, pending: false })
263
+ })
264
+ })
265
+ })
266
+
267
+ onMount(() => {
268
+ const obs: PerformanceObserver[] = []
269
+ const fps: Array<{ at: number; dur: number }> = []
270
+ const long: Array<{ at: number; dur: number }> = []
271
+ const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
272
+ let hasLong = false
273
+ let poll: number | undefined
274
+ let raf = 0
275
+ let last = 0
276
+ let snap = 0
277
+
278
+ const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
279
+ while (list[0] && at - list[0].at > span) list.shift()
280
+ }
281
+
282
+ const syncFrame = (at: number) => {
283
+ trim(fps, span, at)
284
+ const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
285
+ const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
286
+ const jank = fps.filter((entry) => entry.dur > 32).length
287
+ batch(() => {
288
+ setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
289
+ setState("gap", gap > 0 ? gap : undefined)
290
+ setState("jank", jank)
291
+ })
292
+ }
293
+
294
+ const syncLong = (at = performance.now()) => {
295
+ if (!hasLong) return
296
+ trim(long, span, at)
297
+ const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
298
+ const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
299
+ setState("long", { block, count: long.length, max })
300
+ }
301
+
302
+ const syncInp = (at = performance.now()) => {
303
+ for (const [key, entry] of seen) {
304
+ if (at - entry.at > span) seen.delete(key)
305
+ }
306
+ let delay = 0
307
+ let inp = 0
308
+ for (const entry of seen.values()) {
309
+ delay = Math.max(delay, entry.delay)
310
+ inp = Math.max(inp, entry.dur)
311
+ }
312
+ batch(() => {
313
+ setState("delay", delay > 0 ? delay : undefined)
314
+ setState("inp", inp > 0 ? inp : undefined)
315
+ })
316
+ }
317
+
318
+ const syncHeap = () => {
319
+ const mem = (performance as Mem).memory
320
+ if (!mem) return
321
+ setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
322
+ }
323
+
324
+ const reset = () => {
325
+ fps.length = 0
326
+ long.length = 0
327
+ seen.clear()
328
+ last = 0
329
+ snap = 0
330
+ batch(() => {
331
+ setState("fps", undefined)
332
+ setState("gap", undefined)
333
+ setState("jank", undefined)
334
+ setState("delay", undefined)
335
+ setState("inp", undefined)
336
+ if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
337
+ })
338
+ }
339
+
340
+ const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
341
+ if (typeof PerformanceObserver === "undefined") return false
342
+ if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
343
+ const ob = new PerformanceObserver((list) => fn(list.getEntries()))
344
+ try {
345
+ ob.observe(init)
346
+ obs.push(ob)
347
+ return true
348
+ } catch {
349
+ ob.disconnect()
350
+ return false
351
+ }
352
+ }
353
+
354
+ if (
355
+ watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
356
+ const add = entries.reduce((sum, entry) => {
357
+ const item = entry as Shift
358
+ if (item.hadRecentInput) return sum
359
+ return sum + item.value
360
+ }, 0)
361
+ if (add === 0) return
362
+ setState("cls", (value) => (value ?? 0) + add)
363
+ })
364
+ ) {
365
+ setState("cls", 0)
366
+ }
367
+
368
+ if (
369
+ watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
370
+ const at = performance.now()
371
+ long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
372
+ syncLong(at)
373
+ })
374
+ ) {
375
+ hasLong = true
376
+ setState("long", { block: 0, count: 0, max: 0 })
377
+ }
378
+
379
+ watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
380
+ for (const raw of entries) {
381
+ const entry = raw as Evt
382
+ if (entry.duration < 16) continue
383
+ const key =
384
+ entry.interactionId && entry.interactionId > 0
385
+ ? entry.interactionId
386
+ : `${entry.name}:${Math.round(entry.startTime)}`
387
+ const prev = seen.get(key)
388
+ const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
389
+ seen.set(key, {
390
+ at: entry.startTime,
391
+ delay: Math.max(prev?.delay ?? 0, delay),
392
+ dur: Math.max(prev?.dur ?? 0, entry.duration),
393
+ })
394
+ if (seen.size <= 200) continue
395
+ const first = seen.keys().next().value
396
+ if (first !== undefined) seen.delete(first)
397
+ }
398
+ syncInp()
399
+ })
400
+
401
+ const loop = (at: number) => {
402
+ if (document.visibilityState !== "visible") {
403
+ raf = 0
404
+ return
405
+ }
406
+
407
+ if (last === 0) {
408
+ last = at
409
+ raf = requestAnimationFrame(loop)
410
+ return
411
+ }
412
+
413
+ fps.push({ at, dur: at - last })
414
+ last = at
415
+
416
+ if (at - snap >= 250) {
417
+ snap = at
418
+ syncFrame(at)
419
+ }
420
+
421
+ raf = requestAnimationFrame(loop)
422
+ }
423
+
424
+ const stop = () => {
425
+ if (raf !== 0) cancelAnimationFrame(raf)
426
+ raf = 0
427
+ if (poll === undefined) return
428
+ clearInterval(poll)
429
+ poll = undefined
430
+ }
431
+
432
+ const start = () => {
433
+ if (document.visibilityState !== "visible") return
434
+ if (poll === undefined) {
435
+ poll = window.setInterval(() => {
436
+ syncLong()
437
+ syncInp()
438
+ syncHeap()
439
+ }, 1000)
440
+ }
441
+ if (raf !== 0) return
442
+ raf = requestAnimationFrame(loop)
443
+ }
444
+
445
+ const vis = () => {
446
+ if (document.visibilityState !== "visible") {
447
+ stop()
448
+ return
449
+ }
450
+ reset()
451
+ start()
452
+ }
453
+
454
+ syncHeap()
455
+ start()
456
+ makeEventListener(document, "visibilitychange", vis)
457
+
458
+ onCleanup(() => {
459
+ if (one !== 0) cancelAnimationFrame(one)
460
+ if (two !== 0) cancelAnimationFrame(two)
461
+ stop()
462
+ for (const ob of obs) ob.disconnect()
463
+ })
464
+ })
465
+
466
+ return (
467
+ <aside
468
+ aria-label={language.t("debugBar.ariaLabel")}
469
+ classList={{
470
+ "pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
471
+ "mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
472
+ "fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]":
473
+ !props.inline,
474
+ }}
475
+ >
476
+ <div
477
+ classList={{
478
+ "font-mono": true,
479
+ "gap-[9px]": !!props.inline,
480
+ "gap-px": !props.inline,
481
+ "flex w-full flex-nowrap items-center justify-start": !!props.inline,
482
+ "grid-cols-4": !props.inline,
483
+ grid: !props.inline,
484
+ }}
485
+ >
486
+ <Cell
487
+ label={language.t("debugBar.nav.label")}
488
+ tip={language.t("debugBar.nav.tip")}
489
+ value={navv()}
490
+ bad={bad(state.nav.dur, 400)}
491
+ dim={state.nav.dur === undefined && !state.nav.pending}
492
+ inline={props.inline}
493
+ />
494
+ <Cell
495
+ label={language.t("debugBar.fps.label")}
496
+ tip={language.t("debugBar.fps.tip")}
497
+ value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
498
+ bad={bad(state.fps, 50, true)}
499
+ dim={state.fps === undefined}
500
+ inline={props.inline}
501
+ />
502
+ <Cell
503
+ label={language.t("debugBar.frame.label")}
504
+ tip={language.t("debugBar.frame.tip")}
505
+ value={time(state.gap) ?? na()}
506
+ bad={bad(state.gap, 50)}
507
+ dim={state.gap === undefined}
508
+ inline={props.inline}
509
+ />
510
+ <Cell
511
+ label={language.t("debugBar.jank.label")}
512
+ tip={language.t("debugBar.jank.tip")}
513
+ value={state.jank === undefined ? na() : `${state.jank}`}
514
+ bad={bad(state.jank, 8)}
515
+ dim={state.jank === undefined}
516
+ inline={props.inline}
517
+ />
518
+ <Cell
519
+ label={language.t("debugBar.long.label")}
520
+ tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
521
+ value={longv()}
522
+ bad={bad(state.long.block, 200)}
523
+ dim={state.long.count === undefined}
524
+ inline={props.inline}
525
+ />
526
+ <Cell
527
+ label={language.t("debugBar.delay.label")}
528
+ tip={language.t("debugBar.delay.tip")}
529
+ value={time(state.delay) ?? na()}
530
+ bad={bad(state.delay, 100)}
531
+ dim={state.delay === undefined}
532
+ inline={props.inline}
533
+ />
534
+ <Cell
535
+ label={language.t("debugBar.inp.label")}
536
+ tip={language.t("debugBar.inp.tip")}
537
+ value={time(state.inp) ?? na()}
538
+ bad={bad(state.inp, 200)}
539
+ dim={state.inp === undefined}
540
+ inline={props.inline}
541
+ />
542
+ <Cell
543
+ label={language.t("debugBar.cls.label")}
544
+ tip={language.t("debugBar.cls.tip")}
545
+ value={state.cls === undefined ? na() : state.cls.toFixed(2)}
546
+ bad={bad(state.cls, 0.1)}
547
+ dim={state.cls === undefined}
548
+ inline={props.inline}
549
+ />
550
+ <Cell
551
+ label={language.t("debugBar.mem.label")}
552
+ tip={
553
+ state.heap.used === undefined
554
+ ? language.t("debugBar.mem.tipUnavailable")
555
+ : language.t("debugBar.mem.tip", {
556
+ used: mb(state.heap.used) ?? na(),
557
+ limit: mb(state.heap.limit) ?? na(),
558
+ })
559
+ }
560
+ value={heapv()}
561
+ bad={bad(heap(), 0.8)}
562
+ dim={state.heap.used === undefined}
563
+ inline={props.inline}
564
+ span={platform.setForceFocus ? 2 : 3}
565
+ />
566
+ <ToggleCell
567
+ active={language.direction() === "rtl"}
568
+ inline={props.inline}
569
+ label={language.t("debugBar.direction.label")}
570
+ tip={language.t("debugBar.direction.tip")}
571
+ value={language.t(`debugBar.direction.${language.direction()}`)}
572
+ onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
573
+ />
574
+ {platform.setForceFocus && (
575
+ <ToggleCell
576
+ active={state.focus}
577
+ inline={props.inline}
578
+ label={language.t("debugBar.focus.label")}
579
+ tip={language.t("debugBar.focus.tip")}
580
+ value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
581
+ onClick={() => void toggleFocus()}
582
+ />
583
+ )}
584
+ </div>
585
+ </aside>
586
+ )
587
+ }
packages/app/src/components/dialog-command-palette-v2.css ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .command-palette-v2 {
2
+ overflow: hidden;
3
+ }
4
+
5
+ /* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
6
+ top stays put while the content-driven height grows and shrinks. */
7
+ [data-component="dialog-v2"]:has(.command-palette-v2) {
8
+ align-items: flex-start;
9
+ }
10
+
11
+ [data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
12
+ width: min(calc(100vw - 24px), 640px);
13
+ height: auto;
14
+ min-height: 280px;
15
+ max-height: min(calc(100vh - 96px), 480px);
16
+ margin-top: max(48px, calc((100vh - 480px) / 2));
17
+ border-radius: 12px;
18
+ background: var(--v2-background-bg-base);
19
+ box-shadow: var(--v2-elevation-floating);
20
+ }
21
+
22
+ .command-palette-v2-body {
23
+ display: flex;
24
+ min-height: 0;
25
+ flex: 1;
26
+ flex-direction: column;
27
+ gap: 0;
28
+ padding: 0;
29
+ }
30
+
31
+ .command-palette-v2-search {
32
+ flex-shrink: 0;
33
+ padding: 6px;
34
+ }
35
+
36
+ .command-palette-v2-search [data-component="text-input-v2"] {
37
+ width: 100%;
38
+ height: 36px;
39
+ border-radius: 6px;
40
+ outline: 0;
41
+ background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
42
+ box-shadow: none;
43
+ transition:
44
+ background-color 120ms ease-in-out,
45
+ box-shadow 120ms ease-in-out;
46
+ }
47
+
48
+ .command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
49
+ .command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
50
+ background: var(--v2-background-bg-layer-02);
51
+ box-shadow: none;
52
+ }
53
+
54
+ .command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
55
+ border-radius: 6px;
56
+ background: transparent;
57
+ gap: 8px;
58
+ }
59
+
60
+ .command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
61
+ padding-left: 12px;
62
+ }
63
+
64
+ .command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
65
+ border-radius: 6px;
66
+ background: transparent;
67
+ }
68
+
69
+ .command-palette-v2-scroll {
70
+ min-height: 0;
71
+ flex: 1;
72
+ }
73
+
74
+ .command-palette-v2-results {
75
+ display: flex;
76
+ flex-direction: column;
77
+ gap: 16px;
78
+ padding: 6px 6px 8px;
79
+ }
80
+
81
+ .command-palette-v2-group {
82
+ display: flex;
83
+ flex-direction: column;
84
+ gap: 1px;
85
+ }
86
+
87
+ .command-palette-v2-group-title {
88
+ margin: 6px 0;
89
+ padding: 0 12px;
90
+ color: var(--v2-text-text-muted);
91
+ font-size: 13px;
92
+ font-weight: 440;
93
+ line-height: 16px;
94
+ letter-spacing: -0.04px;
95
+ user-select: none;
96
+ }
97
+
98
+ .command-palette-v2-row {
99
+ display: flex;
100
+ width: 100%;
101
+ height: 36px;
102
+ flex-shrink: 0;
103
+ align-items: center;
104
+ justify-content: space-between;
105
+ gap: 8px;
106
+ padding: 0 12px;
107
+ border: 0;
108
+ border-radius: 6px;
109
+ background: transparent;
110
+ color: var(--v2-text-text-base);
111
+ text-align: left;
112
+ cursor: default;
113
+ scroll-margin: 6px 0;
114
+ }
115
+
116
+ .command-palette-v2-row[data-active] {
117
+ background: var(--v2-overlay-simple-overlay-hover);
118
+ }
119
+
120
+ .command-palette-v2-row:focus-visible {
121
+ background: var(--v2-overlay-simple-overlay-hover);
122
+ outline: none;
123
+ }
124
+
125
+ .command-palette-v2-row-main {
126
+ display: flex;
127
+ min-width: 0;
128
+ flex: 1;
129
+ align-items: center;
130
+ gap: 8px;
131
+ }
132
+
133
+ .command-palette-v2-row-icon {
134
+ flex-shrink: 0;
135
+ color: var(--v2-icon-icon-muted);
136
+ }
137
+
138
+ .command-palette-v2-row-text {
139
+ display: flex;
140
+ min-width: 0;
141
+ align-items: center;
142
+ gap: 6px;
143
+ }
144
+
145
+ .command-palette-v2-title {
146
+ min-width: 0;
147
+ overflow: hidden;
148
+ color: var(--v2-text-text-base);
149
+ font-size: 13px;
150
+ font-weight: 530;
151
+ line-height: 16px;
152
+ letter-spacing: -0.04px;
153
+ text-overflow: ellipsis;
154
+ white-space: nowrap;
155
+ }
156
+
157
+ .command-palette-v2-description,
158
+ .command-palette-v2-meta {
159
+ min-width: 0;
160
+ overflow: hidden;
161
+ color: var(--v2-text-text-muted);
162
+ font-size: 13px;
163
+ font-weight: 440;
164
+ line-height: 16px;
165
+ letter-spacing: -0.04px;
166
+ text-overflow: ellipsis;
167
+ white-space: nowrap;
168
+ }
169
+
170
+ .command-palette-v2-meta {
171
+ flex-shrink: 0;
172
+ }
173
+
174
+ .command-palette-v2-file-path {
175
+ display: flex;
176
+ min-width: 0;
177
+ align-items: baseline;
178
+ font-size: 13px;
179
+ line-height: 16px;
180
+ letter-spacing: -0.04px;
181
+ }
182
+
183
+ .command-palette-v2-file-dir {
184
+ min-width: 0;
185
+ overflow: hidden;
186
+ color: var(--v2-text-text-muted);
187
+ font-weight: 440;
188
+ text-overflow: ellipsis;
189
+ white-space: nowrap;
190
+ }
191
+
192
+ .command-palette-v2-file-name {
193
+ flex-shrink: 0;
194
+ color: var(--v2-text-text-base);
195
+ font-weight: 530;
196
+ white-space: nowrap;
197
+ }
198
+
199
+ .command-palette-v2-state {
200
+ display: grid;
201
+ min-height: 120px;
202
+ place-items: center;
203
+ color: var(--v2-text-text-muted);
204
+ font-size: 13px;
205
+ font-weight: 440;
206
+ line-height: 16px;
207
+ letter-spacing: -0.04px;
208
+ }
209
+
210
+ @media (max-width: 640px) {
211
+ .command-palette-v2-row-text {
212
+ flex-direction: column;
213
+ align-items: flex-start;
214
+ gap: 1px;
215
+ }
216
+
217
+ .command-palette-v2-description {
218
+ max-width: 100%;
219
+ }
220
+ }
packages/app/src/components/dialog-command-palette-v2.tsx ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
2
+ import { FileIcon } from "@opencode-ai/ui/file-icon"
3
+ import { ScrollView } from "@opencode-ai/ui/scroll-view"
4
+ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
5
+ import { Icon } from "@opencode-ai/ui/v2/icon"
6
+ import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
7
+ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
8
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
9
+ import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
10
+ import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
11
+ import { useGlobal } from "@/context/global"
12
+ import { useLanguage } from "@/context/language"
13
+ import { ServerConnection } from "@/context/server"
14
+ import { useTabs } from "@/context/tabs"
15
+ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
16
+ import { getRelativeTime } from "@/utils/time"
17
+ import {
18
+ createCommandPaletteCommandEntry,
19
+ createCommandPaletteFileEntry,
20
+ createCommandPaletteModel,
21
+ createServerSessionEntries,
22
+ uniqueCommandPaletteEntries,
23
+ type CommandPaletteEntry,
24
+ } from "./command-palette"
25
+ import "./dialog-command-palette-v2.css"
26
+
27
+ function groups(entries: CommandPaletteEntry[]) {
28
+ const map = new Map<string, CommandPaletteEntry[]>()
29
+ for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
30
+ return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
31
+ }
32
+
33
+ function matchesEntry(entry: CommandPaletteEntry, query: string) {
34
+ const value = query.toLowerCase()
35
+ return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
36
+ }
37
+
38
+ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
39
+ const palette = createCommandPaletteModel(props)
40
+ const loadItems = async (text: string) => {
41
+ const q = text.trim()
42
+ if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
43
+
44
+ const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
45
+ const category = palette.language.t("palette.group.files")
46
+ return [
47
+ ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
48
+ ...nextSessions,
49
+ ...files.map((path) => createCommandPaletteFileEntry(path, category)),
50
+ ]
51
+ }
52
+
53
+ return (
54
+ <CommandPaletteView
55
+ placeholder={palette.language.t("palette.search.placeholder")}
56
+ loadItems={loadItems}
57
+ highlight={palette.highlight}
58
+ select={palette.select}
59
+ close={palette.close}
60
+ />
61
+ )
62
+ }
63
+
64
+ export function DialogHomeCommandPaletteV2(props: {
65
+ server: ServerConnection.Any
66
+ onSelectSession: (entry: CommandPaletteEntry) => void
67
+ }) {
68
+ const command = useCommand()
69
+ const dialog = useDialog()
70
+ const global = useGlobal()
71
+ const language = useLanguage()
72
+ const serverCtx = global.ensureServerCtx(props.server)
73
+ const state = { cleanup: undefined as (() => void) | void, committed: false }
74
+ const commandEntries = createMemo(() => {
75
+ const category = language.t("palette.group.commands")
76
+ return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
77
+ })
78
+ const sessions = createServerSessionEntries({
79
+ server: ServerConnection.key(props.server),
80
+ opened: serverCtx.projects.list,
81
+ stored: () => serverCtx.sync.data.project,
82
+ load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
83
+ untitled: () => language.t("command.session.new"),
84
+ category: () => language.t("command.category.session"),
85
+ })
86
+
87
+ const highlight = (item: CommandPaletteEntry | undefined) => {
88
+ state.cleanup?.()
89
+ state.cleanup = undefined
90
+ if (item?.type !== "command") return
91
+ state.cleanup = item.option?.onHighlight?.()
92
+ }
93
+ const select = (item: CommandPaletteEntry | undefined) => {
94
+ if (!item) return
95
+ state.committed = true
96
+ state.cleanup = undefined
97
+ dialog.close()
98
+ if (item.type === "command") {
99
+ item.option?.onSelect?.("palette")
100
+ return
101
+ }
102
+ if (item.type === "session") props.onSelectSession(item)
103
+ }
104
+ const loadItems = async (text: string) => {
105
+ const query = text.trim()
106
+ if (!query) return commandEntries().slice(0, 5)
107
+ return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
108
+ }
109
+
110
+ onCleanup(() => {
111
+ if (state.committed) return
112
+ state.cleanup?.()
113
+ })
114
+
115
+ return (
116
+ <CommandPaletteView
117
+ placeholder={language.t("palette.search.placeholder.home")}
118
+ loadItems={loadItems}
119
+ highlight={highlight}
120
+ select={select}
121
+ close={() => dialog.close()}
122
+ />
123
+ )
124
+ }
125
+
126
+ function CommandPaletteView(props: {
127
+ placeholder: string
128
+ loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
129
+ highlight: (item: CommandPaletteEntry | undefined) => void
130
+ select: (item: CommandPaletteEntry | undefined) => void
131
+ close: () => void
132
+ }) {
133
+ const language = useLanguage()
134
+ const tabs = useTabs()
135
+ const [query, setQuery] = createSignal("")
136
+ const [active, setActive] = createSignal(0)
137
+
138
+ const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
139
+ // Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
140
+ const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
141
+ const groupedEntries = createMemo(() => groups(visibleEntries()))
142
+ const activeEntry = createMemo(() => visibleEntries()[active()])
143
+ const openSessions = createMemo(
144
+ () => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
145
+ )
146
+
147
+ createEffect(() => {
148
+ query()
149
+ visibleEntries()
150
+ setActive(0)
151
+ })
152
+
153
+ createEffect(() => {
154
+ props.highlight(activeEntry())
155
+ })
156
+
157
+ let resultsRef: HTMLDivElement | undefined
158
+
159
+ const move = (delta: -1 | 1) => {
160
+ const count = visibleEntries().length
161
+ if (count === 0) return
162
+ setActive((index) => (index + delta + count) % count)
163
+ requestAnimationFrame(() => {
164
+ resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
165
+ })
166
+ }
167
+
168
+ const handleKeyDown = (event: KeyboardEvent) => {
169
+ if (event.key === "ArrowDown") {
170
+ event.preventDefault()
171
+ move(1)
172
+ return
173
+ }
174
+ if (event.key === "ArrowUp") {
175
+ event.preventDefault()
176
+ move(-1)
177
+ return
178
+ }
179
+ if (event.key === "Enter") {
180
+ event.preventDefault()
181
+ props.select(activeEntry())
182
+ return
183
+ }
184
+ if (event.key === "Escape") {
185
+ event.preventDefault()
186
+ props.close()
187
+ }
188
+ }
189
+
190
+ return (
191
+ <Dialog class="command-palette-v2" size="large">
192
+ <DialogBody class="command-palette-v2-body">
193
+ <div class="command-palette-v2-search">
194
+ <TextInputV2
195
+ value={query()}
196
+ autofocus
197
+ autocomplete="off"
198
+ spellcheck={false}
199
+ appearance="large"
200
+ placeholder={props.placeholder}
201
+ leadingIcon={<Icon name="magnifying-glass" />}
202
+ onInput={(event) => setQuery(event.currentTarget.value)}
203
+ onKeyDown={handleKeyDown}
204
+ />
205
+ </div>
206
+ <ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
207
+ <div class="command-palette-v2-results" role="listbox">
208
+ <Show
209
+ when={visibleEntries().length > 0}
210
+ fallback={
211
+ <div class="command-palette-v2-state">
212
+ {entries.loading ? language.t("common.loading") : language.t("palette.empty")}
213
+ </div>
214
+ }
215
+ >
216
+ <For each={groupedEntries()}>
217
+ {(group) => (
218
+ <div class="command-palette-v2-group">
219
+ <Show when={group.category}>
220
+ <div class="command-palette-v2-group-title">{group.category}</div>
221
+ </Show>
222
+ <For each={group.entries}>
223
+ {(item) => (
224
+ <PaletteRow
225
+ item={item}
226
+ active={activeEntry()?.id === item.id}
227
+ language={language}
228
+ sessionOpen={
229
+ item.server && item.sessionID
230
+ ? openSessions().has(`${item.server}\0${item.sessionID}`)
231
+ : false
232
+ }
233
+ onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
234
+ onSelect={() => props.select(item)}
235
+ />
236
+ )}
237
+ </For>
238
+ </div>
239
+ )}
240
+ </For>
241
+ </Show>
242
+ </div>
243
+ </ScrollView>
244
+ </DialogBody>
245
+ </Dialog>
246
+ )
247
+ }
248
+
249
+ function PaletteRow(props: {
250
+ item: CommandPaletteEntry
251
+ active: boolean
252
+ language: ReturnType<typeof useLanguage>
253
+ sessionOpen: boolean
254
+ onActive: () => void
255
+ onSelect: () => void
256
+ }) {
257
+ const session = () =>
258
+ props.item.server && props.item.directory && props.item.sessionID
259
+ ? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
260
+ : undefined
261
+
262
+ return (
263
+ <button
264
+ type="button"
265
+ class="command-palette-v2-row group"
266
+ role="option"
267
+ aria-selected={props.active}
268
+ data-active={props.active ? "" : undefined}
269
+ onMouseMove={(event) => {
270
+ // Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
271
+ if (event.movementX === 0 && event.movementY === 0) return
272
+ props.onActive()
273
+ }}
274
+ onMouseDown={(event) => event.preventDefault()}
275
+ onClick={props.onSelect}
276
+ >
277
+ <Switch
278
+ fallback={
279
+ <div class="command-palette-v2-row-main">
280
+ <FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
281
+ <div class="command-palette-v2-file-path">
282
+ <span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
283
+ <span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
284
+ </div>
285
+ </div>
286
+ }
287
+ >
288
+ <Match when={props.item.type === "command"}>
289
+ <div class="command-palette-v2-row-main">
290
+ <div class="command-palette-v2-row-text">
291
+ <span class="command-palette-v2-title">{props.item.title}</span>
292
+ <Show when={props.item.description}>
293
+ <span class="command-palette-v2-description">{props.item.description}</span>
294
+ </Show>
295
+ </div>
296
+ </div>
297
+ <Show when={props.item.keybind}>
298
+ <KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
299
+ </Show>
300
+ </Match>
301
+ <Match when={props.item.type === "session"}>
302
+ <div class="command-palette-v2-row-main">
303
+ <div class="relative shrink-0">
304
+ <Show when={props.sessionOpen}>
305
+ <span
306
+ aria-hidden="true"
307
+ class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
308
+ style={{ right: "calc(100% + 4px)" }}
309
+ />
310
+ </Show>
311
+ <Show when={session()}>
312
+ {(session) => (
313
+ <SessionTabAvatar
314
+ project={props.item.project}
315
+ directory={session().directory}
316
+ sessionId={session().sessionID}
317
+ server={session().server}
318
+ />
319
+ )}
320
+ </Show>
321
+ </div>
322
+ <div class="command-palette-v2-row-text">
323
+ <span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
324
+ {props.item.title}
325
+ </span>
326
+ <Show when={props.item.description}>
327
+ <span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
328
+ {props.item.description}
329
+ </span>
330
+ </Show>
331
+ </div>
332
+ </div>
333
+ <Show when={props.item.updated}>
334
+ <span class="command-palette-v2-meta">
335
+ {getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
336
+ </span>
337
+ </Show>
338
+ </Match>
339
+ </Switch>
340
+ </button>
341
+ )
342
+ }
packages/app/src/components/dialog-connect-provider.stories.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-nocheck
2
+ import { Button } from "@opencode-ai/ui/button"
3
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
5
+ import { mockProviderAuth } from "@/context/server-sync"
6
+ import { onCleanup, onMount } from "solid-js"
7
+ import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
8
+
9
+ function ConnectProviderDialogStory() {
10
+ const dialog = useDialog()
11
+ const open = () => dialog.show(() => <DialogConnectProvider />)
12
+
13
+ onMount(open)
14
+
15
+ return (
16
+ <Button variant="secondary" onClick={open}>
17
+ Open connect provider dialog
18
+ </Button>
19
+ )
20
+ }
21
+
22
+ function ProviderConnectionDialogStory(props) {
23
+ onCleanup(mockProviderAuth(props.provider, props.methods))
24
+ const dialog = useDialog()
25
+ const controller = useProviderConnectController()
26
+ controller.select(props.provider)
27
+ const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
28
+
29
+ onMount(open)
30
+
31
+ return (
32
+ <Button variant="secondary" onClick={open}>
33
+ Open {props.provider} connection dialog
34
+ </Button>
35
+ )
36
+ }
37
+
38
+ function renderConnection(provider, methods) {
39
+ return () => (
40
+ <QueryClientProvider client={new QueryClient()}>
41
+ <ProviderConnectionDialogStory provider={provider} methods={methods} />
42
+ </QueryClientProvider>
43
+ )
44
+ }
45
+
46
+ export default {
47
+ title: "App/Dialogs/Connect Provider",
48
+ id: "app-dialog-connect-provider",
49
+ }
50
+
51
+ export const V2 = {
52
+ render: () => (
53
+ <QueryClientProvider client={new QueryClient()}>
54
+ <ConnectProviderDialogStory />
55
+ </QueryClientProvider>
56
+ ),
57
+ }
58
+
59
+ export const ApiKey = {
60
+ render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
61
+ }
62
+
63
+ export const OpenCodeZen = {
64
+ render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
65
+ }
66
+
67
+ export const LoginMethods = {
68
+ render: renderConnection("openai", [
69
+ { type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
70
+ { type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
71
+ { type: "api", label: "API key" },
72
+ ]),
73
+ }
packages/app/src/components/dialog-connect-provider.tsx ADDED
@@ -0,0 +1,1191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
2
+ import { Button } from "@opencode-ai/ui/button"
3
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
4
+ import { Dialog } from "@opencode-ai/ui/dialog"
5
+ import { Icon } from "@opencode-ai/ui/icon"
6
+ import { IconButton } from "@opencode-ai/ui/icon-button"
7
+ import { List, type ListRef } from "@opencode-ai/ui/list"
8
+ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
9
+ import { Spinner } from "@opencode-ai/ui/spinner"
10
+ import { Tag } from "@opencode-ai/ui/tag"
11
+ import { TextField } from "@opencode-ai/ui/text-field"
12
+ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
13
+ import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
14
+ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
15
+ import { showToast } from "@/utils/toast"
16
+ import {
17
+ type Accessor,
18
+ type Component,
19
+ createEffect,
20
+ createMemo,
21
+ createResource,
22
+ createUniqueId,
23
+ For,
24
+ Match,
25
+ onCleanup,
26
+ onMount,
27
+ Show,
28
+ Switch,
29
+ } from "solid-js"
30
+ import { createStore, produce } from "solid-js/store"
31
+ import { useParams } from "@solidjs/router"
32
+ import { ExternalLink } from "@/components/external-link"
33
+ import { useServerSDK } from "@/context/server-sdk"
34
+ import { useServerSync } from "@/context/server-sync"
35
+ import { useLanguage } from "@/context/language"
36
+ import { usePlatform } from "@/context/platform"
37
+ import { useSettings } from "@/context/settings"
38
+ import { popularProviders, useProviders } from "@/hooks/use-providers"
39
+ import { CustomProviderForm } from "./dialog-custom-provider"
40
+ import { decode64 } from "@/utils/base64"
41
+
42
+ const CUSTOM_ID = "_custom"
43
+ type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
44
+
45
+ export function useProviderConnectController(options: { onBack?: () => void } = {}) {
46
+ const [store, setStore] = createStore({ selected: undefined as string | undefined })
47
+ const reset = () => setStore("selected", undefined)
48
+
49
+ return {
50
+ selected: () => store.selected,
51
+ select: (provider?: string) => setStore("selected", provider),
52
+ back: options.onBack ?? reset,
53
+ }
54
+ }
55
+
56
+ export const DialogConnectProvider: Component<{
57
+ directory?: Accessor<string | undefined>
58
+ controller?: ReturnType<typeof useProviderConnectController>
59
+ }> = (props) => {
60
+ const fallback = useProviderConnectController()
61
+ const controller = props.controller ?? fallback
62
+ const language = useLanguage()
63
+ const settings = useSettings()
64
+ const newLayout = settings.general.newLayoutDesigns
65
+ const reset = controller.back
66
+ const back = { current: reset }
67
+ let focusHost: HTMLDivElement | undefined
68
+ const holdFocus = () => focusHost?.focus({ preventScroll: true })
69
+ const select = (provider?: string) => {
70
+ back.current = reset
71
+ controller.select(provider)
72
+ }
73
+
74
+ function Content() {
75
+ return (
76
+ <Switch>
77
+ <Match when={controller.selected() === CUSTOM_ID}>
78
+ <CustomProviderForm autofocus={!newLayout()} />
79
+ </Match>
80
+ <Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
81
+ {(provider) => (
82
+ <ProviderConnection
83
+ provider={provider()}
84
+ directory={props.directory}
85
+ onBack={reset}
86
+ setBack={(handler) => (back.current = handler)}
87
+ />
88
+ )}
89
+ </Match>
90
+ <Match when={true}>
91
+ <ProviderPicker
92
+ directory={props.directory}
93
+ onSelect={select}
94
+ onPrepare={newLayout() ? holdFocus : undefined}
95
+ />
96
+ </Match>
97
+ </Switch>
98
+ )
99
+ }
100
+
101
+ return (
102
+ <Show
103
+ when={newLayout()}
104
+ fallback={
105
+ <Dialog
106
+ class="h-full"
107
+ transition
108
+ title={
109
+ <Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
110
+ <IconButton
111
+ tabIndex={-1}
112
+ icon="arrow-left"
113
+ variant="ghost"
114
+ onClick={() => back.current()}
115
+ aria-label={language.t("common.goBack")}
116
+ />
117
+ </Show>
118
+ }
119
+ >
120
+ <Content />
121
+ </Dialog>
122
+ }
123
+ >
124
+ <DialogV2
125
+ containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
126
+ class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
127
+ >
128
+ <DialogHeader closeLabel={language.t("common.close")}>
129
+ <Show
130
+ when={controller.selected()}
131
+ fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
132
+ >
133
+ <button
134
+ type="button"
135
+ class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
136
+ onClick={() => back.current()}
137
+ aria-label={language.t("common.goBack")}
138
+ >
139
+ <Icon name="arrow-left" size="small" />
140
+ </button>
141
+ </Show>
142
+ </DialogHeader>
143
+ <DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
144
+ <div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
145
+ <Content />
146
+ </div>
147
+ </DialogBody>
148
+ </DialogV2>
149
+ </Show>
150
+ )
151
+ }
152
+
153
+ function ProviderPicker(props: {
154
+ directory?: Accessor<string | undefined>
155
+ onSelect: (provider: string) => void
156
+ onPrepare?: () => void
157
+ }) {
158
+ const settings = useSettings()
159
+ if (settings.general.newLayoutDesigns())
160
+ return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
161
+ const providers = useProviders(() => props.directory?.())
162
+ const language = useLanguage()
163
+ const popularGroup = () => language.t("dialog.provider.group.popular")
164
+ const otherGroup = () => language.t("dialog.provider.group.other")
165
+ const customLabel = () => language.t("settings.providers.tag.custom")
166
+ const note = (id: string) => {
167
+ if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
168
+ if (id === "openai") return language.t("dialog.provider.openai.note")
169
+ if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
170
+ if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
171
+ return undefined
172
+ }
173
+
174
+ return (
175
+ <List
176
+ class="px-3"
177
+ search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
178
+ emptyMessage={language.t("dialog.provider.empty")}
179
+ activeIcon="plus-small"
180
+ key={(x) => x?.id}
181
+ items={() => {
182
+ language.locale()
183
+ return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
184
+ }}
185
+ filterKeys={["id", "name"]}
186
+ groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
187
+ sortBy={(a, b) => {
188
+ if (a.id === CUSTOM_ID) return -1
189
+ if (b.id === CUSTOM_ID) return 1
190
+ if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
191
+ return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
192
+ return a.name.localeCompare(b.name)
193
+ }}
194
+ sortGroupsBy={(a, b) => {
195
+ const popular = popularGroup()
196
+ if (a.category === popular && b.category !== popular) return -1
197
+ if (b.category === popular && a.category !== popular) return 1
198
+ return 0
199
+ }}
200
+ onSelect={(x) => {
201
+ if (!x) return
202
+ props.onSelect(x.id)
203
+ }}
204
+ >
205
+ {(i) => (
206
+ <div class="px-1.25 w-full flex items-center gap-x-3">
207
+ <ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
208
+ <span>{i.name}</span>
209
+ <Show when={i.id === "opencode"}>
210
+ <div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
211
+ </Show>
212
+ <Show when={i.id === CUSTOM_ID}>
213
+ <Tag>{language.t("settings.providers.tag.custom")}</Tag>
214
+ </Show>
215
+ <Show when={i.id === "opencode"}>
216
+ <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
217
+ </Show>
218
+ <Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
219
+ <Show when={i.id === "opencode-go"}>
220
+ <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
221
+ </Show>
222
+ </div>
223
+ )}
224
+ </List>
225
+ )
226
+ }
227
+
228
+ function ProviderPickerV2(props: {
229
+ directory?: Accessor<string | undefined>
230
+ onSelect: (provider: string) => void
231
+ onPrepare?: () => void
232
+ }) {
233
+ const providers = useProviders(() => props.directory?.())
234
+ const language = useLanguage()
235
+ const [store, setStore] = createStore({
236
+ filter: "",
237
+ active: undefined as string | undefined,
238
+ connecting: undefined as string | undefined,
239
+ })
240
+ const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
241
+ const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
242
+ const all = createMemo(() => {
243
+ language.locale()
244
+ const query = store.filter.trim().toLowerCase()
245
+ const values = [custom(), ...providers.all().values()]
246
+ if (!query) return values
247
+ return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
248
+ })
249
+ const popular = createMemo(() =>
250
+ all()
251
+ .filter((provider) => featured.includes(provider.id))
252
+ .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
253
+ )
254
+ const other = createMemo(() =>
255
+ all()
256
+ .filter((provider) => !featured.includes(provider.id))
257
+ .sort((a, b) => {
258
+ if (a.id === CUSTOM_ID) return -1
259
+ if (b.id === CUSTOM_ID) return 1
260
+ return a.name.localeCompare(b.name)
261
+ }),
262
+ )
263
+ const rows = createMemo(() => [...popular(), ...other()])
264
+ let picker: HTMLDivElement | undefined
265
+ let search: HTMLInputElement | undefined
266
+
267
+ onMount(() => search?.focus({ preventScroll: true }))
268
+
269
+ const connect = (provider: string) => {
270
+ props.onPrepare?.()
271
+ props.onSelect(provider)
272
+ }
273
+
274
+ const move = (event: KeyboardEvent, direction: number) => {
275
+ const items = rows()
276
+ if (items.length === 0) return
277
+ const index = items.findIndex((provider) => provider.id === store.active)
278
+ const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
279
+ setStore("active", items[next].id)
280
+ picker
281
+ ?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
282
+ ?.focus({ preventScroll: true })
283
+ event.preventDefault()
284
+ }
285
+
286
+ const handleKeyDown = (event: KeyboardEvent) => {
287
+ if (event.key === "ArrowDown") return move(event, 1)
288
+ if (event.key === "ArrowUp") return move(event, -1)
289
+ if (event.key !== "Enter" || !store.active) return
290
+ connect(store.active)
291
+ event.preventDefault()
292
+ }
293
+
294
+ return (
295
+ <div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
296
+ <div class="shrink-0 px-1 pt-px">
297
+ <TextInputV2
298
+ ref={search}
299
+ type="search"
300
+ class="!w-full [font-family:var(--v2-font-family-sans)]"
301
+ leadingIcon={<Icon name="magnifying-glass" size="small" />}
302
+ placeholder={language.t("dialog.provider.search.placeholder")}
303
+ value={store.filter}
304
+ onInput={(event) => {
305
+ setStore({ filter: event.currentTarget.value, active: undefined })
306
+ }}
307
+ />
308
+ </div>
309
+ <div class="relative min-h-0 flex-1">
310
+ <div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
311
+ <For
312
+ each={[
313
+ { title: language.t("dialog.provider.group.popular"), items: popular },
314
+ { title: language.t("dialog.provider.group.other"), items: other },
315
+ ]}
316
+ >
317
+ {(group) => (
318
+ <Show when={group.items().length > 0}>
319
+ <section class="flex flex-col">
320
+ <div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
321
+ {group.title}
322
+ </div>
323
+ <For each={group.items()}>
324
+ {(provider) => (
325
+ <button
326
+ type="button"
327
+ data-provider-id={provider.id}
328
+ class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
329
+ classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
330
+ onMouseEnter={() => setStore("active", provider.id)}
331
+ disabled={store.connecting !== undefined}
332
+ aria-busy={store.connecting === provider.id}
333
+ onClick={() => connect(provider.id)}
334
+ >
335
+ <ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
336
+ <span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
337
+ <Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
338
+ <span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
339
+ {language.t(
340
+ provider.id === "opencode"
341
+ ? "dialog.provider.opencode.tagline"
342
+ : "dialog.provider.opencodeGo.tagline",
343
+ )}
344
+ </span>
345
+ <span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
346
+ {language.t("dialog.provider.tag.recommended")}
347
+ </span>
348
+ </Show>
349
+ <Show when={provider.id === CUSTOM_ID}>
350
+ <span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
351
+ {language.t("settings.providers.tag.custom")}
352
+ </span>
353
+ </Show>
354
+ <Show when={store.connecting === provider.id}>
355
+ <Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
356
+ </Show>
357
+ </button>
358
+ )}
359
+ </For>
360
+ </section>
361
+ </Show>
362
+ )}
363
+ </For>
364
+ <Show when={rows().length === 0}>
365
+ <div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
366
+ {language.t("dialog.provider.empty")}
367
+ </div>
368
+ </Show>
369
+ </div>
370
+ <div
371
+ class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
372
+ style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
373
+ />
374
+ </div>
375
+ </div>
376
+ )
377
+ }
378
+
379
+ function ProviderConnection(props: {
380
+ provider: string
381
+ directory?: Accessor<string | undefined>
382
+ onBack: () => void
383
+ setBack: (handler: () => void) => void
384
+ }) {
385
+ const dialog = useDialog()
386
+ const serverSync = useServerSync()
387
+ const serverSDK = useServerSDK()
388
+ const params = useParams()
389
+ const language = useLanguage()
390
+ const platform = usePlatform()
391
+ const settings = useSettings()
392
+ const newLayout = settings.general.newLayoutDesigns
393
+ const providers = useProviders(() => props.directory?.())
394
+ const directory = () => props.directory?.() ?? decode64(params.dir)
395
+ const location = () => {
396
+ const value = directory()
397
+ return value ? { directory: value } : undefined
398
+ }
399
+
400
+ const alive = { value: true }
401
+ const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
402
+
403
+ onCleanup(() => {
404
+ alive.value = false
405
+ if (timer.current === undefined) return
406
+ clearTimeout(timer.current)
407
+ timer.current = undefined
408
+ })
409
+
410
+ const provider = createMemo(
411
+ () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
412
+ )
413
+ const fallback = createMemo<ConnectMethod[]>(() => [
414
+ {
415
+ type: "key" as const,
416
+ label: language.t("provider.connect.method.apiKey"),
417
+ },
418
+ ])
419
+ const [integration] = createResource(
420
+ () => ({ provider: props.provider, directory: directory() }),
421
+ (input) =>
422
+ serverSDK()
423
+ .api.integration.get({
424
+ integrationID: input.provider,
425
+ location: input.directory ? { directory: input.directory } : undefined,
426
+ })
427
+ .then((result) => result.data),
428
+ )
429
+ const loading = createMemo(() => integration.loading)
430
+ const methods = createMemo<ConnectMethod[]>(() => {
431
+ const values = integration.latest?.methods.filter(
432
+ (method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
433
+ )
434
+ return values?.length ? values : fallback()
435
+ })
436
+ const [store, setStore] = createStore({
437
+ methodIndex: undefined as undefined | number,
438
+ authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
439
+ promptInputs: undefined as undefined | Record<string, string>,
440
+ state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
441
+ error: undefined as string | undefined,
442
+ })
443
+
444
+ type Action =
445
+ | { type: "method.select"; index: number }
446
+ | { type: "method.reset" }
447
+ | { type: "auth.prompt" }
448
+ | { type: "auth.inputs"; inputs: Record<string, string> }
449
+ | { type: "auth.pending" }
450
+ | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
451
+ | { type: "auth.error"; error: string }
452
+
453
+ function dispatch(action: Action) {
454
+ setStore(
455
+ produce((draft) => {
456
+ if (action.type === "method.select") {
457
+ draft.methodIndex = action.index
458
+ draft.authorization = undefined
459
+ draft.promptInputs = undefined
460
+ draft.state = undefined
461
+ draft.error = undefined
462
+ return
463
+ }
464
+ if (action.type === "method.reset") {
465
+ draft.methodIndex = undefined
466
+ draft.authorization = undefined
467
+ draft.promptInputs = undefined
468
+ draft.state = undefined
469
+ draft.error = undefined
470
+ return
471
+ }
472
+ if (action.type === "auth.prompt") {
473
+ draft.state = "prompt"
474
+ draft.error = undefined
475
+ return
476
+ }
477
+ if (action.type === "auth.inputs") {
478
+ draft.promptInputs = action.inputs
479
+ draft.state = undefined
480
+ draft.error = undefined
481
+ return
482
+ }
483
+ if (action.type === "auth.pending") {
484
+ draft.state = "pending"
485
+ draft.error = undefined
486
+ return
487
+ }
488
+ if (action.type === "auth.complete") {
489
+ draft.state = "complete"
490
+ draft.authorization = action.authorization
491
+ draft.error = undefined
492
+ return
493
+ }
494
+ draft.state = "error"
495
+ draft.error = action.error
496
+ }),
497
+ )
498
+ }
499
+
500
+ const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
501
+
502
+ const methodLabel = (value?: { type?: string; label?: string }) => {
503
+ if (!value) return ""
504
+ if (value.type === "key") return language.t("provider.connect.method.apiKey")
505
+ return value.label ?? ""
506
+ }
507
+
508
+ const methodDetails = (value?: { type?: string; label?: string }) => {
509
+ const label = methodLabel(value)
510
+ const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
511
+ const hint = suffix?.[1]
512
+ return {
513
+ label: suffix ? label.slice(0, -suffix[0].length) : label,
514
+ hint:
515
+ hint?.toLowerCase() === "headless"
516
+ ? language.t("provider.connect.method.headless")
517
+ : hint?.toLowerCase() === "browser" || (!hint && value?.type === "key")
518
+ ? language.t("provider.connect.method.browser")
519
+ : undefined,
520
+ }
521
+ }
522
+
523
+ function formatError(value: unknown, fallback: string): string {
524
+ if (value && typeof value === "object" && "data" in value) {
525
+ const data = (value as { data?: { message?: unknown } }).data
526
+ if (typeof data?.message === "string" && data.message) return data.message
527
+ }
528
+ if (value && typeof value === "object" && "error" in value) {
529
+ const nested = formatError((value as { error?: unknown }).error, "")
530
+ if (nested) return nested
531
+ }
532
+ if (value && typeof value === "object" && "message" in value) {
533
+ const message = (value as { message?: unknown }).message
534
+ if (typeof message === "string" && message) return message
535
+ }
536
+ if (value instanceof Error && value.message) return value.message
537
+ if (typeof value === "string" && value) return value
538
+ return fallback
539
+ }
540
+
541
+ async function selectMethod(index: number, inputs?: Record<string, string>) {
542
+ if (timer.current !== undefined) {
543
+ clearTimeout(timer.current)
544
+ timer.current = undefined
545
+ }
546
+
547
+ const method = methods()[index]
548
+ dispatch({ type: "method.select", index })
549
+
550
+ if (method.type === "oauth") {
551
+ if (method.prompts?.length && !inputs) {
552
+ dispatch({ type: "auth.prompt" })
553
+ return
554
+ }
555
+ dispatch({ type: "auth.pending" })
556
+ await serverSDK()
557
+ .api.integration.oauth.connect({
558
+ integrationID: props.provider,
559
+ methodID: method.id,
560
+ inputs: inputs ?? {},
561
+ location: location(),
562
+ })
563
+ .then((x) => {
564
+ if (!alive.value) return
565
+ if (props.provider === "opencode" && platform.platform === "desktop") {
566
+ const url = new URL(x.data.url)
567
+ url.searchParams.set("client_id", "opencode-desktop")
568
+ x.data.url = url.href
569
+ }
570
+ dispatch({ type: "auth.complete", authorization: x.data })
571
+ })
572
+ .catch((e) => {
573
+ if (!alive.value) return
574
+ dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
575
+ })
576
+ }
577
+ }
578
+
579
+ function AuthPromptsView() {
580
+ const [formStore, setFormStore] = createStore({
581
+ value: {} as Record<string, string>,
582
+ index: 0,
583
+ })
584
+
585
+ const prompts = createMemo(() => {
586
+ const value = method()
587
+ return value?.type === "oauth" ? (value.prompts ?? []) : []
588
+ })
589
+ const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
590
+ if (!prompt.when) return true
591
+ const actual = value[prompt.when.key]
592
+ if (actual === undefined) return false
593
+ return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
594
+ }
595
+ const current = createMemo(() => {
596
+ const all = prompts()
597
+ const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
598
+ if (index === -1) return
599
+ return {
600
+ index,
601
+ prompt: all[index],
602
+ }
603
+ })
604
+ const valid = createMemo(() => {
605
+ const item = current()
606
+ if (!item || item.prompt.type !== "text") return false
607
+ const value = formStore.value[item.prompt.key] ?? ""
608
+ return value.trim().length > 0
609
+ })
610
+
611
+ async function next(index: number, value: Record<string, string>) {
612
+ if (store.methodIndex === undefined) return
613
+ const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
614
+ if (next !== -1) {
615
+ setFormStore("index", next)
616
+ return
617
+ }
618
+ await selectMethod(store.methodIndex, value)
619
+ }
620
+
621
+ async function handleSubmit(e: SubmitEvent) {
622
+ e.preventDefault()
623
+ const item = current()
624
+ if (!item || item.prompt.type !== "text") return
625
+ if (!valid()) return
626
+ await next(item.index, formStore.value)
627
+ }
628
+
629
+ const item = () => current()
630
+ const text = createMemo(() => {
631
+ const prompt = item()?.prompt
632
+ if (!prompt || prompt.type !== "text") return
633
+ return prompt
634
+ })
635
+ const select = createMemo(() => {
636
+ const prompt = item()?.prompt
637
+ if (!prompt || prompt.type !== "select") return
638
+ return prompt
639
+ })
640
+
641
+ return (
642
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
643
+ <Switch>
644
+ <Match when={item()?.prompt.type === "text"}>
645
+ <TextField
646
+ type="text"
647
+ label={text()?.message ?? ""}
648
+ placeholder={text()?.placeholder}
649
+ value={text() ? (formStore.value[text()!.key] ?? "") : ""}
650
+ onChange={(value) => {
651
+ const prompt = text()
652
+ if (!prompt) return
653
+ setFormStore("value", prompt.key, value)
654
+ }}
655
+ />
656
+ <Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
657
+ {language.t("common.continue")}
658
+ </Button>
659
+ </Match>
660
+ <Match when={item()?.prompt.type === "select"}>
661
+ <div class="w-full flex flex-col gap-1.5">
662
+ <div class="text-14-regular text-text-base">{select()?.message}</div>
663
+ <div>
664
+ <List
665
+ class="px-3"
666
+ items={select()?.options ?? []}
667
+ key={(x) => x.value}
668
+ current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
669
+ onSelect={(value) => {
670
+ if (!value) return
671
+ const prompt = select()
672
+ if (!prompt) return
673
+ const nextValue = {
674
+ ...formStore.value,
675
+ [prompt.key]: value.value,
676
+ }
677
+ setFormStore("value", prompt.key, value.value)
678
+ void next(item()!.index, nextValue)
679
+ }}
680
+ >
681
+ {(option) => (
682
+ <div class="w-full flex items-center gap-x-2">
683
+ <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
684
+ <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
685
+ </div>
686
+ <span>{option.label}</span>
687
+ <span class="text-14-regular text-text-weak">{option.hint}</span>
688
+ </div>
689
+ )}
690
+ </List>
691
+ </div>
692
+ </div>
693
+ </Match>
694
+ </Switch>
695
+ </form>
696
+ )
697
+ }
698
+
699
+ let listRef: ListRef | undefined
700
+ function handleKey(e: KeyboardEvent) {
701
+ if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
702
+ return
703
+ }
704
+ if (e.key === "Escape") return
705
+ listRef?.onKeyDown(e)
706
+ }
707
+
708
+ let auto = false
709
+ createEffect(() => {
710
+ if (auto) return
711
+ if (loading()) return
712
+ if (methods().length === 1) {
713
+ auto = true
714
+ void selectMethod(0)
715
+ }
716
+ })
717
+
718
+ async function complete() {
719
+ await serverSync()
720
+ .refreshProviders()
721
+ .catch(() => undefined)
722
+ dialog.close()
723
+ showToast({
724
+ variant: "success",
725
+ icon: "circle-check",
726
+ title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
727
+ description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
728
+ })
729
+ }
730
+
731
+ function goBack() {
732
+ if (methods().length > 1 && store.methodIndex !== undefined) {
733
+ dispatch({ type: "method.reset" })
734
+ return
735
+ }
736
+ props.onBack()
737
+ }
738
+
739
+ props.setBack(goBack)
740
+
741
+ function MethodSelection() {
742
+ if (newLayout())
743
+ return (
744
+ <div class="flex flex-col gap-2">
745
+ <div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
746
+ {language.t("provider.connect.selectMethod", { provider: provider().name })}
747
+ </div>
748
+ <div class="flex flex-col">
749
+ <For each={methods()}>
750
+ {(item, index) => {
751
+ const details = () => methodDetails(item)
752
+ return (
753
+ <button
754
+ type="button"
755
+ class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
756
+ onClick={() => void selectMethod(index())}
757
+ >
758
+ <span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
759
+ <span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
760
+ </span>
761
+ <span class="font-[530] text-v2-text-text-base">{details().label}</span>
762
+ <Show when={details().hint}>
763
+ {(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
764
+ </Show>
765
+ </button>
766
+ )
767
+ }}
768
+ </For>
769
+ </div>
770
+ </div>
771
+ )
772
+
773
+ return (
774
+ <>
775
+ <div class="text-14-regular text-text-base">
776
+ {language.t("provider.connect.selectMethod", { provider: provider().name })}
777
+ </div>
778
+ <div>
779
+ <List
780
+ class="px-3"
781
+ ref={(ref) => {
782
+ listRef = ref
783
+ }}
784
+ items={methods}
785
+ key={(m) => m?.label ?? m?.type}
786
+ onSelect={async (selected, index) => {
787
+ if (!selected) return
788
+ void selectMethod(index)
789
+ }}
790
+ >
791
+ {(i) => (
792
+ <div class="w-full flex items-center gap-x-2">
793
+ <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
794
+ <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
795
+ </div>
796
+ <span>{methodLabel(i)}</span>
797
+ </div>
798
+ )}
799
+ </List>
800
+ </div>
801
+ </>
802
+ )
803
+ }
804
+
805
+ function ApiAuthView() {
806
+ let apiKey: HTMLInputElement | undefined
807
+ const errorID = createUniqueId()
808
+ const [formStore, setFormStore] = createStore({
809
+ value: "",
810
+ error: undefined as string | undefined,
811
+ })
812
+
813
+ onMount(() => {
814
+ if (!newLayout()) return
815
+ apiKey?.focus({ preventScroll: true })
816
+ })
817
+
818
+ async function handleSubmit(e: SubmitEvent) {
819
+ e.preventDefault()
820
+
821
+ const form = e.currentTarget as HTMLFormElement
822
+ const formData = new FormData(form)
823
+ const apiKey = formData.get("apiKey") as string
824
+
825
+ if (!apiKey?.trim()) {
826
+ setFormStore("error", language.t("provider.connect.apiKey.required"))
827
+ return
828
+ }
829
+
830
+ setFormStore("error", undefined)
831
+ await serverSDK().api.integration.connect.key({
832
+ integrationID: props.provider,
833
+ location: location(),
834
+ key: apiKey,
835
+ })
836
+ await complete()
837
+ }
838
+
839
+ if (newLayout())
840
+ return (
841
+ <div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
842
+ <Show
843
+ when={provider().id === "opencode"}
844
+ fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
845
+ >
846
+ <div class="flex flex-col gap-5">
847
+ <div>{language.t("provider.connect.opencodeZen.line1")}</div>
848
+ <div>{language.t("provider.connect.opencodeZen.line2")}</div>
849
+ <div>
850
+ {language.t("provider.connect.opencodeZen.visit.prefix")}
851
+ <ExternalLink
852
+ href="https://opencode.ai/zen"
853
+ class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
854
+ >
855
+ {language.t("provider.connect.opencodeZen.visit.link")}
856
+ </ExternalLink>
857
+ {language.t("provider.connect.opencodeZen.visit.suffix")}
858
+ </div>
859
+ </div>
860
+ </Show>
861
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
862
+ <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
863
+ {language.t("provider.connect.apiKey.label", { provider: provider().name })}
864
+ <TextInputV2
865
+ ref={apiKey}
866
+ class="!w-full"
867
+ name="apiKey"
868
+ data-input="provider-api-key"
869
+ placeholder={language.t("provider.connect.apiKey.placeholder")}
870
+ value={formStore.value}
871
+ invalid={formStore.error !== undefined}
872
+ aria-describedby={formStore.error ? errorID : undefined}
873
+ autocomplete="off"
874
+ spellcheck={false}
875
+ onInput={(event) => setFormStore("value", event.currentTarget.value)}
876
+ />
877
+ </label>
878
+ <Show when={formStore.error}>
879
+ {(error) => (
880
+ <div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
881
+ {error()}
882
+ </div>
883
+ )}
884
+ </Show>
885
+ <ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
886
+ {language.t("common.continue")}
887
+ </ButtonV2>
888
+ </form>
889
+ </div>
890
+ )
891
+
892
+ return (
893
+ <div class="flex flex-col gap-6">
894
+ <Switch>
895
+ <Match when={provider().id === "opencode"}>
896
+ <div class="flex flex-col gap-4">
897
+ <div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
898
+ <div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
899
+ <div class="text-14-regular text-text-base">
900
+ {language.t("provider.connect.opencodeZen.visit.prefix")}
901
+ <ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
902
+ {language.t("provider.connect.opencodeZen.visit.link")}
903
+ </ExternalLink>
904
+ {language.t("provider.connect.opencodeZen.visit.suffix")}
905
+ </div>
906
+ </div>
907
+ </Match>
908
+ <Match when={true}>
909
+ <div class="text-14-regular text-text-base">
910
+ {language.t("provider.connect.apiKey.description", { provider: provider().name })}
911
+ </div>
912
+ </Match>
913
+ </Switch>
914
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
915
+ <TextField
916
+ autofocus={!newLayout()}
917
+ ref={apiKey}
918
+ type="text"
919
+ label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
920
+ placeholder={language.t("provider.connect.apiKey.placeholder")}
921
+ name="apiKey"
922
+ value={formStore.value}
923
+ onChange={(v) => setFormStore("value", v)}
924
+ validationState={formStore.error ? "invalid" : undefined}
925
+ error={formStore.error}
926
+ />
927
+ <Button class="w-auto" type="submit" size="large" variant="primary">
928
+ {language.t("common.continue")}
929
+ </Button>
930
+ </form>
931
+ </div>
932
+ )
933
+ }
934
+
935
+ function OAuthCodeView() {
936
+ let codeInput: HTMLInputElement | undefined
937
+ const errorID = createUniqueId()
938
+ const [formStore, setFormStore] = createStore({
939
+ value: "",
940
+ error: undefined as string | undefined,
941
+ })
942
+
943
+ onMount(() => {
944
+ if (!newLayout()) return
945
+ codeInput?.focus({ preventScroll: true })
946
+ })
947
+
948
+ async function handleSubmit(e: SubmitEvent) {
949
+ e.preventDefault()
950
+
951
+ const form = e.currentTarget as HTMLFormElement
952
+ const formData = new FormData(form)
953
+ const code = formData.get("code") as string
954
+
955
+ if (!code?.trim()) {
956
+ setFormStore("error", language.t("provider.connect.oauth.code.required"))
957
+ return
958
+ }
959
+
960
+ setFormStore("error", undefined)
961
+ const result = await serverSDK()
962
+ .api.integration.oauth.complete({
963
+ integrationID: props.provider,
964
+ attemptID: store.authorization!.attemptID,
965
+ location: location(),
966
+ code,
967
+ })
968
+ .then(() => ({ ok: true as const }))
969
+ .catch((error) => ({ ok: false as const, error }))
970
+ if (result.ok) {
971
+ await complete()
972
+ return
973
+ }
974
+ setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
975
+ }
976
+
977
+ if (newLayout())
978
+ return (
979
+ <div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
980
+ <div>
981
+ {language.t("provider.connect.oauth.code.visit.prefix")}
982
+ <ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
983
+ {language.t("provider.connect.oauth.code.visit.link")}
984
+ </ExternalLink>
985
+ {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
986
+ </div>
987
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
988
+ <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
989
+ {language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
990
+ <TextInputV2
991
+ ref={codeInput}
992
+ class="!w-full"
993
+ name="code"
994
+ placeholder={language.t("provider.connect.oauth.code.placeholder")}
995
+ value={formStore.value}
996
+ invalid={formStore.error !== undefined}
997
+ aria-describedby={formStore.error ? errorID : undefined}
998
+ autocomplete="off"
999
+ spellcheck={false}
1000
+ onInput={(event) => setFormStore("value", event.currentTarget.value)}
1001
+ />
1002
+ </label>
1003
+ <Show when={formStore.error}>
1004
+ {(error) => (
1005
+ <div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
1006
+ {error()}
1007
+ </div>
1008
+ )}
1009
+ </Show>
1010
+ <ButtonV2 type="submit" variant="contrast">
1011
+ {language.t("common.continue")}
1012
+ </ButtonV2>
1013
+ </form>
1014
+ </div>
1015
+ )
1016
+
1017
+ return (
1018
+ <div class="flex flex-col gap-6">
1019
+ <div class="text-14-regular text-text-base">
1020
+ {language.t("provider.connect.oauth.code.visit.prefix")}
1021
+ <ExternalLink href={store.authorization!.url}>
1022
+ {language.t("provider.connect.oauth.code.visit.link")}
1023
+ </ExternalLink>
1024
+ {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
1025
+ </div>
1026
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
1027
+ <TextField
1028
+ autofocus={!newLayout()}
1029
+ ref={codeInput}
1030
+ type="text"
1031
+ label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
1032
+ placeholder={language.t("provider.connect.oauth.code.placeholder")}
1033
+ name="code"
1034
+ value={formStore.value}
1035
+ onChange={(v) => setFormStore("value", v)}
1036
+ validationState={formStore.error ? "invalid" : undefined}
1037
+ error={formStore.error}
1038
+ />
1039
+ <Button class="w-auto" type="submit" size="large" variant="primary">
1040
+ {language.t("common.continue")}
1041
+ </Button>
1042
+ </form>
1043
+ </div>
1044
+ )
1045
+ }
1046
+
1047
+ function OAuthAutoView() {
1048
+ const code = createMemo(() => {
1049
+ const instructions = store.authorization?.instructions
1050
+ if (instructions?.includes(":")) {
1051
+ return instructions.split(":").pop()?.trim()
1052
+ }
1053
+ return instructions
1054
+ })
1055
+
1056
+ onMount(() => {
1057
+ const poll = async () => {
1058
+ const authorization = store.authorization
1059
+ if (!authorization || !alive.value) return
1060
+ const result = await serverSDK()
1061
+ .api.integration.oauth.status({
1062
+ integrationID: props.provider,
1063
+ attemptID: authorization.attemptID,
1064
+ location: location(),
1065
+ })
1066
+ .then((value) => ({ ok: true as const, status: value.data }))
1067
+ .catch((error) => ({ ok: false as const, error }))
1068
+ if (!alive.value) return
1069
+ if (!result.ok) {
1070
+ dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
1071
+ return
1072
+ }
1073
+ if (result.status.status === "complete") {
1074
+ await complete()
1075
+ return
1076
+ }
1077
+ if (result.status.status === "failed") {
1078
+ dispatch({ type: "auth.error", error: result.status.message })
1079
+ return
1080
+ }
1081
+ if (result.status.status === "expired") {
1082
+ dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
1083
+ return
1084
+ }
1085
+ timer.current = setTimeout(poll, 1_000)
1086
+ }
1087
+ void poll()
1088
+ })
1089
+
1090
+ return (
1091
+ <div class="flex flex-col gap-6">
1092
+ <div class="text-14-regular text-text-base">
1093
+ {language.t("provider.connect.oauth.auto.visit.prefix")}
1094
+ <ExternalLink href={store.authorization!.url}>
1095
+ {language.t("provider.connect.oauth.auto.visit.link")}
1096
+ </ExternalLink>
1097
+ {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
1098
+ </div>
1099
+ <TextField
1100
+ label={language.t("provider.connect.oauth.auto.confirmationCode")}
1101
+ class="font-mono"
1102
+ value={code()}
1103
+ readOnly
1104
+ copyable
1105
+ />
1106
+ <div class="text-14-regular text-text-base flex items-center gap-4">
1107
+ <Spinner />
1108
+ <span>{language.t("provider.connect.status.waiting")}</span>
1109
+ </div>
1110
+ </div>
1111
+ )
1112
+ }
1113
+
1114
+ return (
1115
+ <div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
1116
+ <div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}>
1117
+ <ProviderIcon
1118
+ id={props.provider}
1119
+ class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
1120
+ />
1121
+ <div
1122
+ class={
1123
+ newLayout()
1124
+ ? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
1125
+ : "text-16-medium text-text-strong"
1126
+ }
1127
+ >
1128
+ <Switch>
1129
+ <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
1130
+ {language.t("provider.connect.title.anthropicProMax")}
1131
+ </Match>
1132
+ <Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
1133
+ </Switch>
1134
+ </div>
1135
+ </div>
1136
+ <div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}>
1137
+ <div
1138
+ onKeyDown={handleKey}
1139
+ tabIndex={newLayout() ? undefined : 0}
1140
+ autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
1141
+ >
1142
+ <Switch>
1143
+ <Match when={loading()}>
1144
+ <div class="text-14-regular text-text-base">
1145
+ <div class="flex items-center gap-x-2">
1146
+ <Spinner />
1147
+ <span>{language.t("provider.connect.status.inProgress")}</span>
1148
+ </div>
1149
+ </div>
1150
+ </Match>
1151
+ <Match when={store.methodIndex === undefined}>
1152
+ <MethodSelection />
1153
+ </Match>
1154
+ <Match when={store.state === "pending"}>
1155
+ <div class="text-14-regular text-text-base">
1156
+ <div class="flex items-center gap-x-2">
1157
+ <Spinner />
1158
+ <span>{language.t("provider.connect.status.inProgress")}</span>
1159
+ </div>
1160
+ </div>
1161
+ </Match>
1162
+ <Match when={store.state === "prompt"}>
1163
+ <AuthPromptsView />
1164
+ </Match>
1165
+ <Match when={store.state === "error"}>
1166
+ <div class="text-14-regular text-text-base">
1167
+ <div class="flex items-center gap-x-2">
1168
+ <Icon name="circle-ban-sign" class="text-icon-critical-base" />
1169
+ <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
1170
+ </div>
1171
+ </div>
1172
+ </Match>
1173
+ <Match when={method()?.type === "key"}>
1174
+ <ApiAuthView />
1175
+ </Match>
1176
+ <Match when={method()?.type === "oauth"}>
1177
+ <Switch>
1178
+ <Match when={store.authorization?.mode === "code"}>
1179
+ <OAuthCodeView />
1180
+ </Match>
1181
+ <Match when={store.authorization?.mode === "auto"}>
1182
+ <OAuthAutoView />
1183
+ </Match>
1184
+ </Switch>
1185
+ </Match>
1186
+ </Switch>
1187
+ </div>
1188
+ </div>
1189
+ </div>
1190
+ )
1191
+ }
packages/app/src/components/dialog-custom-provider-form.ts ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
2
+ const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
3
+
4
+ type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
5
+
6
+ export type ModelErr = {
7
+ id?: string
8
+ name?: string
9
+ }
10
+
11
+ export type HeaderErr = {
12
+ key?: string
13
+ value?: string
14
+ }
15
+
16
+ export type ModelRow = {
17
+ row: string
18
+ id: string
19
+ name: string
20
+ err: ModelErr
21
+ }
22
+
23
+ export type HeaderRow = {
24
+ row: string
25
+ key: string
26
+ value: string
27
+ err: HeaderErr
28
+ }
29
+
30
+ export type FormState = {
31
+ providerID: string
32
+ name: string
33
+ baseURL: string
34
+ apiKey: string
35
+ models: ModelRow[]
36
+ headers: HeaderRow[]
37
+ err: {
38
+ providerID?: string
39
+ name?: string
40
+ baseURL?: string
41
+ }
42
+ }
43
+
44
+ type ValidateArgs = {
45
+ form: FormState
46
+ t: Translator
47
+ disabledProviders: string[]
48
+ existingProviderIDs: Set<string>
49
+ }
50
+
51
+ export function validateCustomProvider(input: ValidateArgs) {
52
+ const providerID = input.form.providerID.trim()
53
+ const name = input.form.name.trim()
54
+ const baseURL = input.form.baseURL.trim()
55
+ const apiKey = input.form.apiKey.trim()
56
+
57
+ const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
58
+ const key = apiKey && !env ? apiKey : undefined
59
+
60
+ const idError = !providerID
61
+ ? input.t("provider.custom.error.providerID.required")
62
+ : !PROVIDER_ID.test(providerID)
63
+ ? input.t("provider.custom.error.providerID.format")
64
+ : undefined
65
+
66
+ const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
67
+ const urlError = !baseURL
68
+ ? input.t("provider.custom.error.baseURL.required")
69
+ : !/^https?:\/\//.test(baseURL)
70
+ ? input.t("provider.custom.error.baseURL.format")
71
+ : undefined
72
+
73
+ const disabled = input.disabledProviders.includes(providerID)
74
+ const existsError = idError
75
+ ? undefined
76
+ : input.existingProviderIDs.has(providerID) && !disabled
77
+ ? input.t("provider.custom.error.providerID.exists")
78
+ : undefined
79
+
80
+ const seenModels = new Set<string>()
81
+ const models = input.form.models.map((m) => {
82
+ const id = m.id.trim()
83
+ const idError = !id
84
+ ? input.t("provider.custom.error.required")
85
+ : seenModels.has(id)
86
+ ? input.t("provider.custom.error.duplicate")
87
+ : (() => {
88
+ seenModels.add(id)
89
+ return undefined
90
+ })()
91
+ const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
92
+ return { id: idError, name: nameError }
93
+ })
94
+ const modelsValid = models.every((m) => !m.id && !m.name)
95
+ const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
96
+
97
+ const seenHeaders = new Set<string>()
98
+ const headers = input.form.headers.map((h) => {
99
+ const key = h.key.trim()
100
+ const value = h.value.trim()
101
+
102
+ if (!key && !value) return {}
103
+ const keyError = !key
104
+ ? input.t("provider.custom.error.required")
105
+ : seenHeaders.has(key.toLowerCase())
106
+ ? input.t("provider.custom.error.duplicate")
107
+ : (() => {
108
+ seenHeaders.add(key.toLowerCase())
109
+ return undefined
110
+ })()
111
+ const valueError = !value ? input.t("provider.custom.error.required") : undefined
112
+ return { key: keyError, value: valueError }
113
+ })
114
+ const headersValid = headers.every((h) => !h.key && !h.value)
115
+ const headerConfig = Object.fromEntries(
116
+ input.form.headers
117
+ .map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
118
+ .filter((h) => !!h.key && !!h.value)
119
+ .map((h) => [h.key, h.value]),
120
+ )
121
+
122
+ const err = {
123
+ providerID: idError ?? existsError,
124
+ name: nameError,
125
+ baseURL: urlError,
126
+ }
127
+
128
+ const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
129
+ if (!ok) return { err, models, headers }
130
+
131
+ return {
132
+ err,
133
+ models,
134
+ headers,
135
+ result: {
136
+ providerID,
137
+ name,
138
+ key,
139
+ config: {
140
+ npm: OPENAI_COMPATIBLE,
141
+ name,
142
+ ...(env ? { env: [env] } : {}),
143
+ options: {
144
+ baseURL,
145
+ ...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
146
+ },
147
+ models: modelConfig,
148
+ },
149
+ },
150
+ }
151
+ }
152
+
153
+ let row = 0
154
+
155
+ const nextRow = () => `row-${row++}`
156
+
157
+ export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
158
+ export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
packages/app/src/components/dialog-custom-provider.test.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, test } from "bun:test"
2
+ import { validateCustomProvider } from "./dialog-custom-provider-form"
3
+
4
+ const t = (key: string) => key
5
+
6
+ describe("validateCustomProvider", () => {
7
+ test("builds trimmed config payload", () => {
8
+ const result = validateCustomProvider({
9
+ form: {
10
+ providerID: "custom-provider",
11
+ name: " Custom Provider ",
12
+ baseURL: "https://api.example.com ",
13
+ apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
14
+ models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
15
+ headers: [
16
+ { row: "h0", key: " X-Test ", value: " enabled ", err: {} },
17
+ { row: "h1", key: "", value: "", err: {} },
18
+ ],
19
+ err: {},
20
+ },
21
+ t,
22
+ disabledProviders: [],
23
+ existingProviderIDs: new Set(),
24
+ })
25
+
26
+ expect(result.result).toEqual({
27
+ providerID: "custom-provider",
28
+ name: "Custom Provider",
29
+ key: undefined,
30
+ config: {
31
+ npm: "@ai-sdk/openai-compatible",
32
+ name: "Custom Provider",
33
+ env: ["CUSTOM_PROVIDER_KEY"],
34
+ options: {
35
+ baseURL: "https://api.example.com",
36
+ headers: {
37
+ "X-Test": "enabled",
38
+ },
39
+ },
40
+ models: {
41
+ "model-a": { name: "Model A" },
42
+ },
43
+ },
44
+ })
45
+ })
46
+
47
+ test("flags duplicate rows and allows reconnecting disabled providers", () => {
48
+ const result = validateCustomProvider({
49
+ form: {
50
+ providerID: "custom-provider",
51
+ name: "Provider",
52
+ baseURL: "https://api.example.com",
53
+ apiKey: "secret",
54
+ models: [
55
+ { row: "m0", id: "model-a", name: "Model A", err: {} },
56
+ { row: "m1", id: "model-a", name: "Model A 2", err: {} },
57
+ ],
58
+ headers: [
59
+ { row: "h0", key: "Authorization", value: "one", err: {} },
60
+ { row: "h1", key: "authorization", value: "two", err: {} },
61
+ ],
62
+ err: {},
63
+ },
64
+ t,
65
+ disabledProviders: ["custom-provider"],
66
+ existingProviderIDs: new Set(["custom-provider"]),
67
+ })
68
+
69
+ expect(result.result).toBeUndefined()
70
+ expect(result.err.providerID).toBeUndefined()
71
+ expect(result.models[1]).toEqual({
72
+ id: "provider.custom.error.duplicate",
73
+ name: undefined,
74
+ })
75
+ expect(result.headers[1]).toEqual({
76
+ key: "provider.custom.error.duplicate",
77
+ value: undefined,
78
+ })
79
+ })
80
+ })
packages/app/src/components/dialog-custom-provider.tsx ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@opencode-ai/ui/button"
2
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
3
+ import { Dialog } from "@opencode-ai/ui/dialog"
4
+ import { IconButton } from "@opencode-ai/ui/icon-button"
5
+ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
6
+ import { useMutation } from "@tanstack/solid-query"
7
+ import { TextField } from "@opencode-ai/ui/text-field"
8
+ import { showToast } from "@/utils/toast"
9
+ import { batch, For } from "solid-js"
10
+ import { createStore, produce } from "solid-js/store"
11
+ import { ExternalLink } from "@/components/external-link"
12
+ import { useServerSDK } from "@/context/server-sdk"
13
+ import { useServerSync } from "@/context/server-sync"
14
+ import { useLanguage } from "@/context/language"
15
+ import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
16
+
17
+ type Props = {
18
+ onBack: () => void
19
+ }
20
+
21
+ export function DialogCustomProvider(props: Props) {
22
+ const language = useLanguage()
23
+
24
+ return (
25
+ <Dialog
26
+ class="h-full"
27
+ title={
28
+ <IconButton
29
+ tabIndex={-1}
30
+ icon="arrow-left"
31
+ variant="ghost"
32
+ onClick={props.onBack}
33
+ aria-label={language.t("common.goBack")}
34
+ />
35
+ }
36
+ transition
37
+ >
38
+ <CustomProviderForm />
39
+ </Dialog>
40
+ )
41
+ }
42
+
43
+ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
44
+ const dialog = useDialog()
45
+ const serverSync = useServerSync()
46
+ const serverSDK = useServerSDK()
47
+ const language = useLanguage()
48
+
49
+ const [form, setForm] = createStore<FormState>({
50
+ providerID: "",
51
+ name: "",
52
+ baseURL: "",
53
+ apiKey: "",
54
+ models: [modelRow()],
55
+ headers: [headerRow()],
56
+ err: {},
57
+ })
58
+
59
+ const addModel = () => {
60
+ setForm(
61
+ "models",
62
+ produce((rows) => {
63
+ rows.push(modelRow())
64
+ }),
65
+ )
66
+ }
67
+
68
+ const removeModel = (index: number) => {
69
+ if (form.models.length <= 1) return
70
+ setForm(
71
+ "models",
72
+ produce((rows) => {
73
+ rows.splice(index, 1)
74
+ }),
75
+ )
76
+ }
77
+
78
+ const addHeader = () => {
79
+ setForm(
80
+ "headers",
81
+ produce((rows) => {
82
+ rows.push(headerRow())
83
+ }),
84
+ )
85
+ }
86
+
87
+ const removeHeader = (index: number) => {
88
+ if (form.headers.length <= 1) return
89
+ setForm(
90
+ "headers",
91
+ produce((rows) => {
92
+ rows.splice(index, 1)
93
+ }),
94
+ )
95
+ }
96
+
97
+ const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
98
+ setForm(key, value)
99
+ if (key === "apiKey") return
100
+ setForm("err", key, undefined)
101
+ }
102
+
103
+ const setModel = (index: number, key: "id" | "name", value: string) => {
104
+ batch(() => {
105
+ setForm("models", index, key, value)
106
+ setForm("models", index, "err", key, undefined)
107
+ })
108
+ }
109
+
110
+ const setHeader = (index: number, key: "key" | "value", value: string) => {
111
+ batch(() => {
112
+ setForm("headers", index, key, value)
113
+ setForm("headers", index, "err", key, undefined)
114
+ })
115
+ }
116
+
117
+ const validate = () => {
118
+ const output = validateCustomProvider({
119
+ form,
120
+ t: language.t,
121
+ disabledProviders: serverSync().data.config.disabled_providers ?? [],
122
+ existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
123
+ })
124
+ batch(() => {
125
+ setForm("err", output.err)
126
+ output.models.forEach((err, index) => setForm("models", index, "err", err))
127
+ output.headers.forEach((err, index) => setForm("headers", index, "err", err))
128
+ })
129
+ return output.result
130
+ }
131
+
132
+ const saveMutation = useMutation(() => ({
133
+ mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
134
+ if ((await serverSDK().protocol) !== "v1") throw new Error(language.t("provider.custom.unavailable"))
135
+ const disabledProviders = serverSync().data.config.disabled_providers ?? []
136
+ const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
137
+
138
+ if (result.key) {
139
+ await serverSDK().client.auth.set({
140
+ providerID: result.providerID,
141
+ auth: {
142
+ type: "api",
143
+ key: result.key,
144
+ },
145
+ })
146
+ }
147
+
148
+ await serverSync().updateConfig({
149
+ provider: { [result.providerID]: result.config },
150
+ disabled_providers: nextDisabled,
151
+ })
152
+ return result
153
+ },
154
+ onSuccess: (result) => {
155
+ dialog.close()
156
+ showToast({
157
+ variant: "success",
158
+ icon: "circle-check",
159
+ title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
160
+ description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
161
+ })
162
+ },
163
+ onError: (err) => {
164
+ const message = err instanceof Error ? err.message : String(err)
165
+ showToast({ title: language.t("common.requestFailed"), description: message })
166
+ },
167
+ }))
168
+
169
+ const save = (e: SubmitEvent) => {
170
+ e.preventDefault()
171
+ if (saveMutation.isPending) return
172
+
173
+ const result = validate()
174
+ if (!result) return
175
+ saveMutation.mutate(result)
176
+ }
177
+
178
+ return (
179
+ <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
180
+ <div class="px-2.5 flex gap-4 items-center">
181
+ <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
182
+ <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
183
+ </div>
184
+
185
+ <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
186
+ <p class="text-14-regular text-text-base">
187
+ {language.t("provider.custom.description.prefix")}
188
+ <ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
189
+ {language.t("provider.custom.description.link")}
190
+ </ExternalLink>
191
+ {language.t("provider.custom.description.suffix")}
192
+ </p>
193
+
194
+ <div class="flex flex-col gap-4">
195
+ <TextField
196
+ autofocus={props.autofocus ?? true}
197
+ label={language.t("provider.custom.field.providerID.label")}
198
+ placeholder={language.t("provider.custom.field.providerID.placeholder")}
199
+ description={language.t("provider.custom.field.providerID.description")}
200
+ value={form.providerID}
201
+ onChange={(v) => setField("providerID", v)}
202
+ validationState={form.err.providerID ? "invalid" : undefined}
203
+ error={form.err.providerID}
204
+ />
205
+ <TextField
206
+ label={language.t("provider.custom.field.name.label")}
207
+ placeholder={language.t("provider.custom.field.name.placeholder")}
208
+ value={form.name}
209
+ onChange={(v) => setField("name", v)}
210
+ validationState={form.err.name ? "invalid" : undefined}
211
+ error={form.err.name}
212
+ />
213
+ <TextField
214
+ label={language.t("provider.custom.field.baseURL.label")}
215
+ placeholder={language.t("provider.custom.field.baseURL.placeholder")}
216
+ value={form.baseURL}
217
+ onChange={(v) => setField("baseURL", v)}
218
+ validationState={form.err.baseURL ? "invalid" : undefined}
219
+ error={form.err.baseURL}
220
+ />
221
+ <TextField
222
+ label={language.t("provider.custom.field.apiKey.label")}
223
+ placeholder={language.t("provider.custom.field.apiKey.placeholder")}
224
+ description={language.t("provider.custom.field.apiKey.description")}
225
+ value={form.apiKey}
226
+ onChange={(v) => setField("apiKey", v)}
227
+ />
228
+ </div>
229
+
230
+ <div class="flex flex-col gap-3">
231
+ <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
232
+ <For each={form.models}>
233
+ {(m, i) => (
234
+ <div class="flex gap-2 items-start" data-row={m.row}>
235
+ <div class="flex-1">
236
+ <TextField
237
+ label={language.t("provider.custom.models.id.label")}
238
+ hideLabel
239
+ placeholder={language.t("provider.custom.models.id.placeholder")}
240
+ value={m.id}
241
+ onChange={(v) => setModel(i(), "id", v)}
242
+ validationState={m.err.id ? "invalid" : undefined}
243
+ error={m.err.id}
244
+ />
245
+ </div>
246
+ <div class="flex-1">
247
+ <TextField
248
+ label={language.t("provider.custom.models.name.label")}
249
+ hideLabel
250
+ placeholder={language.t("provider.custom.models.name.placeholder")}
251
+ value={m.name}
252
+ onChange={(v) => setModel(i(), "name", v)}
253
+ validationState={m.err.name ? "invalid" : undefined}
254
+ error={m.err.name}
255
+ />
256
+ </div>
257
+ <IconButton
258
+ type="button"
259
+ icon="trash"
260
+ variant="ghost"
261
+ class="mt-1.5"
262
+ onClick={() => removeModel(i())}
263
+ disabled={form.models.length <= 1}
264
+ aria-label={language.t("provider.custom.models.remove")}
265
+ />
266
+ </div>
267
+ )}
268
+ </For>
269
+ <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
270
+ {language.t("provider.custom.models.add")}
271
+ </Button>
272
+ </div>
273
+
274
+ <div class="flex flex-col gap-3">
275
+ <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
276
+ <For each={form.headers}>
277
+ {(h, i) => (
278
+ <div class="flex gap-2 items-start" data-row={h.row}>
279
+ <div class="flex-1">
280
+ <TextField
281
+ label={language.t("provider.custom.headers.key.label")}
282
+ hideLabel
283
+ placeholder={language.t("provider.custom.headers.key.placeholder")}
284
+ value={h.key}
285
+ onChange={(v) => setHeader(i(), "key", v)}
286
+ validationState={h.err.key ? "invalid" : undefined}
287
+ error={h.err.key}
288
+ />
289
+ </div>
290
+ <div class="flex-1">
291
+ <TextField
292
+ label={language.t("provider.custom.headers.value.label")}
293
+ hideLabel
294
+ placeholder={language.t("provider.custom.headers.value.placeholder")}
295
+ value={h.value}
296
+ onChange={(v) => setHeader(i(), "value", v)}
297
+ validationState={h.err.value ? "invalid" : undefined}
298
+ error={h.err.value}
299
+ />
300
+ </div>
301
+ <IconButton
302
+ type="button"
303
+ icon="trash"
304
+ variant="ghost"
305
+ class="mt-1.5"
306
+ onClick={() => removeHeader(i())}
307
+ disabled={form.headers.length <= 1}
308
+ aria-label={language.t("provider.custom.headers.remove")}
309
+ />
310
+ </div>
311
+ )}
312
+ </For>
313
+ <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
314
+ {language.t("provider.custom.headers.add")}
315
+ </Button>
316
+ </div>
317
+
318
+ <Button
319
+ class="w-auto self-start"
320
+ type="submit"
321
+ size="large"
322
+ variant="primary"
323
+ disabled={saveMutation.isPending}
324
+ >
325
+ {saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
326
+ </Button>
327
+ </form>
328
+ </div>
329
+ )
330
+ }
packages/app/src/components/dialog-edit-project-v2.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
2
+ import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
3
+ import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
4
+ import { Field } from "@opencode-ai/ui/v2/field-v2"
5
+ import { Icon } from "@opencode-ai/ui/v2/icon"
6
+ import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
7
+ import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
8
+ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
9
+ import { For, Show } from "solid-js"
10
+ import { useLanguage } from "@/context/language"
11
+ import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
12
+ import { ServerConnection } from "@/context/server"
13
+ import { getProjectAvatarSource } from "@/pages/layout/helpers"
14
+ import { createEditProjectModel } from "./edit-project"
15
+
16
+ export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
17
+ const language = useLanguage()
18
+ const model = createEditProjectModel(props)
19
+
20
+ return (
21
+ <Dialog fit>
22
+ <form onSubmit={model.submit} class="contents">
23
+ <DialogHeader>
24
+ <DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
25
+ </DialogHeader>
26
+ <DividerV2 />
27
+ <DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
28
+ <Field>
29
+ <Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
30
+ <TextInputV2
31
+ autofocus
32
+ appearance="large"
33
+ class="!w-full"
34
+ value={model.store.name}
35
+ placeholder={model.folderName()}
36
+ onInput={(event) => model.setStore("name", event.currentTarget.value)}
37
+ />
38
+ </Field>
39
+
40
+ <div class="flex w-full flex-col gap-2">
41
+ <div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
42
+ {language.t("dialog.project.edit.icon")}
43
+ </div>
44
+ <div class="flex items-center gap-3">
45
+ <button
46
+ type="button"
47
+ aria-label={language.t("dialog.project.edit.icon.alt")}
48
+ class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
49
+ classList={{
50
+ "bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
51
+ }}
52
+ onMouseEnter={() => model.setStore("iconHover", true)}
53
+ onMouseLeave={() => model.setStore("iconHover", false)}
54
+ onDrop={model.drop}
55
+ onDragOver={model.dragOver}
56
+ onDragLeave={model.dragLeave}
57
+ onClick={model.iconClick}
58
+ >
59
+ <ProjectAvatar
60
+ fallback={model.store.name || model.defaultName()}
61
+ src={getProjectAvatarSource(props.project.id, {
62
+ color: model.store.color,
63
+ url: props.project.icon?.url,
64
+ override: model.store.iconOverride,
65
+ })}
66
+ variant={getProjectAvatarVariant(model.store.color)}
67
+ class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
68
+ />
69
+ <span
70
+ class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
71
+ classList={{
72
+ "opacity-100": model.store.iconHover,
73
+ "opacity-0": !model.store.iconHover,
74
+ }}
75
+ >
76
+ <Icon name={model.store.iconOverride ? "close" : "outline-share"} />
77
+ </span>
78
+ </button>
79
+ <input
80
+ ref={(element) => {
81
+ model.setIconInput(element)
82
+ }}
83
+ type="file"
84
+ accept="image/*"
85
+ class="hidden"
86
+ onChange={model.inputChange}
87
+ />
88
+ <div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
89
+ <span>{language.t("dialog.project.edit.icon.hint")}</span>
90
+ <span>{language.t("dialog.project.edit.icon.recommended")}</span>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ <Show when={!model.store.iconOverride}>
96
+ <div class="flex w-full flex-col gap-2">
97
+ <div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
98
+ {language.t("dialog.project.edit.color")}
99
+ </div>
100
+ <div class="-ml-1 flex gap-1.5">
101
+ <For each={PROJECT_AVATAR_VARIANTS}>
102
+ {(color) => (
103
+ <button
104
+ type="button"
105
+ aria-label={language.t("dialog.project.edit.color.select", { color })}
106
+ aria-pressed={getProjectAvatarVariant(model.store.color) === color}
107
+ class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
108
+ classList={{
109
+ "bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
110
+ getProjectAvatarVariant(model.store.color) === color,
111
+ }}
112
+ onClick={() => {
113
+ if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
114
+ model.setStore(
115
+ "color",
116
+ getProjectAvatarVariant(model.store.color) === color ? undefined : color,
117
+ )
118
+ }}
119
+ >
120
+ <ProjectAvatar
121
+ fallback={model.store.name || model.defaultName()}
122
+ variant={getProjectAvatarVariant(color)}
123
+ class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
124
+ />
125
+ </button>
126
+ )}
127
+ </For>
128
+ </div>
129
+ </div>
130
+ </Show>
131
+
132
+ <Field>
133
+ <Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
134
+ <Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
135
+ <TextareaV2
136
+ class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
137
+ rows={3}
138
+ value={model.store.startup}
139
+ placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
140
+ spellcheck={false}
141
+ onInput={(event) => model.setStore("startup", event.currentTarget.value)}
142
+ />
143
+ </Field>
144
+ </DialogBody>
145
+ <DialogFooter>
146
+ <ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
147
+ {language.t("common.cancel")}
148
+ </ButtonV2>
149
+ <ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
150
+ {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
151
+ </ButtonV2>
152
+ </DialogFooter>
153
+ </form>
154
+ </Dialog>
155
+ )
156
+ }
packages/app/src/components/dialog-edit-project.tsx ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@opencode-ai/ui/button"
2
+ import { Dialog } from "@opencode-ai/ui/dialog"
3
+ import { TextField } from "@opencode-ai/ui/text-field"
4
+ import { Icon } from "@opencode-ai/ui/icon"
5
+ import { For, Show } from "solid-js"
6
+ import { type LocalProject, getAvatarColors } from "@/context/layout"
7
+ import { Avatar } from "@opencode-ai/ui/avatar"
8
+ import { useLanguage } from "@/context/language"
9
+ import { getProjectAvatarSource } from "@/pages/layout/helpers"
10
+ import { ServerConnection } from "@/context/server"
11
+ import { createEditProjectModel } from "./edit-project"
12
+
13
+ const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
14
+
15
+ export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
16
+ const language = useLanguage()
17
+ const model = createEditProjectModel(props)
18
+
19
+ return (
20
+ <Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
21
+ <form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
22
+ <div class="flex flex-col gap-4">
23
+ <TextField
24
+ autofocus
25
+ type="text"
26
+ label={language.t("dialog.project.edit.name")}
27
+ placeholder={model.folderName()}
28
+ value={model.store.name}
29
+ onChange={(v) => model.setStore("name", v)}
30
+ />
31
+
32
+ <div class="flex flex-col gap-2">
33
+ <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
34
+ <div class="flex gap-3 items-start">
35
+ <div
36
+ class="relative"
37
+ onMouseEnter={() => model.setStore("iconHover", true)}
38
+ onMouseLeave={() => model.setStore("iconHover", false)}
39
+ >
40
+ <div
41
+ class="relative size-16 rounded-md transition-colors cursor-pointer"
42
+ classList={{
43
+ "border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
44
+ "border-border-base hover:border-border-strong": !model.store.dragOver,
45
+ "overflow-hidden": !!model.store.iconOverride,
46
+ }}
47
+ onDrop={model.drop}
48
+ onDragOver={model.dragOver}
49
+ onDragLeave={model.dragLeave}
50
+ onClick={model.iconClick}
51
+ >
52
+ <Show
53
+ when={getProjectAvatarSource(props.project.id, {
54
+ color: model.store.color,
55
+ url: props.project.icon?.url,
56
+ override: model.store.iconOverride,
57
+ })}
58
+ fallback={
59
+ <div class="size-full flex items-center justify-center">
60
+ <Avatar
61
+ fallback={model.store.name || model.defaultName()}
62
+ {...getAvatarColors(model.store.color)}
63
+ class="size-full text-[32px]"
64
+ />
65
+ </div>
66
+ }
67
+ >
68
+ {(src) => (
69
+ <img
70
+ src={src()}
71
+ alt={language.t("dialog.project.edit.icon.alt")}
72
+ class="size-full object-cover"
73
+ />
74
+ )}
75
+ </Show>
76
+ </div>
77
+ <div
78
+ class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
79
+ classList={{
80
+ "opacity-100": model.store.iconHover && !model.store.iconOverride,
81
+ "opacity-0": !(model.store.iconHover && !model.store.iconOverride),
82
+ }}
83
+ >
84
+ <Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
85
+ </div>
86
+ <div
87
+ class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
88
+ classList={{
89
+ "opacity-100": model.store.iconHover && !!model.store.iconOverride,
90
+ "opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
91
+ }}
92
+ >
93
+ <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
94
+ </div>
95
+ </div>
96
+ <input
97
+ id="icon-upload"
98
+ ref={(el) => {
99
+ model.setIconInput(el)
100
+ }}
101
+ type="file"
102
+ accept="image/*"
103
+ class="hidden"
104
+ onChange={model.inputChange}
105
+ />
106
+ <div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
107
+ <span>{language.t("dialog.project.edit.icon.hint")}</span>
108
+ <span>{language.t("dialog.project.edit.icon.recommended")}</span>
109
+ </div>
110
+ </div>
111
+ </div>
112
+
113
+ <Show when={!model.store.iconOverride}>
114
+ <div class="flex flex-col gap-2">
115
+ <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
116
+ <div class="flex gap-1.5">
117
+ <For each={AVATAR_COLOR_KEYS}>
118
+ {(color) => (
119
+ <button
120
+ type="button"
121
+ aria-label={language.t("dialog.project.edit.color.select", { color })}
122
+ aria-pressed={model.store.color === color}
123
+ classList={{
124
+ "flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
125
+ "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
126
+ model.store.color === color,
127
+ "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
128
+ model.store.color !== color,
129
+ }}
130
+ onClick={() => {
131
+ if (model.store.color === color && !props.project.icon?.url) return
132
+ model.setStore("color", model.store.color === color ? undefined : color)
133
+ }}
134
+ >
135
+ <Avatar
136
+ fallback={model.store.name || model.defaultName()}
137
+ {...getAvatarColors(color)}
138
+ class="size-full rounded"
139
+ />
140
+ </button>
141
+ )}
142
+ </For>
143
+ </div>
144
+ </div>
145
+ </Show>
146
+
147
+ <TextField
148
+ multiline
149
+ label={language.t("dialog.project.edit.worktree.startup")}
150
+ description={language.t("dialog.project.edit.worktree.startup.description")}
151
+ placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
152
+ value={model.store.startup}
153
+ onChange={(v) => model.setStore("startup", v)}
154
+ spellcheck={false}
155
+ class="max-h-14 w-full overflow-y-auto font-mono text-xs"
156
+ />
157
+ </div>
158
+
159
+ <div class="flex justify-end gap-2">
160
+ <Button type="button" variant="ghost" size="large" onClick={model.close}>
161
+ {language.t("common.cancel")}
162
+ </Button>
163
+ <Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
164
+ {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
165
+ </Button>
166
+ </div>
167
+ </form>
168
+ </Dialog>
169
+ )
170
+ }
packages/app/src/components/dialog-fork.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Component, createMemo } from "solid-js"
2
+ import { useNavigate, useParams } from "@solidjs/router"
3
+ import { useSync } from "@/context/sync"
4
+ import { useSDK } from "@/context/sdk"
5
+ import { usePrompt } from "@/context/prompt"
6
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
7
+ import { Dialog } from "@opencode-ai/ui/dialog"
8
+ import { List } from "@opencode-ai/ui/list"
9
+ import { showToast } from "@/utils/toast"
10
+ import { extractPromptFromParts } from "@/utils/prompt"
11
+ import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
12
+ import { base64Encode } from "@opencode-ai/core/util/encode"
13
+ import { useLanguage } from "@/context/language"
14
+
15
+ interface ForkableMessage {
16
+ id: string
17
+ text: string
18
+ time: string
19
+ }
20
+
21
+ function formatTime(date: Date): string {
22
+ return date.toLocaleTimeString(undefined, { timeStyle: "short" })
23
+ }
24
+
25
+ export const DialogFork: Component = () => {
26
+ const params = useParams()
27
+ const navigate = useNavigate()
28
+ const sync = useSync()
29
+ const sdk = useSDK()
30
+ const prompt = usePrompt()
31
+ const dialog = useDialog()
32
+ const language = useLanguage()
33
+
34
+ const messages = createMemo((): ForkableMessage[] => {
35
+ const sessionID = params.id
36
+ if (!sessionID) return []
37
+
38
+ const msgs = sync().data.message[sessionID] ?? []
39
+ const result: ForkableMessage[] = []
40
+
41
+ for (const message of msgs) {
42
+ if (message.role !== "user") continue
43
+
44
+ const parts = sync().data.part[message.id] ?? []
45
+ const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
46
+ if (!textPart) continue
47
+
48
+ result.push({
49
+ id: message.id,
50
+ text: textPart.text.replace(/\n/g, " ").slice(0, 200),
51
+ time: formatTime(new Date(message.time.created)),
52
+ })
53
+ }
54
+
55
+ return result.reverse()
56
+ })
57
+
58
+ const handleSelect = (item: ForkableMessage | undefined) => {
59
+ if (!item) return
60
+
61
+ const sessionID = params.id
62
+ if (!sessionID) return
63
+
64
+ const parts = sync().data.part[item.id] ?? []
65
+ const restored = extractPromptFromParts(parts, {
66
+ directory: sdk().directory,
67
+ attachmentName: language.t("common.attachment"),
68
+ })
69
+ const dir = base64Encode(sdk().directory)
70
+
71
+ sdk()
72
+ .api.session.fork({ sessionID, messageID: item.id })
73
+ .then((forked) => {
74
+ dialog.close()
75
+ prompt.set(restored, undefined, { dir, id: forked.id })
76
+ navigate(`/${dir}/session/${forked.id}`)
77
+ })
78
+ .catch((err: unknown) => {
79
+ const message = err instanceof Error ? err.message : String(err)
80
+ showToast({ title: language.t("common.requestFailed"), description: message })
81
+ })
82
+ }
83
+
84
+ return (
85
+ <Dialog title={language.t("command.session.fork")}>
86
+ <List
87
+ class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
88
+ search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
89
+ emptyMessage={language.t("dialog.fork.empty")}
90
+ key={(x) => x.id}
91
+ items={messages}
92
+ filterKeys={["text"]}
93
+ onSelect={handleSelect}
94
+ >
95
+ {(item) => (
96
+ <div class="w-full flex items-center gap-2">
97
+ <span class="truncate flex-1 min-w-0 text-left font-normal">{item.text}</span>
98
+ <span class="text-text-weak shrink-0 font-normal">{item.time}</span>
99
+ </div>
100
+ )}
101
+ </List>
102
+ </Dialog>
103
+ )
104
+ }
packages/app/src/components/dialog-manage-models.tsx ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Dialog } from "@opencode-ai/ui/dialog"
2
+ import { List } from "@opencode-ai/ui/list"
3
+ import { Switch } from "@opencode-ai/ui/switch"
4
+ import { Tooltip } from "@opencode-ai/ui/tooltip"
5
+ import { Button } from "@opencode-ai/ui/button"
6
+ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
7
+ import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
8
+ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
9
+ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
10
+ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
11
+ import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2"
12
+ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
13
+ import { useFilteredList } from "@opencode-ai/ui/hooks"
14
+ import { For, Show, type Component } from "solid-js"
15
+ import { useLocal } from "@/context/local"
16
+ import { popularProviders } from "@/hooks/use-providers"
17
+ import { useLanguage } from "@/context/language"
18
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
19
+ import { DialogConnectProvider } from "./dialog-connect-provider"
20
+ import { decode64 } from "@/utils/base64"
21
+ import { SettingsListV2 } from "./settings-v2/parts/list"
22
+ import { SettingsRowV2 } from "./settings-v2/parts/row"
23
+ import "./settings-v2/settings-v2.css"
24
+
25
+ type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
26
+
27
+ export const DialogManageModels: Component = () => {
28
+ const local = useLocal()
29
+ const language = useLanguage()
30
+ const dialog = useDialog()
31
+ const directory = () => decode64(local.slug())
32
+
33
+ const handleConnectProvider = () => {
34
+ void dialog.show(() => <DialogConnectProvider directory={directory} />)
35
+ }
36
+ const providerRank = (id: string) => popularProviders.indexOf(id)
37
+ const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
38
+ const providerVisible = (providerID: string) =>
39
+ providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
40
+ const setProviderVisibility = (providerID: string, checked: boolean) => {
41
+ providerList(providerID).forEach((x) => {
42
+ local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
43
+ })
44
+ }
45
+
46
+ return (
47
+ <Dialog
48
+ title={language.t("dialog.model.manage")}
49
+ description={language.t("dialog.model.manage.description")}
50
+ action={
51
+ <Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={handleConnectProvider}>
52
+ {language.t("command.provider.connect")}
53
+ </Button>
54
+ }
55
+ >
56
+ <List
57
+ class="px-3"
58
+ search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
59
+ emptyMessage={language.t("dialog.model.empty")}
60
+ key={(x) => `${x?.provider?.id}:${x?.id}`}
61
+ items={local.model.list()}
62
+ filterKeys={["provider.name", "name", "id"]}
63
+ sortBy={(a, b) => a.name.localeCompare(b.name)}
64
+ groupBy={(x) => x.provider.id}
65
+ groupHeader={(group) => {
66
+ const provider = group.items[0].provider
67
+ return (
68
+ <>
69
+ <span>{provider.name}</span>
70
+ <Tooltip
71
+ placement="top"
72
+ value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
73
+ >
74
+ <Switch
75
+ class="-mr-1"
76
+ checked={providerVisible(provider.id)}
77
+ onChange={(checked) => setProviderVisibility(provider.id, checked)}
78
+ hideLabel
79
+ >
80
+ {provider.name}
81
+ </Switch>
82
+ </Tooltip>
83
+ </>
84
+ )
85
+ }}
86
+ sortGroupsBy={(a, b) => {
87
+ const aRank = providerRank(a.items[0].provider.id)
88
+ const bRank = providerRank(b.items[0].provider.id)
89
+ const aPopular = aRank >= 0
90
+ const bPopular = bRank >= 0
91
+ if (aPopular && !bPopular) return -1
92
+ if (!aPopular && bPopular) return 1
93
+ return aRank - bRank
94
+ }}
95
+ onSelect={(x) => {
96
+ if (!x) return
97
+ const key = { modelID: x.id, providerID: x.provider.id }
98
+ local.model.setVisibility(key, !local.model.visible(key))
99
+ }}
100
+ >
101
+ {(i) => (
102
+ <div class="w-full flex items-center justify-between gap-x-3">
103
+ <span>{i.name}</span>
104
+ <div onClick={(e) => e.stopPropagation()}>
105
+ <Switch
106
+ checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
107
+ onChange={(checked) => {
108
+ local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
109
+ }}
110
+ />
111
+ </div>
112
+ </div>
113
+ )}
114
+ </List>
115
+ </Dialog>
116
+ )
117
+ }
118
+
119
+ export const DialogManageModelsV2: Component = () => {
120
+ const local = useLocal()
121
+ const language = useLanguage()
122
+ const dialog = useDialog()
123
+ const directory = () => decode64(local.slug())
124
+
125
+ const handleConnectProvider = () => {
126
+ void dialog.show(() => <DialogConnectProvider directory={directory} />)
127
+ }
128
+ const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
129
+ const providerVisible = (providerID: string) =>
130
+ providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
131
+ const setProviderVisibility = (providerID: string, checked: boolean) => {
132
+ providerList(providerID).forEach((x) => {
133
+ local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
134
+ })
135
+ }
136
+ const setModelVisibility = (item: ModelItem, checked: boolean) => {
137
+ local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked)
138
+ }
139
+ const list = useFilteredList<ModelItem>({
140
+ items: () => local.model.list(),
141
+ key: (x) => `${x.provider.id}:${x.id}`,
142
+ filterKeys: ["provider.name", "name", "id"],
143
+ sortBy: (a, b) => a.name.localeCompare(b.name),
144
+ groupBy: (x) => x.provider.id,
145
+ sortGroupsBy: (a, b) => {
146
+ const aRank = popularProviders.indexOf(a.category)
147
+ const bRank = popularProviders.indexOf(b.category)
148
+ const aPopular = aRank >= 0
149
+ const bPopular = bRank >= 0
150
+ if (aPopular && !bPopular) return -1
151
+ if (!aPopular && bPopular) return 1
152
+ return aRank - bRank
153
+ },
154
+ })
155
+
156
+ return (
157
+ <DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
158
+ <DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
159
+ <DialogTitleGroup
160
+ title={language.t("dialog.model.manage")}
161
+ description={language.t("dialog.model.manage.description")}
162
+ />
163
+ <ButtonV2 variant="neutral" icon="plus" onClick={handleConnectProvider}>
164
+ {language.t("command.provider.connect")}
165
+ </ButtonV2>
166
+ </DialogHeader>
167
+ <DialogBody class="flex min-h-0 flex-1 flex-col">
168
+ <div class="px-4 pt-px pb-3">
169
+ <div class="relative">
170
+ <TextInputV2
171
+ type="search"
172
+ appearance="base"
173
+ class="!w-full self-stretch"
174
+ value={list.filter()}
175
+ onInput={(event) => list.onInput(event.currentTarget.value)}
176
+ placeholder={language.t("dialog.model.search.placeholder")}
177
+ spellcheck={false}
178
+ autocorrect="off"
179
+ autocomplete="off"
180
+ autocapitalize="off"
181
+ autofocus
182
+ aria-label={language.t("dialog.model.search.placeholder")}
183
+ />
184
+ <Show when={list.filter()}>
185
+ <IconButtonV2
186
+ type="button"
187
+ variant="ghost-muted"
188
+ size="small"
189
+ class="settings-v2-tab-search-clear"
190
+ icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
191
+ onClick={() => list.clear()}
192
+ aria-label={language.t("common.clear")}
193
+ />
194
+ </Show>
195
+ </div>
196
+ </div>
197
+ <div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
198
+ <div class="settings-v2-panel settings-v2-models h-full px-4 pt-4 pb-4">
199
+ <Show
200
+ when={!list.grouped.loading}
201
+ fallback={
202
+ <div class="settings-v2-models-status">
203
+ {language.t("common.loading")}
204
+ {language.t("common.loading.ellipsis")}
205
+ </div>
206
+ }
207
+ >
208
+ <Show
209
+ when={list.flat().length > 0}
210
+ fallback={
211
+ <div class="settings-v2-models-status">
212
+ <span>{language.t("dialog.model.empty")}</span>
213
+ <Show when={list.filter()}>
214
+ <span class="settings-v2-models-status-filter">&quot;{list.filter()}&quot;</span>
215
+ </Show>
216
+ </div>
217
+ }
218
+ >
219
+ <For each={list.grouped.latest}>
220
+ {(group) => (
221
+ <div class="settings-v2-section" data-component="settings-models-provider">
222
+ <div class="settings-v2-models-group-header justify-between">
223
+ <div class="flex min-w-0 items-center gap-2">
224
+ <ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
225
+ <h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
226
+ </div>
227
+ <div>
228
+ <SwitchV2
229
+ class="mr-6"
230
+ checked={providerVisible(group.category)}
231
+ onChange={(checked) => setProviderVisibility(group.category, checked)}
232
+ hideLabel
233
+ >
234
+ {group.items[0].provider.name}
235
+ </SwitchV2>
236
+ </div>
237
+ </div>
238
+ <SettingsListV2>
239
+ <For each={group.items}>
240
+ {(item) => (
241
+ <SettingsRowV2 title={item.name} description="">
242
+ <div>
243
+ <SwitchV2
244
+ checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
245
+ onChange={(checked) => setModelVisibility(item, checked)}
246
+ hideLabel
247
+ >
248
+ {item.name}
249
+ </SwitchV2>
250
+ </div>
251
+ </SettingsRowV2>
252
+ )}
253
+ </For>
254
+ </SettingsListV2>
255
+ </div>
256
+ )}
257
+ </For>
258
+ </Show>
259
+ </Show>
260
+ </div>
261
+ </div>
262
+ </DialogBody>
263
+ </DialogV2>
264
+ )
265
+ }
packages/app/src/components/dialog-release-notes.tsx ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createSignal } from "solid-js"
2
+ import { Dialog } from "@opencode-ai/ui/dialog"
3
+ import { Button } from "@opencode-ai/ui/button"
4
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
5
+ import { useLanguage } from "@/context/language"
6
+ import { useSettings } from "@/context/settings"
7
+
8
+ export type Highlight = {
9
+ title: string
10
+ description: string
11
+ media?: {
12
+ type: "image" | "video"
13
+ src: string
14
+ alt?: string
15
+ }
16
+ }
17
+
18
+ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
19
+ const dialog = useDialog()
20
+ const language = useLanguage()
21
+ const settings = useSettings()
22
+ const [index, setIndex] = createSignal(0)
23
+
24
+ const total = () => props.highlights.length
25
+ const last = () => Math.max(0, total() - 1)
26
+ const feature = () => props.highlights[index()] ?? props.highlights[last()]
27
+ const isFirst = () => index() === 0
28
+ const isLast = () => index() >= last()
29
+ const paged = () => total() > 1
30
+
31
+ function handleNext() {
32
+ if (isLast()) return
33
+ setIndex(index() + 1)
34
+ }
35
+
36
+ function handleClose() {
37
+ dialog.close()
38
+ }
39
+
40
+ function handleDisable() {
41
+ settings.general.setReleaseNotes(false)
42
+ handleClose()
43
+ }
44
+
45
+ function handleKeyDown(e: KeyboardEvent) {
46
+ if (e.key === "Escape") {
47
+ e.preventDefault()
48
+ handleClose()
49
+ return
50
+ }
51
+
52
+ if (!paged()) return
53
+ if (e.key === "ArrowLeft" && !isFirst()) {
54
+ e.preventDefault()
55
+ setIndex(index() - 1)
56
+ }
57
+ if (e.key === "ArrowRight" && !isLast()) {
58
+ e.preventDefault()
59
+ setIndex(index() + 1)
60
+ }
61
+ }
62
+
63
+ return (
64
+ <Dialog
65
+ size="large"
66
+ fit
67
+ class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
68
+ >
69
+ <div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
70
+ {/* Left side - Text content */}
71
+ <div class="flex flex-col flex-1 min-w-0 p-8">
72
+ {/* Top section - feature content (fixed position from top) */}
73
+ <div class="flex flex-col gap-2 pt-22">
74
+ <div class="flex items-center gap-2">
75
+ <h1 class="text-16-medium text-text-strong">{feature()?.title ?? ""}</h1>
76
+ </div>
77
+ <p class="text-14-regular text-text-base">{feature()?.description ?? ""}</p>
78
+ </div>
79
+
80
+ {/* Spacer to push buttons to bottom */}
81
+ <div class="flex-1" />
82
+
83
+ {/* Bottom section - buttons and indicators (fixed position) */}
84
+ <div class="flex flex-col gap-12">
85
+ <div class="flex flex-col items-start gap-3">
86
+ {isLast() ? (
87
+ <Button variant="primary" size="large" onClick={handleClose}>
88
+ {language.t("dialog.releaseNotes.action.getStarted")}
89
+ </Button>
90
+ ) : (
91
+ <Button variant="secondary" size="large" onClick={handleNext}>
92
+ {language.t("dialog.releaseNotes.action.next")}
93
+ </Button>
94
+ )}
95
+
96
+ <Button variant="ghost" size="small" onClick={handleDisable}>
97
+ {language.t("dialog.releaseNotes.action.hideFuture")}
98
+ </Button>
99
+ </div>
100
+
101
+ {paged() && (
102
+ <div class="flex items-center gap-1.5 -my-2.5">
103
+ {props.highlights.map((_, i) => (
104
+ <button
105
+ type="button"
106
+ class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
107
+ classList={{
108
+ "w-8": i === index(),
109
+ "w-3": i !== index(),
110
+ }}
111
+ onClick={() => setIndex(i)}
112
+ >
113
+ <div
114
+ class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
115
+ classList={{
116
+ "bg-icon-strong-base": i === index(),
117
+ "bg-icon-weak-base": i !== index(),
118
+ }}
119
+ />
120
+ </button>
121
+ ))}
122
+ </div>
123
+ )}
124
+ </div>
125
+ </div>
126
+
127
+ {/* Right side - Media content (edge to edge) */}
128
+ {feature()?.media && (
129
+ <div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
130
+ {feature()!.media!.type === "image" ? (
131
+ <img
132
+ src={feature()!.media!.src}
133
+ alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
134
+ class="w-full h-full object-cover"
135
+ />
136
+ ) : (
137
+ <video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
138
+ )}
139
+ </div>
140
+ )}
141
+ </div>
142
+ </Dialog>
143
+ )
144
+ }
packages/app/src/components/dialog-select-directory-v2.css ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .directory-picker-v2-body {
2
+ display: flex;
3
+ min-height: 0;
4
+ flex: 1;
5
+ flex-direction: column;
6
+ gap: 12px;
7
+ padding: 2px 16px 0;
8
+ }
9
+
10
+ .directory-picker-v2-path {
11
+ position: relative;
12
+ z-index: 10;
13
+ display: flex;
14
+ gap: 8px;
15
+ }
16
+
17
+ .directory-picker-v2-actions {
18
+ display: flex;
19
+ flex-shrink: 0;
20
+ gap: 2px;
21
+ }
22
+
23
+ .directory-picker-v2-suggestions {
24
+ position: absolute;
25
+ z-index: 20;
26
+ top: 36px;
27
+ right: 0;
28
+ left: 0;
29
+ display: flex;
30
+ flex-direction: column;
31
+ padding: 4px;
32
+ border: 1px solid var(--v2-border-border-base);
33
+ border-radius: 6px;
34
+ background: var(--v2-background-bg-layer-02);
35
+ box-shadow: var(--v2-elevation-overlay);
36
+ }
37
+
38
+ .directory-picker-v2-suggestions button {
39
+ overflow: hidden;
40
+ padding: 6px 8px;
41
+ border-radius: 4px;
42
+ color: var(--v2-text-text-muted);
43
+ font-size: 12px;
44
+ text-align: left;
45
+ text-overflow: ellipsis;
46
+ white-space: nowrap;
47
+ }
48
+
49
+ .directory-picker-v2-suggestions button:hover,
50
+ .directory-picker-v2-suggestions button[data-active] {
51
+ color: var(--v2-text-text-base);
52
+ background: var(--v2-overlay-simple-overlay-hover);
53
+ }
54
+
55
+ .directory-picker-v2-browser {
56
+ position: relative;
57
+ z-index: 0;
58
+ isolation: isolate;
59
+ min-height: 0;
60
+ flex: 1;
61
+ overflow: auto;
62
+ border: 1px solid var(--v2-border-border-base);
63
+ border-radius: 6px;
64
+ background: transparent;
65
+ }
66
+
67
+ .directory-picker-v2-tree {
68
+ display: block;
69
+ width: 100%;
70
+ height: 100%;
71
+ --trees-bg-override: transparent;
72
+ --trees-fg-override: var(--v2-text-text-base);
73
+ --trees-fg-muted-override: var(--v2-text-text-muted);
74
+ --trees-bg-muted-override: transparent;
75
+ --trees-selected-bg-override: transparent;
76
+ --trees-selected-fg-override: var(--v2-text-text-base);
77
+ --trees-selected-focused-border-color-override: transparent;
78
+ --trees-focus-ring-color-override: transparent;
79
+ --trees-focus-ring-width-override: 0px;
80
+ --trees-focus-ring-offset-override: 0px;
81
+ --trees-border-color-override: var(--v2-border-border-base);
82
+ --trees-font-family-override: var(--font-family-sans);
83
+ --trees-font-size-override: 12px;
84
+ --trees-item-height: 24px;
85
+ --trees-border-radius-override: 4px;
86
+ }
87
+
88
+ .directory-picker-v2-state {
89
+ position: absolute;
90
+ z-index: 1;
91
+ inset: 0;
92
+ display: grid;
93
+ place-items: center;
94
+ color: var(--v2-text-text-muted);
95
+ font-size: 12px;
96
+ pointer-events: none;
97
+ }
98
+
99
+ .directory-picker-v2-selection {
100
+ overflow: hidden;
101
+ flex-shrink: 0;
102
+ color: var(--v2-text-text-muted);
103
+ font-size: 12px;
104
+ line-height: 16px;
105
+ text-overflow: ellipsis;
106
+ white-space: nowrap;
107
+ }
packages/app/src/components/dialog-select-directory-v2.tsx ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "@pierre/trees/web-components"
2
+ import { FileTree } from "@pierre/trees"
3
+ import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
4
+ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
5
+ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
6
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
7
+ import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
8
+ import { useGlobal } from "@/context/global"
9
+ import { useLanguage } from "@/context/language"
10
+ import { ServerConnection } from "@/context/server"
11
+ import type { Path } from "@opencode-ai/sdk/v2/client"
12
+ import {
13
+ absoluteTreePath,
14
+ activeTreeNavigation,
15
+ advanceTreePreload,
16
+ nextSuggestionIndex,
17
+ nextTreeScrollTop,
18
+ pickerFileSearchQuery,
19
+ pickerAbsoluteInput,
20
+ pickerMode,
21
+ preloadTreeDirectories,
22
+ cleanPickerInput,
23
+ createPriorityTaskQueue,
24
+ createDirectorySearch,
25
+ currentPickerSuggestions,
26
+ displayPickerPath,
27
+ pickerParent,
28
+ pickerRoot,
29
+ } from "./directory-picker-domain"
30
+ import "./dialog-select-directory-v2.css"
31
+ import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
32
+ import { getFilename } from "@opencode-ai/core/util/path"
33
+
34
+ interface DialogSelectDirectoryV2Props {
35
+ title?: string
36
+ multiple?: boolean
37
+ onSelect: (result: string | string[] | null) => void
38
+ server: ServerConnection.Any
39
+ mode?: "directory" | "file"
40
+ start?: string
41
+ }
42
+
43
+ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
44
+ const global = useGlobal()
45
+ const { sync, sdk } = global.ensureServerCtx(props.server)
46
+ const dialog = useDialog()
47
+ const language = useLanguage()
48
+ const policy = pickerMode(props.mode ?? "directory", props.start)
49
+ const action = {
50
+ file: language.t("dialog.directory.action.selectFile"),
51
+ directory: language.t("dialog.directory.action.selectFolder"),
52
+ }
53
+ const [root, setRoot] = createSignal("")
54
+ const [input, setInput] = createSignal("")
55
+ const [selected, setSelected] = createSignal("")
56
+ const [suggestionsOpen, setSuggestionsOpen] = createSignal(false)
57
+ const [activeSuggestion, setActiveSuggestion] = createSignal(-1)
58
+ const [loading, setLoading] = createSignal(false)
59
+ const [error, setError] = createSignal(false)
60
+ const [rootValid, setRootValid] = createSignal(false)
61
+ const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
62
+ const loads = createPriorityTaskQueue<Array<{ name: string; type: "file" | "directory" }> | undefined>(3)
63
+ const advanced = new Set<string>()
64
+ let tree: FileTree | undefined
65
+ let container: HTMLDivElement | undefined
66
+ let pathArea: HTMLDivElement | undefined
67
+ let navigation = 0
68
+
69
+ const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
70
+ const [fallbackPath] = createResource(
71
+ () => (missingBase() ? true : undefined),
72
+ async (): Promise<Path | undefined> => {
73
+ if ((await sdk.protocol) !== "v1") return
74
+ return sdk.client.path
75
+ .get()
76
+ .then((result) => result.data)
77
+ .catch(() => undefined)
78
+ },
79
+ { initialValue: undefined },
80
+ )
81
+ const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
82
+ const start = createMemo(
83
+ () =>
84
+ props.start ||
85
+ sync.data.path.home ||
86
+ sync.data.path.directory ||
87
+ fallbackPath()?.home ||
88
+ fallbackPath()?.directory,
89
+ )
90
+ const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
91
+ const [suggestions] = createResource(input, async (value) => {
92
+ const cleaned = cleanPickerInput(value)
93
+ const typed = cleaned.replace(/\/+$/, "")
94
+ const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
95
+ if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
96
+ const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
97
+ if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
98
+ const base = pickerRoot(cleaned) || root() || start()
99
+ if (!base) return { query: value, items: directories.slice(0, 5) }
100
+ const files = await sdk.api.file
101
+ .find({
102
+ location: { directory: base },
103
+ query: pickerFileSearchQuery(base, value, home()),
104
+ type: "file",
105
+ limit: 20,
106
+ })
107
+ .then((result) => result.data)
108
+ .catch(() => [])
109
+ const results = [
110
+ ...directories,
111
+ ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
112
+ ]
113
+ return {
114
+ query: value,
115
+ items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
116
+ }
117
+ })
118
+ const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
119
+
120
+ async function load(path: string, generation: number, eager = false) {
121
+ const key = path.replace(/\/+$/, "")
122
+ setError(false)
123
+ const absolute = absoluteTreePath(root(), key)
124
+ const existing = listings.get(key)
125
+ if (existing && !eager) loads.promote(`${generation}:${key}`)
126
+ const request =
127
+ existing ??
128
+ loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
129
+ if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
130
+ return sdk.api.file
131
+ .list({ location: { directory: absolute } })
132
+ .then((result) =>
133
+ result.data.map((entry) => ({
134
+ name: getFilename(entry.path.replace(/[\\/]+$/, "")),
135
+ type: entry.type,
136
+ })),
137
+ )
138
+ .catch(() => undefined)
139
+ })
140
+ listings.set(key, request)
141
+ const nodes = await request
142
+ if (!activeTreeNavigation(generation, navigation)) return false
143
+ if (!nodes) {
144
+ listings.delete(key)
145
+ if (!key) setError(true)
146
+ return false
147
+ }
148
+ tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
149
+ if (!eager && advanceTreePreload(advanced, key)) {
150
+ for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
151
+ }
152
+ return true
153
+ }
154
+
155
+ async function navigate(path: string) {
156
+ const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
157
+ if (!value) return
158
+ const token = ++navigation
159
+ setLoading(true)
160
+ setRootValid(false)
161
+ setSelected("")
162
+ setSuggestionsOpen(false)
163
+ setActiveSuggestion(-1)
164
+ setRoot(value)
165
+ setInput(displayPickerPath(value, value, home()))
166
+ listings.clear()
167
+ advanced.clear()
168
+ tree?.resetPaths([])
169
+ const valid = await load("", token)
170
+ if (!activeTreeNavigation(token, navigation)) return
171
+ setRootValid(valid)
172
+ setLoading(false)
173
+ }
174
+
175
+ function complete() {
176
+ const items = currentSuggestions()
177
+ const match = items[activeSuggestion()] ?? items[0]
178
+ if (!match) return
179
+ const value = displayPickerPath(match.absolute, input(), home())
180
+ setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
181
+ if (match.type === "file") {
182
+ setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
183
+ setSuggestionsOpen(false)
184
+ setActiveSuggestion(-1)
185
+ }
186
+ }
187
+
188
+ function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
189
+ if (suggestion.type === "directory") {
190
+ void navigate(suggestion.absolute)
191
+ return
192
+ }
193
+ setInput(displayPickerPath(suggestion.absolute, input(), home()))
194
+ setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
195
+ setSuggestionsOpen(false)
196
+ setActiveSuggestion(-1)
197
+ }
198
+
199
+ function moveSuggestion(delta: -1 | 1) {
200
+ setSuggestionsOpen(true)
201
+ setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length))
202
+ }
203
+
204
+ function activeSuggestionValue() {
205
+ const items = currentSuggestions()
206
+ return items[activeSuggestion()] ?? items[0]
207
+ }
208
+
209
+ const keyActions: Partial<Record<string, () => void>> = {
210
+ ArrowDown: () => moveSuggestion(1),
211
+ ArrowUp: () => moveSuggestion(-1),
212
+ Enter: () => {
213
+ const suggestion = activeSuggestionValue()
214
+ if (suggestion) chooseSuggestion(suggestion)
215
+ if (!suggestion) void navigate(input())
216
+ },
217
+ Tab: complete,
218
+ }
219
+
220
+ function handleInputKey(event: KeyboardEvent) {
221
+ const action = keyActions[event.key]
222
+ if (!action) return
223
+ if (event.key === "Tab" && event.shiftKey) return
224
+ event.preventDefault()
225
+ action()
226
+ }
227
+
228
+ function resolve() {
229
+ const path = policy.result(root(), selected(), rootValid())
230
+ if (!path) return
231
+ props.onSelect(props.multiple ? [path] : path)
232
+ dialog.close()
233
+ }
234
+
235
+ onMount(() => {
236
+ const closeSuggestions = (event: PointerEvent) => {
237
+ if (pathArea?.contains(event.target as Node)) return
238
+ setSuggestionsOpen(false)
239
+ setActiveSuggestion(-1)
240
+ }
241
+ document.addEventListener("pointerdown", closeSuggestions)
242
+ onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
243
+ tree = new FileTree({
244
+ paths: [],
245
+ flattenEmptyDirectories: false,
246
+ initialExpansion: "closed",
247
+ stickyFolders: true,
248
+ unsafeCSS: `
249
+ button[data-type="item"] {
250
+ background: transparent !important;
251
+ box-shadow: none !important;
252
+ }
253
+ button[data-type="item"]:hover {
254
+ background: var(--v2-overlay-simple-overlay-hover) !important;
255
+ }
256
+ button[data-type="item"]:focus-visible {
257
+ outline: none !important;
258
+ box-shadow: none !important;
259
+ }
260
+ [data-file-tree-virtualized-scroll] {
261
+ overscroll-behavior: contain;
262
+ scrollbar-width: thin;
263
+ }
264
+ `,
265
+ onExpansionChange(change) {
266
+ if (change.expanded) void load(change.path, navigation)
267
+ },
268
+ onSelectionChange(paths) {
269
+ const path = paths.at(-1)
270
+ setSelected(path ? (policy.selection(root(), path) ?? "") : "")
271
+ },
272
+ })
273
+ if (!container) return
274
+ tree.render({ containerWrapper: container })
275
+ tree.getFileTreeContainer()?.classList.add("directory-picker-v2-tree")
276
+ })
277
+
278
+ createEffect(() => {
279
+ const path = start()
280
+ if (!path || root()) return
281
+ void navigate(path)
282
+ })
283
+
284
+ onCleanup(() => tree?.cleanUp())
285
+
286
+ return (
287
+ <Dialog size="large" class="directory-picker-v2">
288
+ <DialogHeader>
289
+ <DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
290
+ </DialogHeader>
291
+ <DividerV2 />
292
+ <DialogBody class="directory-picker-v2-body pt-4!">
293
+ <div class="directory-picker-v2-path" ref={pathArea}>
294
+ <TextInputV2
295
+ value={input()}
296
+ autofocus
297
+ autocomplete="off"
298
+ spellcheck={false}
299
+ class="!w-full"
300
+ onInput={(event) => {
301
+ setInput(cleanPickerInput(event.currentTarget.value))
302
+ setSelected("")
303
+ setSuggestionsOpen(true)
304
+ setActiveSuggestion(-1)
305
+ }}
306
+ role="combobox"
307
+ aria-autocomplete="list"
308
+ aria-expanded={suggestionsOpen()}
309
+ aria-controls="directory-picker-v2-suggestions"
310
+ aria-activedescendant={
311
+ activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined
312
+ }
313
+ onKeyDown={handleInputKey}
314
+ />
315
+ <div class="directory-picker-v2-actions">
316
+ <ButtonV2 size="small" variant="ghost" onClick={() => void navigate(home())}>
317
+ ~
318
+ </ButtonV2>
319
+ <ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>
320
+ {language.t("dialog.directory.root")}
321
+ </ButtonV2>
322
+ <ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>
323
+ {language.t("dialog.directory.parent")}
324
+ </ButtonV2>
325
+ </div>
326
+ <Show when={suggestionsOpen() && currentSuggestions().length > 0}>
327
+ <div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions">
328
+ <For each={currentSuggestions()}>
329
+ {(suggestion, index) => (
330
+ <button
331
+ id={`directory-picker-v2-suggestion-${index()}`}
332
+ data-directory-path={suggestion.absolute}
333
+ role="option"
334
+ aria-selected={index() === activeSuggestion()}
335
+ data-active={index() === activeSuggestion() ? "" : undefined}
336
+ onPointerMove={() => setActiveSuggestion(index())}
337
+ onClick={() => chooseSuggestion(suggestion)}
338
+ >
339
+ {displayPickerPath(suggestion.absolute, input(), home())}
340
+ {suggestion.type === "directory" ? "/" : ""}
341
+ </button>
342
+ )}
343
+ </For>
344
+ </div>
345
+ </Show>
346
+ </div>
347
+ <div
348
+ class="directory-picker-v2-browser"
349
+ ref={container}
350
+ onWheel={(event) => {
351
+ const scroller = tree
352
+ ?.getFileTreeContainer()
353
+ ?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
354
+ if (!scroller) return
355
+ const next = nextTreeScrollTop(
356
+ scroller.scrollTop,
357
+ event.deltaY,
358
+ scroller.scrollHeight,
359
+ scroller.clientHeight,
360
+ )
361
+ if (next === scroller.scrollTop) return
362
+ event.preventDefault()
363
+ scroller.scrollTop = next
364
+ scroller.dispatchEvent(new Event("scroll"))
365
+ }}
366
+ >
367
+ <Show when={loading()}>
368
+ <div class="directory-picker-v2-state">{language.t("common.loading")}</div>
369
+ </Show>
370
+ <Show when={!loading() && error()}>
371
+ <div class="directory-picker-v2-state">{language.t("dialog.directory.readError")}</div>
372
+ </Show>
373
+ </div>
374
+ <div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
375
+ </DialogBody>
376
+ <DialogFooter>
377
+ <ButtonV2 variant="neutral" onClick={() => dialog.close()}>
378
+ {language.t("common.cancel")}
379
+ </ButtonV2>
380
+ <ButtonV2 variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
381
+ {action[policy.action]}
382
+ </ButtonV2>
383
+ </DialogFooter>
384
+ </Dialog>
385
+ )
386
+ }
packages/app/src/components/dialog-select-directory.tsx ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
2
+ import { Dialog } from "@opencode-ai/ui/dialog"
3
+ import { FileIcon } from "@opencode-ai/ui/file-icon"
4
+ import { List } from "@opencode-ai/ui/list"
5
+ import type { ListRef } from "@opencode-ai/ui/list"
6
+ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
7
+ import { createMemo, createResource, createSignal } from "solid-js"
8
+ import { useLanguage } from "@/context/language"
9
+ import { ServerConnection } from "@/context/server"
10
+ import { useGlobal } from "@/context/global"
11
+ import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
12
+ import type { Path } from "@opencode-ai/sdk/v2/client"
13
+
14
+ interface DialogSelectDirectoryProps {
15
+ title?: string
16
+ multiple?: boolean
17
+ onSelect: (result: string | string[] | null) => void
18
+ server: ServerConnection.Any
19
+ }
20
+
21
+ const RECENT_PROJECT_LIMIT = 5
22
+
23
+ type Row = {
24
+ absolute: string
25
+ search: string
26
+ group: "recent" | "folders"
27
+ }
28
+
29
+ function toRow(absolute: string, home: string, group: Row["group"]): Row {
30
+ const full = displayPickerPath(absolute, "", "")
31
+ const tilde = displayPickerPath(full, "~", home)
32
+ const withSlash = (value: string) => {
33
+ if (!value) return ""
34
+ if (value.endsWith("/")) return value
35
+ return value + "/"
36
+ }
37
+
38
+ const search = Array.from(
39
+ new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
40
+ ).join("\n")
41
+ return { absolute: full, search, group }
42
+ }
43
+
44
+ function uniqueRows(rows: Row[]) {
45
+ const seen = new Set<string>()
46
+ return rows.filter((row) => {
47
+ if (seen.has(row.absolute)) return false
48
+ seen.add(row.absolute)
49
+ return true
50
+ })
51
+ }
52
+
53
+ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
54
+ const global = useGlobal()
55
+ const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
56
+ const dialog = useDialog()
57
+ const language = useLanguage()
58
+
59
+ const [filter, setFilter] = createSignal("")
60
+ let list: ListRef | undefined
61
+
62
+ const missingHome = createMemo(() => !sync.data.path.home)
63
+ const [fallbackPath] = createResource(
64
+ () => (missingHome() ? true : undefined),
65
+ async (): Promise<Path | undefined> => {
66
+ if ((await sdk.protocol) !== "v1") return
67
+ return sdk.client.path
68
+ .get()
69
+ .then((result) => result.data)
70
+ .catch(() => undefined)
71
+ },
72
+ { initialValue: undefined },
73
+ )
74
+
75
+ const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
76
+ const start = createMemo(
77
+ () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
78
+ )
79
+
80
+ const directories = createDirectorySearch({
81
+ sdk,
82
+ home,
83
+ base: start,
84
+ })
85
+
86
+ const recentProjects = createMemo(() => {
87
+ const projects = serverCtx.projects.list()
88
+ const byProject = new Map<string, number>()
89
+
90
+ for (const project of projects) {
91
+ let at = 0
92
+ const dirs = [project.worktree, ...(project.sandboxes ?? [])]
93
+ for (const directory of dirs) {
94
+ const sessions = sync.child(directory, { bootstrap: false })[0].session
95
+ for (const session of sessions) {
96
+ if (session.time.archived) continue
97
+ const updated = session.time.updated ?? session.time.created
98
+ if (updated > at) at = updated
99
+ }
100
+ }
101
+ byProject.set(project.worktree, at)
102
+ }
103
+
104
+ return projects
105
+ .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
106
+ .sort((a, b) => b.at - a.at || a.index - b.index)
107
+ .map(({ project }) => {
108
+ const row = toRow(project.worktree, home(), "recent")
109
+ const name = project.name || getFilename(project.worktree)
110
+ return {
111
+ ...row,
112
+ search: `${row.search}\n${name}`,
113
+ }
114
+ })
115
+ })
116
+
117
+ const items = async (value: string) => {
118
+ const results = await directories(value)
119
+ const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
120
+ // Cap the idle list only. Once a query narrows the results, every project stays searchable.
121
+ const recent = recentProjects()
122
+ const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
123
+ return uniqueRows([...visible, ...directoryRows])
124
+ }
125
+
126
+ function resolve(absolute: string) {
127
+ props.onSelect(props.multiple ? [absolute] : absolute)
128
+ dialog.close()
129
+ }
130
+
131
+ return (
132
+ <Dialog title={props.title ?? language.t("command.project.open")}>
133
+ <List
134
+ class="px-3"
135
+ search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
136
+ emptyMessage={language.t("dialog.directory.empty")}
137
+ loadingMessage={language.t("common.loading")}
138
+ items={items}
139
+ key={(x) => x.absolute}
140
+ filterKeys={["search"]}
141
+ groupBy={(item) => item.group}
142
+ sortGroupsBy={(a, b) => {
143
+ if (a.category === b.category) return 0
144
+ return a.category === "recent" ? -1 : 1
145
+ }}
146
+ groupHeader={(group) =>
147
+ group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
148
+ }
149
+ ref={(r) => (list = r)}
150
+ onFilter={(value) => setFilter(cleanPickerInput(value))}
151
+ onKeyEvent={(e, item) => {
152
+ if (e.key !== "Tab") return
153
+ if (e.shiftKey) return
154
+ if (!item) return
155
+
156
+ e.preventDefault()
157
+ e.stopPropagation()
158
+
159
+ const value = displayPickerPath(item.absolute, filter(), home())
160
+ list?.setFilter(value.endsWith("/") ? value : value + "/")
161
+ }}
162
+ onSelect={(path) => {
163
+ if (!path) return
164
+ resolve(path.absolute)
165
+ }}
166
+ >
167
+ {(item) => {
168
+ const path = displayPickerPath(item.absolute, filter(), home())
169
+ if (path === "~") {
170
+ return (
171
+ <div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
172
+ <div class="flex items-center gap-x-3 grow min-w-0">
173
+ <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
174
+ <div class="flex items-center text-14-regular min-w-0">
175
+ <span class="text-text-strong whitespace-nowrap">~</span>
176
+ <span class="text-text-weak whitespace-nowrap">/</span>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ )
181
+ }
182
+ return (
183
+ <div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
184
+ <div class="flex items-center gap-x-3 grow min-w-0">
185
+ <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
186
+ <div class="flex items-center text-14-regular min-w-0">
187
+ <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
188
+ {getDirectory(path)}
189
+ </span>
190
+ <span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
191
+ <span class="text-text-weak whitespace-nowrap">/</span>
192
+ </div>
193
+ </div>
194
+ </div>
195
+ )
196
+ }}
197
+ </List>
198
+ </Dialog>
199
+ )
200
+ }
packages/app/src/components/dialog-select-file.tsx ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Dialog } from "@opencode-ai/ui/dialog"
2
+ import { FileIcon } from "@opencode-ai/ui/file-icon"
3
+ import { Icon } from "@opencode-ai/ui/icon"
4
+ import { Keybind } from "@opencode-ai/ui/keybind"
5
+ import { List } from "@opencode-ai/ui/list"
6
+ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
7
+ import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
8
+ import { formatKeybind } from "@/context/command"
9
+ import { useServerSDK } from "@/context/server-sdk"
10
+ import { useLanguage } from "@/context/language"
11
+ import { usePlatform } from "@/context/platform"
12
+ import { useSettings } from "@/context/settings"
13
+ import { useSessionLayout } from "@/pages/session/session-layout"
14
+ import { decode64 } from "@/utils/base64"
15
+ import { getRelativeTime } from "@/utils/time"
16
+ import {
17
+ createCommandPaletteFileEntry,
18
+ createCommandPaletteFileOpener,
19
+ createCommandPaletteModel,
20
+ uniqueCommandPaletteEntries,
21
+ type CommandPaletteEntry,
22
+ } from "./command-palette"
23
+ import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
24
+
25
+ const DialogSelectFileV2 = lazy(() =>
26
+ import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
27
+ )
28
+ type DialogSelectFileMode = "all" | "files"
29
+
30
+ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
31
+ const platform = usePlatform()
32
+ const settings = useSettings()
33
+ const filesOnly = () => props.mode === "files"
34
+
35
+ if (!filesOnly() && settings.general.newLayoutDesigns()) {
36
+ return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
37
+ }
38
+
39
+ if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
40
+ return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
41
+ }
42
+
43
+ return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
44
+ }
45
+
46
+ function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
47
+ const language = useLanguage()
48
+ const serverSDK = useServerSDK()
49
+ const { params } = useSessionLayout()
50
+ const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
51
+ const openFile = createCommandPaletteFileOpener(props.onOpenFile)
52
+
53
+ return (
54
+ <DialogSelectFileV2
55
+ server={serverSDK().server}
56
+ mode="file"
57
+ start={projectDirectory()}
58
+ title={language.t("session.header.searchFiles")}
59
+ onSelect={(result) => {
60
+ if (typeof result !== "string") return
61
+ openFile(result)
62
+ }}
63
+ />
64
+ )
65
+ }
66
+
67
+ function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
68
+ const palette = createCommandPaletteModel(props)
69
+ const [grouped, setGrouped] = createSignal(false)
70
+
71
+ const items = async (text: string) => {
72
+ const query = text.trim()
73
+ setGrouped(query.length > 0)
74
+
75
+ if (!query && props.filesOnly()) {
76
+ const loaded = palette.file.tree.state("")?.loaded
77
+ const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
78
+ const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
79
+
80
+ if (loaded || next.length > 0) {
81
+ void pending
82
+ return next
83
+ }
84
+
85
+ await pending
86
+ return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
87
+ }
88
+
89
+ if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
90
+
91
+ if (props.filesOnly()) {
92
+ const files = await palette.file.searchFiles(query)
93
+ const category = palette.language.t("palette.group.files")
94
+ return files.map((path) => createCommandPaletteFileEntry(path, category))
95
+ }
96
+
97
+ const [files, nextSessions] = await Promise.all([
98
+ palette.file.searchFiles(query),
99
+ Promise.resolve(palette.sessions(query)),
100
+ ])
101
+ const category = palette.language.t("palette.group.files")
102
+ const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
103
+ return [...palette.commandEntries(), ...nextSessions, ...entries]
104
+ }
105
+
106
+ return (
107
+ <Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
108
+ <List
109
+ class="px-3"
110
+ search={{
111
+ placeholder: props.filesOnly()
112
+ ? palette.language.t("session.header.searchFiles")
113
+ : palette.language.t("palette.search.placeholder"),
114
+ autofocus: true,
115
+ hideIcon: true,
116
+ }}
117
+ emptyMessage={palette.language.t("palette.empty")}
118
+ loadingMessage={palette.language.t("common.loading")}
119
+ items={items}
120
+ key={(item) => item.id}
121
+ filterKeys={["title", "description", "category"]}
122
+ skipFilter={(item) => item.type === "file"}
123
+ groupBy={grouped() ? (item) => item.category : () => ""}
124
+ onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
125
+ onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
126
+ >
127
+ {(item) => (
128
+ <Switch
129
+ fallback={
130
+ <div class="w-full flex items-center justify-between rounded-md pl-1">
131
+ <div class="flex items-center gap-x-3 grow min-w-0">
132
+ <FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
133
+ <div class="flex items-center text-14-regular">
134
+ <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
135
+ {getDirectory(item.path ?? "")}
136
+ </span>
137
+ <span class="text-text-strong whitespace-nowrap">{getFilename(item.path ?? "")}</span>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ }
142
+ >
143
+ <Match when={item.type === "command"}>
144
+ <div class="w-full flex items-center justify-between gap-4">
145
+ <div class="flex items-center gap-2 min-w-0">
146
+ <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
147
+ <Show when={item.description}>
148
+ <span class="text-14-regular text-text-weak truncate">{item.description}</span>
149
+ </Show>
150
+ </div>
151
+ <Show when={item.keybind}>
152
+ <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
153
+ </Show>
154
+ </div>
155
+ </Match>
156
+ <Match when={item.type === "session"}>
157
+ <div class="w-full flex items-center justify-between rounded-md pl-1">
158
+ <div class="flex items-center gap-x-3 grow min-w-0">
159
+ <Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
160
+ <div class="flex items-center gap-2 min-w-0">
161
+ <span
162
+ class="text-14-regular text-text-strong truncate"
163
+ classList={{ "opacity-70": !!item.archived }}
164
+ >
165
+ {item.title}
166
+ </span>
167
+ <Show when={item.description}>
168
+ <span
169
+ class="text-14-regular text-text-weak truncate"
170
+ classList={{ "opacity-70": !!item.archived }}
171
+ >
172
+ {item.description}
173
+ </span>
174
+ </Show>
175
+ </div>
176
+ </div>
177
+ <Show when={item.updated}>
178
+ <span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
179
+ {getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
180
+ </span>
181
+ </Show>
182
+ </div>
183
+ </Match>
184
+ </Switch>
185
+ )}
186
+ </List>
187
+ </Dialog>
188
+ )
189
+ }
packages/app/src/components/dialog-select-mcp.tsx ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Component, createMemo, Show } from "solid-js"
2
+ import { useSync } from "@/context/sync"
3
+ import { Dialog } from "@opencode-ai/ui/dialog"
4
+ import { List } from "@opencode-ai/ui/list"
5
+ import { Switch } from "@opencode-ai/ui/switch"
6
+ import { useLanguage } from "@/context/language"
7
+ import { useMcpToggle } from "@/context/mcp"
8
+
9
+ const statusLabels = {
10
+ connected: "mcp.status.connected",
11
+ failed: "mcp.status.failed",
12
+ needs_auth: "mcp.status.needs_auth",
13
+ needs_client_registration: "mcp.status.needs_client_registration",
14
+ disabled: "mcp.status.disabled",
15
+ } as const
16
+
17
+ export const DialogSelectMcp: Component = () => {
18
+ const sync = useSync()
19
+ const language = useLanguage()
20
+
21
+ const items = createMemo(() =>
22
+ Object.entries(sync().data.mcp ?? {})
23
+ .map(([name, status]) => ({ name, status: status.status }))
24
+ .sort((a, b) => a.name.localeCompare(b.name)),
25
+ )
26
+
27
+ const toggle = useMcpToggle()
28
+
29
+ const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
30
+ const totalCount = createMemo(() => items().length)
31
+
32
+ return (
33
+ <Dialog
34
+ title={language.t("dialog.mcp.title")}
35
+ description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
36
+ >
37
+ <List
38
+ class="px-3"
39
+ search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
40
+ emptyMessage={language.t("dialog.mcp.empty")}
41
+ key={(x) => x?.name ?? ""}
42
+ items={items}
43
+ filterKeys={["name", "status"]}
44
+ sortBy={(a, b) => a.name.localeCompare(b.name)}
45
+ onSelect={(x) => {
46
+ if (!x || x.status === "pending" || toggle.isPending) return
47
+ toggle.mutate(x.name)
48
+ }}
49
+ >
50
+ {(i) => {
51
+ const mcpStatus = () => sync().data.mcp[i.name]
52
+ const status = () => mcpStatus()?.status
53
+ const statusLabel = () => {
54
+ const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
55
+ if (!key) return
56
+ return language.t(key)
57
+ }
58
+ const error = () => {
59
+ const s = mcpStatus()
60
+ if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
61
+ }
62
+ const enabled = () => status() === "connected"
63
+ return (
64
+ <div class="w-full flex items-center justify-between gap-x-3">
65
+ <div class="flex flex-col gap-0.5 min-w-0">
66
+ <div class="flex items-center gap-2">
67
+ <span class="truncate">{i.name}</span>
68
+ <Show when={statusLabel()}>
69
+ <span class="text-11-regular text-text-weaker">{statusLabel()}</span>
70
+ </Show>
71
+ </div>
72
+ <Show when={error()}>
73
+ <span class="text-11-regular text-text-weaker truncate">{error()}</span>
74
+ </Show>
75
+ </div>
76
+ <div onClick={(e) => e.stopPropagation()}>
77
+ <Switch
78
+ checked={enabled()}
79
+ disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
80
+ onChange={() => {
81
+ if (toggle.isPending) return
82
+ toggle.mutate(i.name)
83
+ }}
84
+ />
85
+ </div>
86
+ </div>
87
+ )
88
+ }}
89
+ </List>
90
+ </Dialog>
91
+ )
92
+ }
packages/app/src/components/dialog-select-model-search.test.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, test } from "bun:test"
2
+ import { matchesModelSearch } from "./dialog-select-model-search"
3
+
4
+ describe("matchesModelSearch", () => {
5
+ test("matches model names across separators", () => {
6
+ expect(matchesModelSearch("gpt 5", ["GPT-5.5"])).toBe(true)
7
+ expect(matchesModelSearch("gpt-5", ["GPT-5.5"])).toBe(true)
8
+ expect(matchesModelSearch("gpt5", ["GPT-5.5"])).toBe(true)
9
+ })
10
+
11
+ test("matches any searchable model field", () => {
12
+ expect(matchesModelSearch("open ai", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
13
+ expect(matchesModelSearch("gpt 5", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
14
+ })
15
+
16
+ test("does not match unrelated searches", () => {
17
+ expect(matchesModelSearch("claude", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(false)
18
+ })
19
+ })
packages/app/src/components/dialog-select-model-search.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const normalizeModelSearch = (value: string) =>
2
+ value
3
+ .toLowerCase()
4
+ .replace(/[^\p{Letter}\p{Number}]+/gu, " ")
5
+ .trim()
6
+ .replace(/\s+/g, " ")
7
+
8
+ export const compactModelSearch = (value: string) => normalizeModelSearch(value).replaceAll(" ", "")
9
+
10
+ export const matchesModelSearch = (query: string, values: string[]) => {
11
+ const search = normalizeModelSearch(query)
12
+ if (!search) return true
13
+
14
+ const compactSearch = compactModelSearch(query)
15
+ return values.some(
16
+ (value) => normalizeModelSearch(value).includes(search) || compactModelSearch(value).includes(compactSearch),
17
+ )
18
+ }
packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-nocheck
2
+ import { Button } from "@opencode-ai/ui/button"
3
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
4
+ import { createSignal, onMount } from "solid-js"
5
+ import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
6
+
7
+ const names = [
8
+ "MiMo V2.5 Free",
9
+ "Nemotron 3 Ultra Free",
10
+ "Deepseek V4 Flash Free",
11
+ "North Mini Code Free",
12
+ "Hy3 Free",
13
+ "Big Pickle",
14
+ ]
15
+
16
+ function SelectModelWithoutProviders() {
17
+ const dialog = useDialog()
18
+ const models = names.map((name, index) => ({
19
+ id: name.toLowerCase().replaceAll(" ", "-"),
20
+ name,
21
+ provider: { id: "opencode", name: "OpenCode" },
22
+ cost: { input: 0, output: 0 },
23
+ limit: { context: 128_000 },
24
+ capabilities: {
25
+ reasoning: index !== 5,
26
+ input: { text: true, image: false, audio: false, video: false, pdf: false },
27
+ },
28
+ }))
29
+ const [current, setCurrent] = createSignal(models[2])
30
+ const model = {
31
+ list: () => models,
32
+ current,
33
+ set(value) {
34
+ setCurrent(models.find((item) => item.id === value?.modelID))
35
+ },
36
+ }
37
+ const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
38
+
39
+ onMount(open)
40
+
41
+ return (
42
+ <Button variant="secondary" onClick={open}>
43
+ Open select model dialog
44
+ </Button>
45
+ )
46
+ }
47
+
48
+ export default {
49
+ title: "App/Dialogs/Select Model",
50
+ id: "app-dialog-select-model",
51
+ }
52
+
53
+ export const WithoutProviders = {
54
+ render: () => <SelectModelWithoutProviders />,
55
+ }
packages/app/src/components/dialog-select-model-unpaid-v2.tsx ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
2
+ import { Icon } from "@opencode-ai/ui/v2/icon"
3
+ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
4
+ import { Tag } from "@opencode-ai/ui/v2/badge-v2"
5
+ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
6
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
7
+ import { useTheme } from "@opencode-ai/ui/theme"
8
+ import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
9
+ import { useLocal } from "@/context/local"
10
+ import { useProviders } from "@/hooks/use-providers"
11
+ import { decode64 } from "@/utils/base64"
12
+ import { useLanguage } from "@/context/language"
13
+ import { ModelTooltip } from "./model-tooltip"
14
+
15
+ type ModelState = ReturnType<typeof useLocal>["model"]
16
+ const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
17
+ const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
18
+
19
+ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
20
+ const local = useLocal()
21
+ const model = props.model ?? local.model
22
+ const dialog = useDialog()
23
+ const theme = useTheme()
24
+ const directory = () => decode64(local.slug())
25
+ const providers = useProviders(directory)
26
+ const language = useLanguage()
27
+ const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
28
+ const currentKey = createMemo(() => {
29
+ const c = model.current()
30
+ return c ? `${c.provider.id}:${c.id}` : undefined
31
+ })
32
+ const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
33
+ item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
34
+ const freeModels = createMemo(() => model.list().filter(isFree))
35
+
36
+ const openProviders = (provider?: string) => {
37
+ void import("./dialog-connect-provider").then((x) => {
38
+ const controller = x.useProviderConnectController()
39
+ controller.select(provider)
40
+ void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
41
+ })
42
+ }
43
+
44
+ const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
45
+ model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
46
+ dialog.close()
47
+ }
48
+
49
+ // Focus starts on the dialog's close button, outside the list, so listen at the
50
+ // document level while the dialog is mounted instead of on the list container.
51
+ let listEl: HTMLDivElement | undefined
52
+ onMount(() => {
53
+ const handleKeyDown = (e: KeyboardEvent) => {
54
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
55
+ if (!listEl) return
56
+ const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
57
+ if (buttons.length === 0) return
58
+ const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
59
+ const next =
60
+ index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
61
+ buttons[(next + buttons.length) % buttons.length]?.focus()
62
+ e.preventDefault()
63
+ }
64
+ document.addEventListener("keydown", handleKeyDown)
65
+ onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
66
+ })
67
+
68
+ return (
69
+ <DialogV2
70
+ fit
71
+ containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
72
+ class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
73
+ >
74
+ <DialogHeader closeLabel={language.t("common.close")}>
75
+ <DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
76
+ </DialogHeader>
77
+ <DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
78
+ <div ref={listEl} class="flex min-h-0 flex-col">
79
+ <div data-section="free-models" class="flex w-full flex-col items-start pb-3">
80
+ <div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
81
+ <div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
82
+ {language.t("dialog.model.unpaid.freeModels.title")}
83
+ </div>
84
+ </div>
85
+ <For each={freeModels()}>
86
+ {(item) => (
87
+ <TooltipV2
88
+ class="w-full"
89
+ placement="right-start"
90
+ gutter={6}
91
+ openDelay={0}
92
+ contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
93
+ value={
94
+ <ModelTooltip
95
+ model={{ ...item, name: displayModelName(item.name) }}
96
+ latest={item.latest}
97
+ free={isFree(item)}
98
+ v2
99
+ />
100
+ }
101
+ >
102
+ <button
103
+ type="button"
104
+ class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
105
+ onClick={() => selectModel(item)}
106
+ >
107
+ <span class="min-w-0 truncate">{displayModelName(item.name)}</span>
108
+ <Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
109
+ <Show when={item.latest}>
110
+ <Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
111
+ </Show>
112
+ <Show when={currentKey() === modelKey(item)}>
113
+ <Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
114
+ </Show>
115
+ </button>
116
+ </TooltipV2>
117
+ )}
118
+ </For>
119
+ </div>
120
+
121
+ <div class="flex w-full flex-col">
122
+ <div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
123
+ <div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
124
+ <div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
125
+ {language.t("dialog.model.unpaid.addMore.title")}
126
+ </div>
127
+ </div>
128
+ <div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
129
+ <For
130
+ each={[...providers.popular()]
131
+ .filter((provider) => featuredProviders.includes(provider.id))
132
+ .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
133
+ >
134
+ {(provider) => (
135
+ <button
136
+ type="button"
137
+ data-provider-id={provider.id}
138
+ class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
139
+ classList={{
140
+ "border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
141
+ theme.mode() !== "dark",
142
+ "border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
143
+ }}
144
+ onClick={() => openProviders(provider.id)}
145
+ >
146
+ <ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
147
+ <span class="flex min-w-0 flex-col">
148
+ <span class="truncate">{provider.name}</span>
149
+ <Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
150
+ <span class="truncate font-[440] text-v2-text-text-muted">
151
+ {language.t(
152
+ provider.id === "opencode"
153
+ ? "dialog.provider.opencode.tagline"
154
+ : "dialog.provider.opencodeGo.tagline",
155
+ )}
156
+ </span>
157
+ </Show>
158
+ </span>
159
+ </button>
160
+ )}
161
+ </For>
162
+ <button
163
+ type="button"
164
+ class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
165
+ onClick={() => openProviders()}
166
+ >
167
+ {language.t("dialog.model.unpaid.viewMoreProviders")}
168
+ </button>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </DialogBody>
174
+ </DialogV2>
175
+ )
176
+ }
packages/app/src/components/dialog-select-model-unpaid.tsx ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@opencode-ai/ui/button"
2
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
3
+ import { Dialog } from "@opencode-ai/ui/dialog"
4
+ import { List, type ListRef } from "@opencode-ai/ui/list"
5
+ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
6
+ import { Tag } from "@opencode-ai/ui/tag"
7
+ import { Tooltip } from "@opencode-ai/ui/tooltip"
8
+ import { type Component, Show } from "solid-js"
9
+ import { useLocal } from "@/context/local"
10
+ import { popularProviders, useProviders } from "@/hooks/use-providers"
11
+ import { ModelTooltip } from "./model-tooltip"
12
+ import { useLanguage } from "@/context/language"
13
+ import { decode64 } from "@/utils/base64"
14
+
15
+ type ModelState = ReturnType<typeof useLocal>["model"]
16
+
17
+ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
18
+ const local = useLocal()
19
+ const model = props.model ?? local.model
20
+ const dialog = useDialog()
21
+ const directory = () => decode64(local.slug())
22
+ const providers = useProviders(directory)
23
+ const language = useLanguage()
24
+
25
+ const openProviders = (provider?: string) => {
26
+ void import("./dialog-connect-provider").then((x) => {
27
+ const controller = x.useProviderConnectController()
28
+ controller.select(provider)
29
+ void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
30
+ })
31
+ }
32
+
33
+ const connect = (provider: string) => openProviders(provider)
34
+ const all = () => openProviders()
35
+
36
+ let listRef: ListRef | undefined
37
+ const handleKeyDown = (e: KeyboardEvent) => {
38
+ if (e.key === "Escape") return
39
+ listRef?.onKeyDown(e)
40
+ }
41
+
42
+ return (
43
+ <Dialog
44
+ title={language.t("dialog.model.select.title")}
45
+ class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
46
+ >
47
+ <div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
48
+ <div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
49
+ <List
50
+ class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
51
+ ref={(ref) => (listRef = ref)}
52
+ items={model.list}
53
+ current={model.current()}
54
+ key={(x) => `${x.provider.id}:${x.id}`}
55
+ itemWrapper={(item, node) => (
56
+ <Tooltip
57
+ class="w-full"
58
+ placement="right-start"
59
+ gutter={12}
60
+ value={
61
+ <ModelTooltip
62
+ model={item}
63
+ latest={item.latest}
64
+ free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
65
+ />
66
+ }
67
+ >
68
+ {node}
69
+ </Tooltip>
70
+ )}
71
+ onSelect={(x) => {
72
+ model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
73
+ recent: true,
74
+ })
75
+ dialog.close()
76
+ }}
77
+ >
78
+ {(i) => (
79
+ <div class="w-full flex items-center gap-x-2.5">
80
+ <span>{i.name}</span>
81
+ <Tag>{language.t("model.tag.free")}</Tag>
82
+ <Show when={i.latest}>
83
+ <Tag>{language.t("model.tag.latest")}</Tag>
84
+ </Show>
85
+ </div>
86
+ )}
87
+ </List>
88
+ </div>
89
+ <div class="px-1.5 pb-1.5">
90
+ <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
91
+ <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
92
+ <div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
93
+ <div class="w-full">
94
+ <List
95
+ class="w-full px-3"
96
+ key={(p) => p.id}
97
+ items={providers.popular}
98
+ activeIcon="plus-small"
99
+ sortBy={(a, b) => {
100
+ if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
101
+ return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
102
+ return a.name.localeCompare(b.name)
103
+ }}
104
+ onSelect={(x) => {
105
+ if (!x) return
106
+ connect(x.id)
107
+ }}
108
+ >
109
+ {(i) => (
110
+ <div class="w-full flex items-center gap-x-3">
111
+ <ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
112
+ <span>{i.name}</span>
113
+ <Show when={i.id === "opencode"}>
114
+ <div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
115
+ </Show>
116
+ <Show when={i.id === "opencode"}>
117
+ <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
118
+ </Show>
119
+ <Show when={i.id === "opencode-go"}>
120
+ <>
121
+ <div class="text-14-regular text-text-weak">
122
+ {language.t("dialog.provider.opencodeGo.tagline")}
123
+ </div>
124
+ <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
125
+ </>
126
+ </Show>
127
+ <Show when={i.id === "anthropic"}>
128
+ <div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
129
+ </Show>
130
+ </div>
131
+ )}
132
+ </List>
133
+ <Button
134
+ variant="ghost"
135
+ class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
136
+ icon="dot-grid"
137
+ onClick={all}
138
+ >
139
+ {language.t("dialog.provider.viewAll")}
140
+ </Button>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </Dialog>
146
+ )
147
+ }
packages/app/src/components/dialog-select-server.tsx ADDED
@@ -0,0 +1,722 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@opencode-ai/ui/button"
2
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
3
+ import { Dialog } from "@opencode-ai/ui/dialog"
4
+ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
5
+ import { Icon } from "@opencode-ai/ui/icon"
6
+ import { IconButton } from "@opencode-ai/ui/icon-button"
7
+ import { List } from "@opencode-ai/ui/list"
8
+ import { TextField } from "@opencode-ai/ui/text-field"
9
+ import { useMutation } from "@tanstack/solid-query"
10
+ import { showToast } from "@/utils/toast"
11
+ import { useNavigate } from "@solidjs/router"
12
+ import { createEffect, createMemo, createResource, Show } from "solid-js"
13
+ import { createStore } from "solid-js/store"
14
+ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
15
+ import { useGlobal } from "@/context/global"
16
+ import { useLanguage } from "@/context/language"
17
+ import { usePlatform } from "@/context/platform"
18
+ import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
19
+ import { detectServerProtocol } from "@/utils/server-protocol"
20
+ import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
21
+ import { useSettings } from "@/context/settings"
22
+ import { useTabs } from "@/context/tabs"
23
+
24
+ const DEFAULT_USERNAME = "opencode"
25
+
26
+ interface ServerFormProps {
27
+ value: string
28
+ name: string
29
+ username: string
30
+ password: string
31
+ placeholder: string
32
+ busy: boolean
33
+ error: string
34
+ status: boolean | undefined
35
+ onChange: (value: string) => void
36
+ onNameChange: (value: string) => void
37
+ onUsernameChange: (value: string) => void
38
+ onPasswordChange: (value: string) => void
39
+ onSubmit: () => void
40
+ onBack: () => void
41
+ }
42
+
43
+ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
44
+ showToast({
45
+ variant: "error",
46
+ title: language.t("common.requestFailed"),
47
+ description: err instanceof Error ? err.message : String(err),
48
+ })
49
+ }
50
+
51
+ function useDefaultServer() {
52
+ const language = useLanguage()
53
+ const platform = usePlatform()
54
+ const [defaultKey, defaultUrlActions] = createResource(
55
+ async () => {
56
+ try {
57
+ const key = await platform.getDefaultServer?.()
58
+ if (!key) return null
59
+ return key
60
+ } catch (err) {
61
+ showRequestError(language, err)
62
+ return null
63
+ }
64
+ },
65
+ { initialValue: null },
66
+ )
67
+
68
+ const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
69
+ const setDefault = async (key: ServerConnection.Key | null) => {
70
+ try {
71
+ await platform.setDefaultServer?.(key)
72
+ defaultUrlActions.mutate(key)
73
+ } catch (err) {
74
+ showRequestError(language, err)
75
+ }
76
+ }
77
+
78
+ return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
79
+ }
80
+
81
+ function useServerPreview() {
82
+ const checkServerHealth = useCheckServerHealth()
83
+
84
+ const looksComplete = (value: string) => {
85
+ const normalized = normalizeServerUrl(value)
86
+ if (!normalized) return false
87
+ const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
88
+ if (!host) return false
89
+ if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
90
+ return host.includes(".") || host.includes(":")
91
+ }
92
+
93
+ const previewStatus = async (
94
+ value: string,
95
+ username: string,
96
+ password: string,
97
+ setStatus: (value: boolean | undefined) => void,
98
+ ) => {
99
+ setStatus(undefined)
100
+ if (!looksComplete(value)) return
101
+ const normalized = normalizeServerUrl(value)
102
+ if (!normalized) return
103
+ const http: ServerConnection.HttpBase = { url: normalized }
104
+ if (username) http.username = username
105
+ if (password) http.password = password
106
+ const result = await checkServerHealth(http)
107
+ setStatus(result.healthy)
108
+ }
109
+
110
+ return { previewStatus }
111
+ }
112
+
113
+ function ServerForm(props: ServerFormProps) {
114
+ const language = useLanguage()
115
+ const keyDown = (event: KeyboardEvent) => {
116
+ event.stopPropagation()
117
+ if (event.key === "Escape") {
118
+ event.preventDefault()
119
+ props.onBack()
120
+ return
121
+ }
122
+ if (event.key !== "Enter" || event.isComposing) return
123
+ event.preventDefault()
124
+ props.onSubmit()
125
+ }
126
+
127
+ return (
128
+ <div>
129
+ <div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
130
+ <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
131
+ <TextField
132
+ type="text"
133
+ label={language.t("dialog.server.add.url")}
134
+ placeholder={props.placeholder}
135
+ value={props.value}
136
+ autofocus
137
+ validationState={props.error ? "invalid" : "valid"}
138
+ error={props.error}
139
+ disabled={props.busy}
140
+ onChange={props.onChange}
141
+ onKeyDown={keyDown}
142
+ />
143
+ </div>
144
+ <TextField
145
+ type="text"
146
+ label={language.t("dialog.server.add.name")}
147
+ placeholder={language.t("dialog.server.add.namePlaceholder")}
148
+ defaultValue={props.name}
149
+ disabled={props.busy}
150
+ onChange={props.onNameChange}
151
+ onKeyDown={keyDown}
152
+ />
153
+ <div class="grid grid-cols-2 gap-2 min-w-0">
154
+ <TextField
155
+ type="text"
156
+ label={language.t("dialog.server.add.username")}
157
+ placeholder={language.t("dialog.server.add.usernamePlaceholder")}
158
+ defaultValue={props.username}
159
+ disabled={props.busy}
160
+ onChange={props.onUsernameChange}
161
+ onKeyDown={keyDown}
162
+ />
163
+ <TextField
164
+ type="password"
165
+ label={language.t("dialog.server.add.password")}
166
+ placeholder={language.t("dialog.server.add.passwordPlaceholder")}
167
+ defaultValue={props.password}
168
+ disabled={props.busy}
169
+ onChange={props.onPasswordChange}
170
+ onKeyDown={keyDown}
171
+ />
172
+ </div>
173
+ </div>
174
+ </div>
175
+ )
176
+ }
177
+
178
+ export function DialogSelectServer() {
179
+ const dialog = useDialog()
180
+ const controller = useServerManagementController({ onSelect: dialog.close })
181
+
182
+ return (
183
+ <Dialog title={controller.formTitle()}>
184
+ <div class="flex flex-1 min-h-0 flex-col px-5">
185
+ <Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
186
+ <ServerConnectionForm controller={controller} />
187
+ </Show>
188
+ </div>
189
+ </Dialog>
190
+ )
191
+ }
192
+
193
+ export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
194
+ const navigate = useNavigate()
195
+ const server = useServer()
196
+ const tabs = useTabs()
197
+ const global = useGlobal()
198
+ const platform = usePlatform()
199
+ const language = useLanguage()
200
+ const { defaultKey, canDefault, setDefault } = useDefaultServer()
201
+ const { previewStatus } = useServerPreview()
202
+ const checkServerHealth = useCheckServerHealth()
203
+ const [store, setStore] = createStore({
204
+ addServer: {
205
+ url: "",
206
+ name: "",
207
+ username: DEFAULT_USERNAME,
208
+ password: "",
209
+ error: "",
210
+ showForm: false,
211
+ status: undefined as boolean | undefined,
212
+ },
213
+ editServer: {
214
+ id: undefined as string | undefined,
215
+ value: "",
216
+ name: "",
217
+ username: "",
218
+ password: "",
219
+ error: "",
220
+ status: undefined as boolean | undefined,
221
+ },
222
+ })
223
+
224
+ const resetAdd = () => {
225
+ setStore("addServer", {
226
+ url: "",
227
+ name: "",
228
+ username: DEFAULT_USERNAME,
229
+ password: "",
230
+ error: "",
231
+ showForm: false,
232
+ status: undefined,
233
+ })
234
+ }
235
+ const resetEdit = () => {
236
+ setStore("editServer", {
237
+ id: undefined,
238
+ value: "",
239
+ name: "",
240
+ username: "",
241
+ password: "",
242
+ error: "",
243
+ status: undefined,
244
+ })
245
+ }
246
+
247
+ const addMutation = useMutation(() => ({
248
+ mutationFn: async (value: string) => {
249
+ const normalized = normalizeServerUrl(value)
250
+ if (!normalized) {
251
+ resetAdd()
252
+ return
253
+ }
254
+
255
+ const conn: ServerConnection.Http = {
256
+ type: "http",
257
+ http: { url: normalized },
258
+ }
259
+ if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
260
+ if (store.addServer.password) conn.http.password = store.addServer.password
261
+ if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
262
+ const result = await checkServerHealth(conn.http)
263
+ if (!result.healthy) {
264
+ setStore("addServer", { error: language.t("dialog.server.add.error") })
265
+ return
266
+ }
267
+ if (
268
+ !settings.general.newLayoutDesigns() &&
269
+ (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
270
+ ) {
271
+ setStore("addServer", { error: language.t("dialog.server.add.error") })
272
+ return
273
+ }
274
+
275
+ resetAdd()
276
+ if (options.navigateOnAdd === false) {
277
+ server.add(conn)
278
+ options.onSelect?.()
279
+ return
280
+ }
281
+ await select(conn, true)
282
+ },
283
+ }))
284
+
285
+ const editMutation = useMutation(() => ({
286
+ mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
287
+ if (input.original.type !== "http") return
288
+ const normalized = normalizeServerUrl(input.value)
289
+ if (!normalized) {
290
+ resetEdit()
291
+ return
292
+ }
293
+
294
+ const name = store.editServer.name.trim() || undefined
295
+ const username = store.editServer.username || undefined
296
+ const password = store.editServer.password || undefined
297
+ const existingName = input.original.displayName
298
+ if (
299
+ normalized === input.original.http.url &&
300
+ name === existingName &&
301
+ username === input.original.http.username &&
302
+ password === input.original.http.password
303
+ ) {
304
+ resetEdit()
305
+ return
306
+ }
307
+
308
+ const conn: ServerConnection.Http = {
309
+ type: "http",
310
+ displayName: name,
311
+ http: { url: normalized, username, password },
312
+ }
313
+ const result = await checkServerHealth(conn.http)
314
+ if (!result.healthy) {
315
+ setStore("editServer", { error: language.t("dialog.server.add.error") })
316
+ return
317
+ }
318
+ if (
319
+ !settings.general.newLayoutDesigns() &&
320
+ (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
321
+ ) {
322
+ setStore("editServer", { error: language.t("dialog.server.add.error") })
323
+ return
324
+ }
325
+ if (normalized === input.original.http.url) {
326
+ server.add(conn)
327
+ } else {
328
+ replaceServer(input.original, conn)
329
+ }
330
+
331
+ resetEdit()
332
+ },
333
+ }))
334
+
335
+ const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
336
+ const originalKey = ServerConnection.key(original)
337
+ const active = server.key
338
+ tabs.removeServer(originalKey)
339
+ const newConn = server.add(next)
340
+ if (!newConn) return
341
+ const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
342
+ if (nextActive) server.setActive(nextActive)
343
+ server.remove(originalKey)
344
+ }
345
+
346
+ const items = createMemo(() => {
347
+ const current = server.current
348
+ const list = server.list
349
+ if (!current) return list
350
+ if (!list.includes(current)) return [current, ...list]
351
+ return [current, ...list.filter((x) => x !== current)]
352
+ })
353
+
354
+ const settings = useSettings()
355
+ const current = createMemo<ServerConnection.Any | undefined>(() =>
356
+ settings.general.newLayoutDesigns()
357
+ ? undefined
358
+ : (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
359
+ )
360
+
361
+ const sortedItems = createMemo(() => {
362
+ const raw = items()
363
+ const list = settings.general.newLayoutDesigns()
364
+ ? raw
365
+ : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
366
+ if (!list.length) return list
367
+ const active = current()
368
+ const order = new Map(list.map((url, index) => [url, index] as const))
369
+ const rank = (value?: ServerHealth) => {
370
+ if (value?.healthy === true) return 0
371
+ if (value?.healthy === false) return 2
372
+ return 1
373
+ }
374
+ return list.slice().sort((a, b) => {
375
+ if (a === active) return -1
376
+ if (b === active) return 1
377
+ const diff =
378
+ rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
379
+ if (diff !== 0) return diff
380
+ return (order.get(a) ?? 0) - (order.get(b) ?? 0)
381
+ })
382
+ })
383
+
384
+ async function select(conn: ServerConnection.Any, persist?: boolean) {
385
+ if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
386
+ options.onSelect?.()
387
+ if (persist && conn.type === "http") {
388
+ server.add(conn)
389
+ navigate("/")
390
+ return
391
+ }
392
+ navigate("/")
393
+ queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
394
+ }
395
+
396
+ const handleAddChange = (value: string) => {
397
+ if (addMutation.isPending) return
398
+ setStore("addServer", { url: value, error: "" })
399
+ void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
400
+ setStore("addServer", { status: next }),
401
+ )
402
+ }
403
+
404
+ const handleAddNameChange = (value: string) => {
405
+ if (addMutation.isPending) return
406
+ setStore("addServer", { name: value, error: "" })
407
+ }
408
+
409
+ const handleAddUsernameChange = (value: string) => {
410
+ if (addMutation.isPending) return
411
+ setStore("addServer", { username: value, error: "" })
412
+ void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
413
+ setStore("addServer", { status: next }),
414
+ )
415
+ }
416
+
417
+ const handleAddPasswordChange = (value: string) => {
418
+ if (addMutation.isPending) return
419
+ setStore("addServer", { password: value, error: "" })
420
+ void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
421
+ setStore("addServer", { status: next }),
422
+ )
423
+ }
424
+
425
+ const handleEditChange = (value: string) => {
426
+ if (editMutation.isPending) return
427
+ setStore("editServer", { value, error: "" })
428
+ void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
429
+ setStore("editServer", { status: next }),
430
+ )
431
+ }
432
+
433
+ const handleEditNameChange = (value: string) => {
434
+ if (editMutation.isPending) return
435
+ setStore("editServer", { name: value, error: "" })
436
+ }
437
+
438
+ const handleEditUsernameChange = (value: string) => {
439
+ if (editMutation.isPending) return
440
+ setStore("editServer", { username: value, error: "" })
441
+ void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
442
+ setStore("editServer", { status: next }),
443
+ )
444
+ }
445
+
446
+ const handleEditPasswordChange = (value: string) => {
447
+ if (editMutation.isPending) return
448
+ setStore("editServer", { password: value, error: "" })
449
+ void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
450
+ setStore("editServer", { status: next }),
451
+ )
452
+ }
453
+
454
+ const mode = createMemo<"list" | "add" | "edit">(() => {
455
+ if (store.editServer.id) return "edit"
456
+ if (store.addServer.showForm) return "add"
457
+ return "list"
458
+ })
459
+
460
+ const editing = createMemo(() => {
461
+ if (!store.editServer.id) return
462
+ return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
463
+ })
464
+
465
+ const resetForm = () => {
466
+ resetAdd()
467
+ resetEdit()
468
+ }
469
+
470
+ const startAdd = () => {
471
+ resetEdit()
472
+ setStore("addServer", {
473
+ showForm: true,
474
+ url: "",
475
+ name: "",
476
+ username: DEFAULT_USERNAME,
477
+ password: "",
478
+ error: "",
479
+ status: undefined,
480
+ })
481
+ }
482
+
483
+ const startEdit = (conn: ServerConnection.Http) => {
484
+ resetAdd()
485
+ setStore("editServer", {
486
+ id: conn.http.url,
487
+ value: conn.http.url,
488
+ name: conn.displayName ?? "",
489
+ username: conn.http.username ?? "",
490
+ password: conn.http.password ?? "",
491
+ error: "",
492
+ status: global.servers.health[ServerConnection.key(conn)]?.healthy,
493
+ })
494
+ }
495
+
496
+ const submitForm = () => {
497
+ if (mode() === "add") {
498
+ if (addMutation.isPending) return
499
+ setStore("addServer", { error: "" })
500
+ addMutation.mutate(store.addServer.url)
501
+ return
502
+ }
503
+ const original = editing()
504
+ if (!original) return
505
+ if (editMutation.isPending) return
506
+ setStore("editServer", { error: "" })
507
+ editMutation.mutate({ original, value: store.editServer.value })
508
+ }
509
+
510
+ const isFormMode = createMemo(() => mode() !== "list")
511
+ const isAddMode = createMemo(() => mode() === "add")
512
+ const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
513
+
514
+ const formTitle = createMemo(() => {
515
+ if (!isFormMode()) return language.t("dialog.server.title")
516
+ return (
517
+ <div class="flex items-center gap-2 -ml-2">
518
+ <IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
519
+ <span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
520
+ </div>
521
+ )
522
+ })
523
+
524
+ createEffect(() => {
525
+ if (!store.editServer.id) return
526
+ if (editing()) return
527
+ resetEdit()
528
+ })
529
+
530
+ async function handleRemove(key: ServerConnection.Key) {
531
+ try {
532
+ if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
533
+ tabs.removeServer(key)
534
+ server.remove(key)
535
+ if ((await platform.getDefaultServer?.()) === key) {
536
+ await setDefault(null)
537
+ }
538
+ } catch (err) {
539
+ showRequestError(language, err)
540
+ }
541
+ }
542
+
543
+ return {
544
+ defaultKey,
545
+ canDefault,
546
+ current,
547
+ sortedItems,
548
+ status: () => global.servers.health,
549
+ isFormMode,
550
+ isAddMode,
551
+ formTitle,
552
+ formBusy,
553
+ formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
554
+ formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
555
+ formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
556
+ formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
557
+ formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
558
+ formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
559
+ select,
560
+ setDefault,
561
+ startAdd,
562
+ startEdit,
563
+ resetForm,
564
+ submitForm,
565
+ handleRemove,
566
+ handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
567
+ handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
568
+ handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
569
+ handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
570
+ }
571
+ }
572
+
573
+ export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
574
+ const language = useLanguage()
575
+ const settings = useSettings()
576
+
577
+ return (
578
+ <div class="flex flex-1 min-h-0 flex-col gap-4">
579
+ <List
580
+ class="flex-1 min-h-0 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
581
+ search={{
582
+ placeholder: language.t("dialog.server.search.placeholder"),
583
+ autofocus: false,
584
+ }}
585
+ noInitialSelection
586
+ emptyMessage={language.t("dialog.server.empty")}
587
+ items={props.controller.sortedItems}
588
+ key={(x) => x.http.url}
589
+ onSelect={(x) => {
590
+ if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
591
+ }}
592
+ divider={true}
593
+ >
594
+ {(i) => {
595
+ const key = ServerConnection.key(i)
596
+ return (
597
+ <div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
598
+ <div class="flex flex-col h-full items-center w-5">
599
+ <ServerHealthIndicator health={props.controller.status()[key]} />
600
+ </div>
601
+ <ServerRow
602
+ conn={i}
603
+ dimmed={props.controller.status()[key]?.healthy === false}
604
+ status={props.controller.status()[key]}
605
+ class="flex items-center gap-3 min-w-0 flex-1"
606
+ badge={
607
+ <Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
608
+ <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
609
+ {language.t("dialog.server.status.default")}
610
+ </span>
611
+ </Show>
612
+ }
613
+ showCredentials
614
+ />
615
+ <div class="flex items-center justify-center gap-4 pl-4">
616
+ <Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
617
+ <Icon name="check" class="h-6" />
618
+ </Show>
619
+
620
+ <Show when={i.type === "http"}>
621
+ <DropdownMenu>
622
+ <DropdownMenu.Trigger
623
+ as={IconButton}
624
+ icon="dot-grid"
625
+ variant="ghost"
626
+ class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
627
+ onClick={(e: MouseEvent) => e.stopPropagation()}
628
+ onPointerDown={(e: PointerEvent) => e.stopPropagation()}
629
+ />
630
+ <DropdownMenu.Portal>
631
+ <DropdownMenu.Content class="mt-1">
632
+ <DropdownMenu.Item
633
+ onSelect={() => {
634
+ if (i.type !== "http") return
635
+ props.controller.startEdit(i)
636
+ }}
637
+ >
638
+ <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
639
+ </DropdownMenu.Item>
640
+ <Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
641
+ <DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
642
+ <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
643
+ </DropdownMenu.Item>
644
+ </Show>
645
+ <Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
646
+ <DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
647
+ <DropdownMenu.ItemLabel>
648
+ {language.t("dialog.server.menu.defaultRemove")}
649
+ </DropdownMenu.ItemLabel>
650
+ </DropdownMenu.Item>
651
+ </Show>
652
+ <DropdownMenu.Separator />
653
+ <DropdownMenu.Item
654
+ onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
655
+ class="text-text-on-critical-base hover:bg-surface-critical-weak"
656
+ >
657
+ <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
658
+ </DropdownMenu.Item>
659
+ </DropdownMenu.Content>
660
+ </DropdownMenu.Portal>
661
+ </DropdownMenu>
662
+ </Show>
663
+ </div>
664
+ </div>
665
+ )
666
+ }}
667
+ </List>
668
+
669
+ <div class="shrink-0 pb-5">
670
+ <Button
671
+ variant="secondary"
672
+ icon="plus-small"
673
+ size="large"
674
+ onClick={props.controller.startAdd}
675
+ class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
676
+ >
677
+ {language.t("dialog.server.add.button")}
678
+ </Button>
679
+ </div>
680
+ </div>
681
+ )
682
+ }
683
+
684
+ export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
685
+ const language = useLanguage()
686
+
687
+ return (
688
+ <div class="flex flex-1 min-h-0 flex-col gap-4">
689
+ <ServerForm
690
+ value={props.controller.formValue()}
691
+ name={props.controller.formName()}
692
+ username={props.controller.formUsername()}
693
+ password={props.controller.formPassword()}
694
+ placeholder={language.t("dialog.server.add.placeholder")}
695
+ busy={props.controller.formBusy()}
696
+ error={props.controller.formError()}
697
+ status={props.controller.formStatus()}
698
+ onChange={props.controller.handleFormChange()}
699
+ onNameChange={props.controller.handleFormNameChange()}
700
+ onUsernameChange={props.controller.handleFormUsernameChange()}
701
+ onPasswordChange={props.controller.handleFormPasswordChange()}
702
+ onSubmit={props.controller.submitForm}
703
+ onBack={props.controller.resetForm}
704
+ />
705
+ <div class="shrink-0 pb-5">
706
+ <Button
707
+ variant="primary"
708
+ size="large"
709
+ onClick={props.controller.submitForm}
710
+ disabled={props.controller.formBusy()}
711
+ class="px-3 py-1.5"
712
+ >
713
+ {props.controller.formBusy()
714
+ ? language.t("dialog.server.add.checking")
715
+ : props.controller.isAddMode()
716
+ ? language.t("dialog.server.add.button")
717
+ : language.t("common.save")}
718
+ </Button>
719
+ </div>
720
+ </div>
721
+ )
722
+ }
packages/app/src/components/dialog-settings.tsx ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Component, createSignal, startTransition } from "solid-js"
2
+ import { Dialog } from "@opencode-ai/ui/dialog"
3
+ import { Tabs } from "@opencode-ai/ui/tabs"
4
+ import { Icon } from "@opencode-ai/ui/icon"
5
+ import { useLanguage } from "@/context/language"
6
+ import { usePlatform } from "@/context/platform"
7
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
8
+ import { SettingsGeneral } from "./settings-general"
9
+ import { SettingsKeybinds } from "./settings-keybinds"
10
+ import { SettingsProviders } from "./settings-providers"
11
+ import { SettingsModels } from "./settings-models"
12
+ import { SettingsServers } from "./settings-servers"
13
+
14
+ export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
15
+ const language = useLanguage()
16
+ const platform = usePlatform()
17
+ const dialog = useDialog()
18
+ const [tab, setTab] = createSignal(props.defaultValue ?? "general")
19
+
20
+ const showProviders = () => {
21
+ void dialog.show(() => <DialogSettings defaultValue="providers" />)
22
+ }
23
+
24
+ return (
25
+ <Dialog size="x-large" transition>
26
+ <Tabs
27
+ orientation="vertical"
28
+ variant="settings"
29
+ value={tab()}
30
+ onChange={(value) => void startTransition(() => setTab(value))}
31
+ class="h-full settings-dialog"
32
+ >
33
+ <Tabs.List>
34
+ <div class="flex flex-col justify-between h-full w-full gap-4">
35
+ <div class="flex flex-col gap-3 w-full pt-3">
36
+ <div class="flex flex-col gap-3">
37
+ <div class="flex flex-col gap-1.5">
38
+ <Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
39
+ <div class="flex flex-col gap-1.5 w-full">
40
+ <Tabs.Trigger value="general">
41
+ <Icon name="sliders" />
42
+ {language.t("settings.tab.general")}
43
+ </Tabs.Trigger>
44
+ <Tabs.Trigger value="shortcuts">
45
+ <Icon name="keyboard" />
46
+ {language.t("settings.tab.shortcuts")}
47
+ </Tabs.Trigger>
48
+ <Tabs.Trigger value="servers">
49
+ <Icon name="server" />
50
+ {language.t("status.popover.tab.servers")}
51
+ </Tabs.Trigger>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="flex flex-col gap-1.5">
56
+ <Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
57
+ <div class="flex flex-col gap-1.5 w-full">
58
+ <Tabs.Trigger value="providers">
59
+ <Icon name="providers" />
60
+ {language.t("settings.providers.title")}
61
+ </Tabs.Trigger>
62
+ <Tabs.Trigger value="models">
63
+ <Icon name="models" />
64
+ {language.t("settings.models.title")}
65
+ </Tabs.Trigger>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ <div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
71
+ <span>{language.t("app.name.desktop")}</span>
72
+ <span class="text-11-regular">v{platform.version}</span>
73
+ </div>
74
+ </div>
75
+ </Tabs.List>
76
+ <Tabs.Content value="general" class="no-scrollbar">
77
+ <SettingsGeneral />
78
+ </Tabs.Content>
79
+ <Tabs.Content value="shortcuts" class="no-scrollbar">
80
+ <SettingsKeybinds />
81
+ </Tabs.Content>
82
+ <Tabs.Content value="servers" class="no-scrollbar">
83
+ <SettingsServers />
84
+ </Tabs.Content>
85
+ <Tabs.Content value="providers" class="no-scrollbar">
86
+ <SettingsProviders onBack={showProviders} />
87
+ </Tabs.Content>
88
+ <Tabs.Content value="models" class="no-scrollbar">
89
+ <SettingsModels />
90
+ </Tabs.Content>
91
+ </Tabs>
92
+ </Dialog>
93
+ )
94
+ }
packages/app/src/components/dialog-usage-exceeded.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { usePlatform } from "@/context/platform"
2
+ import { useLanguage } from "@/context/language"
3
+ import { Button } from "@opencode-ai/ui/button"
4
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
5
+ import { Dialog } from "@opencode-ai/ui/dialog"
6
+ import { JSX } from "solid-js"
7
+
8
+ export type DialogGoUpsellProps = {
9
+ title: string
10
+ description: JSX.Element
11
+ link?: string
12
+ actionLabel: string
13
+ onClose?: (dontShowAgain?: boolean) => void
14
+ }
15
+
16
+ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
17
+ const dialog = useDialog()
18
+ const language = useLanguage()
19
+ const platform = usePlatform()
20
+
21
+ const runAction = () => {
22
+ if (props.link) platform.openExternal(props.link)
23
+ props.onClose?.()
24
+ dialog.close()
25
+ }
26
+
27
+ const dismiss = () => {
28
+ props.onClose?.(true)
29
+ dialog.close()
30
+ }
31
+
32
+ return (
33
+ <Dialog title={props.title} description={props.description} fit>
34
+ <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
35
+ <div class="flex justify-end gap-2">
36
+ <Button variant="ghost" size="large" onClick={dismiss}>
37
+ {language.t("dialog.usageExceeded.dontShowAgain")}
38
+ </Button>
39
+ <Button variant="primary" size="large" onClick={runAction}>
40
+ {props.actionLabel}
41
+ </Button>
42
+ </div>
43
+ </div>
44
+ </Dialog>
45
+ )
46
+ }
packages/app/src/components/directory-picker-domain.test.ts ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import {
3
+ absoluteTreePath,
4
+ activeTreeNavigation,
5
+ advanceTreePreload,
6
+ nextSuggestionIndex,
7
+ nextTreeScrollTop,
8
+ pickerTreeEntries,
9
+ pickerSearchEntries,
10
+ pickerFileSearchQuery,
11
+ pickerMode,
12
+ preloadTreeDirectories,
13
+ selectedTreePath,
14
+ treeEntries,
15
+ treePathWithin,
16
+ currentPickerSuggestions,
17
+ createDirectorySearch,
18
+ createPriorityTaskQueue,
19
+ displayPickerPath,
20
+ pickerParent,
21
+ pickerRoot,
22
+ pickerAbsoluteInput,
23
+ } from "./directory-picker-domain"
24
+
25
+ test("maps server directory entries into Pierre paths", () => {
26
+ expect(
27
+ treeEntries("src/", [
28
+ { name: "components", type: "directory" },
29
+ { name: "index.ts", type: "file" },
30
+ ]),
31
+ ).toEqual(["src/components/", "src/index.ts"])
32
+ })
33
+
34
+ test("maps Pierre paths back to the selected server root", () => {
35
+ expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components")
36
+ expect(absoluteTreePath("C:/", "")).toBe("C:/")
37
+ expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md")
38
+ expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md")
39
+ })
40
+
41
+ test("includes files only when the picker selects files", () => {
42
+ const nodes = [
43
+ { name: "components", type: "directory" as const },
44
+ { name: "index.ts", type: "file" as const },
45
+ ]
46
+ expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"])
47
+ expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"])
48
+ })
49
+
50
+ test("includes files in file autocomplete while preserving directory navigation", () => {
51
+ const nodes = [
52
+ { name: "src", absolute: "/repo/src", type: "directory" as const },
53
+ { name: "README.md", absolute: "/repo/README.md", type: "file" as const },
54
+ ]
55
+ expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]])
56
+ expect(pickerSearchEntries(nodes, "file")).toEqual(nodes)
57
+ })
58
+
59
+ test("centralizes file and directory selection policy", () => {
60
+ const file = pickerMode("file", "/repo")
61
+ expect(file.includeFiles).toBeTrue()
62
+ expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts")
63
+ expect(file.selection("/repo", "src/")).toBeUndefined()
64
+ expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts")
65
+ expect(file.selection("/tmp", "example.txt")).toBeUndefined()
66
+ expect(file.navigation("/repo/src")).toBe("/repo/src")
67
+ expect(file.navigation("/tmp")).toBeUndefined()
68
+
69
+ const directory = pickerMode("directory")
70
+ expect(directory.includeFiles).toBeFalse()
71
+ expect(directory.selection("/repo", "src/")).toBe("/repo/src")
72
+ expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos")
73
+ expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo")
74
+ expect(directory.navigation("/tmp")).toBe("/tmp")
75
+ expect(directory.result("/repo", "")).toBe("/repo")
76
+ expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke")
77
+ expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo")
78
+ expect(directory.result("/repo", "", false)).toBeUndefined()
79
+ })
80
+
81
+ test("accepts mutations only from the active navigation", () => {
82
+ expect(activeTreeNavigation(3, 3)).toBeTrue()
83
+ expect(activeTreeNavigation(2, 3)).toBeFalse()
84
+ })
85
+
86
+ test("preserves POSIX case while matching Windows drives case-insensitively", () => {
87
+ expect(treePathWithin("/repo", "/Repo")).toBeFalse()
88
+ expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue()
89
+ expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue()
90
+ expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe("src/file.ts")
91
+ expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse()
92
+ expect(treePathWithin("/", "/src")).toBeTrue()
93
+ expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts")
94
+ expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts")
95
+ })
96
+
97
+ test("displays paths using the selected server path format", () => {
98
+ expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe(
99
+ "C:\\Users\\luke\\repos",
100
+ )
101
+ expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe(
102
+ "C:\\Users\\luke\\repos",
103
+ )
104
+ expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos")
105
+ expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos")
106
+ })
107
+
108
+ test("treats the server share prefix as the UNC root", () => {
109
+ expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share")
110
+ expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share")
111
+ expect(pickerParent("//Server/Share")).toBe("//Server/Share")
112
+ expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share")
113
+ })
114
+
115
+ test("resolves relative input against the current picker root", () => {
116
+ expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src")
117
+ expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other")
118
+ expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config")
119
+ expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src")
120
+ })
121
+
122
+ test("exposes autocomplete results only for their source query", () => {
123
+ const result = { query: "/repo/src", items: ["/repo/src/index.ts"] }
124
+ expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items)
125
+ expect(currentPickerSuggestions(result, "/repo/test")).toEqual([])
126
+ })
127
+
128
+ test("scopes file autocomplete to the current browser root", () => {
129
+ expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in")
130
+ expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
131
+ })
132
+
133
+ test("resolves directory autocomplete from the current browser root", async () => {
134
+ const directories: string[] = []
135
+ const sdk = {
136
+ api: {
137
+ file: {
138
+ find: (input: { location?: { directory?: string } }) => {
139
+ directories.push(input.location?.directory ?? "")
140
+ return Promise.resolve({ data: [] })
141
+ },
142
+ list: () => Promise.resolve({ data: [] }),
143
+ },
144
+ },
145
+ } as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
146
+ let base = "/repo"
147
+ const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
148
+
149
+ await search("components")
150
+ base = "/repo/src"
151
+ await search("components")
152
+
153
+ expect(directories).toEqual(["/repo", "/repo/src"])
154
+ })
155
+
156
+ test("keeps indexed directory results for servers that support empty search", async () => {
157
+ const sdk = {
158
+ api: {
159
+ file: {
160
+ find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
161
+ list: () => Promise.reject(new Error("listing should not run when search returns results")),
162
+ },
163
+ },
164
+ } as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
165
+ const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
166
+
167
+ expect(await search("")).toEqual(["/home/luke/projects"])
168
+ })
169
+
170
+ test("lists the default directory when empty search is unsupported", async () => {
171
+ const calls: string[] = []
172
+ const directories = Array.from({ length: 60 }, (_, index) => ({
173
+ path: `project-${index}/`,
174
+ type: "directory" as const,
175
+ }))
176
+ const sdk = {
177
+ api: {
178
+ file: {
179
+ find: () => Promise.resolve({ data: [] }),
180
+ list: (input: { location?: { directory?: string } }) => {
181
+ calls.push(input.location?.directory ?? "")
182
+ return Promise.resolve({
183
+ data: [...directories, { path: "README.md", type: "file" }],
184
+ })
185
+ },
186
+ },
187
+ },
188
+ } as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
189
+ const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
190
+
191
+ const results = await search("")
192
+ expect(results).toHaveLength(60)
193
+ expect(results.at(-1)).toBe("/home/luke/project-59")
194
+ expect(calls).toEqual(["/home/luke"])
195
+ })
196
+
197
+ test("matches the default directory listing when typed search is unsupported", async () => {
198
+ const sdk = {
199
+ api: {
200
+ file: {
201
+ find: () => Promise.resolve({ data: [] }),
202
+ list: () =>
203
+ Promise.resolve({
204
+ data: [
205
+ { path: "Documents/", type: "directory" },
206
+ { path: "Downloads/", type: "directory" },
207
+ ],
208
+ }),
209
+ },
210
+ },
211
+ } as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
212
+ const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
213
+
214
+ expect(await search("documents")).toEqual(["/home/luke/Documents"])
215
+ })
216
+
217
+ test("searches from an absolute root without a default base", async () => {
218
+ const directories: string[] = []
219
+ const sdk = {
220
+ api: {
221
+ file: {
222
+ list: (input: { location?: { directory?: string } }) => {
223
+ directories.push(input.location?.directory ?? "")
224
+ return Promise.resolve({
225
+ data: [
226
+ { path: "Users/", type: "directory" },
227
+ { path: "tmp/", type: "directory" },
228
+ ],
229
+ })
230
+ },
231
+ },
232
+ },
233
+ } as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
234
+ const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
235
+
236
+ expect(await search("/")).toEqual(["/Users", "/tmp"])
237
+ expect(directories).toEqual(["/"])
238
+ })
239
+
240
+ test("identifies the next directory level to preload", () => {
241
+ expect(
242
+ preloadTreeDirectories("src/", [
243
+ { name: "components", type: "directory" },
244
+ { name: "index.ts", type: "file" },
245
+ { name: "utils", type: "directory" },
246
+ ]),
247
+ ).toEqual(["src/components/", "src/utils/"])
248
+ })
249
+
250
+ test("advances preloading once for every expanded directory", () => {
251
+ const advanced = new Set<string>()
252
+ expect(advanceTreePreload(advanced, "")).toBeTrue()
253
+ expect(advanceTreePreload(advanced, "")).toBeFalse()
254
+ expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
255
+ })
256
+
257
+ test("limits background tasks and prioritizes newly requested work", async () => {
258
+ const queue = createPriorityTaskQueue<void>(2)
259
+ const first = Promise.withResolvers<void>()
260
+ const second = Promise.withResolvers<void>()
261
+ const started: string[] = []
262
+ let active = 0
263
+ let maximum = 0
264
+ const task = (name: string, blocker?: Promise<void>) => async () => {
265
+ started.push(name)
266
+ active++
267
+ maximum = Math.max(maximum, active)
268
+ await blocker
269
+ active--
270
+ }
271
+
272
+ const running = [
273
+ queue.schedule("first", "background", task("first", first.promise)),
274
+ queue.schedule("second", "background", task("second", second.promise)),
275
+ queue.schedule("preload", "background", task("preload")),
276
+ queue.schedule("opened", "user", task("opened")),
277
+ ]
278
+ await Promise.resolve()
279
+ expect(started).toEqual(["first", "second"])
280
+
281
+ first.resolve()
282
+ await running[0]
283
+ await Promise.resolve()
284
+ expect(started).toEqual(["first", "second", "opened"])
285
+
286
+ second.resolve()
287
+ await Promise.all(running)
288
+ expect(started).toEqual(["first", "second", "opened", "preload"])
289
+ expect(maximum).toBe(2)
290
+ })
291
+
292
+ test("clamps bridged tree wheel scrolling", () => {
293
+ expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
294
+ expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
295
+ expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300)
296
+ })
297
+
298
+ test("wraps autocomplete keyboard navigation", () => {
299
+ expect(nextSuggestionIndex(-1, 1, 4)).toBe(0)
300
+ expect(nextSuggestionIndex(3, 1, 4)).toBe(0)
301
+ expect(nextSuggestionIndex(0, -1, 4)).toBe(3)
302
+ expect(nextSuggestionIndex(0, 1, 0)).toBe(-1)
303
+ })
304
+
305
+ test("returns absolute directories and relative files", () => {
306
+ expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src")
307
+ expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts")
308
+ expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts")
309
+ expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined()
310
+ })
packages/app/src/components/directory-picker-domain.ts ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
2
+ const prefix = parent.replace(/^\/+|\/+$/g, "")
3
+ return nodes.map((node) => {
4
+ const path = prefix ? `${prefix}/${node.name}` : node.name
5
+ return node.type === "directory" ? path + "/" : path
6
+ })
7
+ }
8
+
9
+ export function pickerTreeEntries(
10
+ parent: string,
11
+ nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
12
+ mode: "directory" | "file",
13
+ ) {
14
+ return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes)
15
+ }
16
+
17
+ export function pickerSearchEntries<T extends { type: "file" | "directory" }>(
18
+ nodes: readonly T[],
19
+ mode: "directory" | "file",
20
+ ) {
21
+ return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes]
22
+ }
23
+
24
+ export function pickerMode(mode: "directory" | "file", base?: string) {
25
+ if (mode === "file") {
26
+ return {
27
+ includeFiles: true,
28
+ action: "file" as const,
29
+ entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
30
+ return treeEntries(parent, nodes)
31
+ },
32
+ navigation(path: string) {
33
+ return treePathWithin(base, path) ? path : undefined
34
+ },
35
+ result(root: string, selected: string) {
36
+ return selected || undefined
37
+ },
38
+ selection(root: string, path: string) {
39
+ if (!treePathWithin(base, root)) return
40
+ return selectedTreePath(root, path, "file", base)
41
+ },
42
+ }
43
+ }
44
+ return {
45
+ includeFiles: false,
46
+ action: "directory" as const,
47
+ entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
48
+ return treeEntries(
49
+ parent,
50
+ nodes.filter((node) => node.type === "directory"),
51
+ )
52
+ },
53
+ navigation(path: string) {
54
+ return path
55
+ },
56
+ result(root: string, selected: string, valid = true) {
57
+ if (!valid) return
58
+ return selected || (root ? nativePickerPath(root) : undefined)
59
+ },
60
+ selection(root: string, path: string) {
61
+ return selectedTreePath(root, path, "directory")
62
+ },
63
+ }
64
+ }
65
+
66
+ export function pickerFileSearchQuery(root: string, input: string, home: string) {
67
+ const value = input
68
+ .replace(/\\/g, "/")
69
+ .replace(/^~(?=\/|$)/, home)
70
+ .replace(/\/+$/, "")
71
+ const base = root.replace(/\\/g, "/").replace(/\/+$/, "")
72
+ if (value === base) return ""
73
+ if (value.startsWith(base + "/")) return value.slice(base.length + 1)
74
+ return value
75
+ }
76
+
77
+ export function pickerAbsoluteInput(input: string, home: string, current: string) {
78
+ const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
79
+ const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
80
+ return canonicalPickerPath(absolute)
81
+ }
82
+
83
+ export function treePathWithin(base: string | undefined, path: string) {
84
+ return pickerRelativePath(base, path) !== undefined
85
+ }
86
+
87
+ export function canonicalPickerPath(path: string) {
88
+ const value = normalizePickerDrive(path)
89
+ const root = pickerRoot(value)
90
+ const parts = value.slice(root.length).split("/")
91
+ const resolved = parts.reduce<string[]>((output, part) => {
92
+ if (!part || part === ".") return output
93
+ if (part === "..") {
94
+ output.pop()
95
+ return output
96
+ }
97
+ output.push(part)
98
+ return output
99
+ }, [])
100
+ return joinPickerPath(root, resolved.join("/"))
101
+ }
102
+
103
+ export function pickerRelativePath(base: string | undefined, path: string) {
104
+ if (!base) return
105
+ const rootPath = canonicalPickerPath(base)
106
+ const targetPath = canonicalPickerPath(path)
107
+ const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//")
108
+ const root = insensitive ? rootPath.toLowerCase() : rootPath
109
+ const target = insensitive ? targetPath.toLowerCase() : targetPath
110
+ if (target === root) return ""
111
+ const prefix = root.endsWith("/") ? root : root + "/"
112
+ if (!target.startsWith(prefix)) return
113
+ return targetPath.slice(prefix.length)
114
+ }
115
+
116
+ export function currentPickerSuggestions<T>(result: { query: string; items: readonly T[] } | undefined, query: string) {
117
+ if (result?.query !== query) return []
118
+ return result.items
119
+ }
120
+
121
+ export function preloadTreeDirectories(
122
+ parent: string,
123
+ nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
124
+ ) {
125
+ return treeEntries(
126
+ parent,
127
+ nodes.filter((node) => node.type === "directory"),
128
+ )
129
+ }
130
+
131
+ export function advanceTreePreload(advanced: Set<string>, path: string) {
132
+ if (advanced.has(path)) return false
133
+ advanced.add(path)
134
+ return true
135
+ }
136
+
137
+ export function activeTreeNavigation(request: number, current: number) {
138
+ return request === current
139
+ }
140
+
141
+ export function createPriorityTaskQueue<T>(concurrency: number) {
142
+ type Job = {
143
+ key: string
144
+ priority: "user" | "background"
145
+ promise: Promise<T>
146
+ run: () => void
147
+ }
148
+
149
+ const jobs = new Map<string, Job>()
150
+ const user: Job[] = []
151
+ const background: Job[] = []
152
+ let active = 0
153
+
154
+ const drain = () => {
155
+ while (active < concurrency) {
156
+ const job = user.pop() ?? background.shift()
157
+ if (!job) return
158
+ active++
159
+ job.run()
160
+ }
161
+ }
162
+
163
+ const schedule = (key: string, priority: Job["priority"], task: () => Promise<T>) => {
164
+ const existing = jobs.get(key)
165
+ if (existing) {
166
+ if (priority === "user") promote(key)
167
+ return existing.promise
168
+ }
169
+
170
+ const deferred = Promise.withResolvers<T>()
171
+ const job: Job = {
172
+ key,
173
+ priority,
174
+ promise: deferred.promise,
175
+ run: () => {
176
+ const complete = () => {
177
+ active--
178
+ jobs.delete(key)
179
+ drain()
180
+ }
181
+ Promise.resolve()
182
+ .then(task)
183
+ .then(
184
+ (value) => {
185
+ complete()
186
+ deferred.resolve(value)
187
+ },
188
+ (error) => {
189
+ complete()
190
+ deferred.reject(error)
191
+ },
192
+ )
193
+ },
194
+ }
195
+ jobs.set(key, job)
196
+ ;(priority === "user" ? user : background).push(job)
197
+ drain()
198
+ return job.promise
199
+ }
200
+
201
+ const promote = (key: string) => {
202
+ const job = jobs.get(key)
203
+ if (!job || job.priority === "user") return
204
+ const index = background.indexOf(job)
205
+ if (index === -1) return
206
+ background.splice(index, 1)
207
+ job.priority = "user"
208
+ user.push(job)
209
+ }
210
+
211
+ return { schedule, promote }
212
+ }
213
+
214
+ export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
215
+ return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
216
+ }
217
+
218
+ export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) {
219
+ if (count === 0) return -1
220
+ return (current + delta + count) % count
221
+ }
222
+
223
+ export function absoluteTreePath(root: string, path: string) {
224
+ const base = trimPickerPath(root)
225
+ const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
226
+ if (!relative) return base || "/"
227
+ if (!base || base === "/") return "/" + relative
228
+ if (base.endsWith("/")) return base + relative
229
+ return `${base}/${relative}`
230
+ }
231
+
232
+ export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) {
233
+ const directory = path.endsWith("/")
234
+ if (mode === "file") {
235
+ if (directory) return
236
+ if (!base) return path
237
+ const absolute = absoluteTreePath(root, path)
238
+ return pickerRelativePath(base, absolute)
239
+ }
240
+ return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined
241
+ }
242
+
243
+ export function nativePickerPath(path: string) {
244
+ const value = trimPickerPath(path)
245
+ if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
246
+ return value
247
+ }
248
+ import { getFilename } from "@opencode-ai/core/util/path"
249
+ import fuzzysort from "fuzzysort"
250
+ import { ServerSDK } from "@/context/server-sdk"
251
+
252
+ export function cleanPickerInput(value: string) {
253
+ const first = (value ?? "").split(/\r?\n/)[0] ?? ""
254
+ return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
255
+ }
256
+
257
+ export function normalizePickerPath(input: string) {
258
+ const value = input.replaceAll("\\", "/")
259
+ if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/")
260
+ return value.replace(/\/+/g, "/")
261
+ }
262
+
263
+ export function normalizePickerDrive(input: string) {
264
+ const value = normalizePickerPath(input)
265
+ if (/^[A-Za-z]:$/.test(value)) return value + "/"
266
+ return value
267
+ }
268
+
269
+ export function trimPickerPath(input: string) {
270
+ const value = normalizePickerDrive(input)
271
+ if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
272
+ return value.replace(/\/+$/, "")
273
+ }
274
+
275
+ export function joinPickerPath(base: string | undefined, relative: string) {
276
+ const root = trimPickerPath(base ?? "")
277
+ const path = trimPickerPath(relative).replace(/^\/+/, "")
278
+ if (!root) return path
279
+ if (!path) return root
280
+ if (root.endsWith("/")) return root + path
281
+ return root + "/" + path
282
+ }
283
+
284
+ export function pickerRoot(input: string) {
285
+ const value = normalizePickerDrive(input)
286
+ if (value.startsWith("//")) {
287
+ const [server, share] = value.slice(2).split("/")
288
+ if (server && share) return `//${server}/${share}`
289
+ return "//"
290
+ }
291
+ if (value.startsWith("/")) return "/"
292
+ if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3)
293
+ return ""
294
+ }
295
+
296
+ export function pickerParent(input: string) {
297
+ const value = trimPickerPath(input)
298
+ const root = pickerRoot(value)
299
+ if (value === root) return value
300
+ if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
301
+ const index = value.lastIndexOf("/")
302
+ if (index < root.length) return root
303
+ if (index <= 0) return "/"
304
+ if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
305
+ return value.slice(0, index)
306
+ }
307
+
308
+ function pickerTilde(absolute: string, home: string) {
309
+ const path = trimPickerPath(absolute)
310
+ if (!home) return ""
311
+ const root = trimPickerPath(home)
312
+ if (/^[A-Za-z]:\//.test(root)) return ""
313
+ if (path === root) return "~"
314
+ if (path.startsWith(root + "/")) return "~" + path.slice(root.length)
315
+ return ""
316
+ }
317
+
318
+ export function displayPickerPath(path: string, input: string, home: string) {
319
+ const value = trimPickerPath(path)
320
+ if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\")
321
+ return pickerTilde(value, home) || value
322
+ }
323
+
324
+ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
325
+ const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
326
+ let current = 0
327
+
328
+ const scoped = (value: string) => {
329
+ const raw = normalizePickerDrive(value)
330
+ const root = pickerRoot(raw)
331
+ if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
332
+ const base = args.base()
333
+ if (!base) return
334
+ if (!raw) return { directory: trimPickerPath(base), path: "" }
335
+ const home = args.home()
336
+ if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
337
+ if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
338
+ return { directory: trimPickerPath(base), path: raw }
339
+ }
340
+
341
+ const directories = async (directory: string) => {
342
+ const key = trimPickerPath(directory)
343
+ const existing = cache.get(key)
344
+ if (existing) return existing
345
+ const request = args.sdk.api.file
346
+ .list({ location: { directory: key } })
347
+ .then((result) => result.data)
348
+ .catch(() => [])
349
+ .then((nodes) =>
350
+ nodes
351
+ .filter((node) => node.type === "directory")
352
+ .map((node) => {
353
+ const relative = trimPickerPath(normalizePickerDrive(node.path))
354
+ return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
355
+ }),
356
+ )
357
+ cache.set(key, request)
358
+ return request
359
+ }
360
+
361
+ const match = async (directory: string, query: string, limit: number) => {
362
+ const items = await directories(directory)
363
+ if (!query) return items.slice(0, limit).map((item) => item.absolute)
364
+ return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute)
365
+ }
366
+
367
+ return async (filter: string) => {
368
+ const token = ++current
369
+ const active = () => token === current
370
+ const value = cleanPickerInput(filter)
371
+ const input = scoped(value)
372
+ if (!input) return [] as string[]
373
+ const raw = normalizePickerDrive(value)
374
+ const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
375
+ const query = normalizePickerDrive(input.path)
376
+ if (!pathInput) {
377
+ const results = await args.sdk.api.file
378
+ .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
379
+ .then((result) => result.data.map((entry) => entry.path))
380
+ .catch(() => [])
381
+ if (!active()) return []
382
+ if (results.length) {
383
+ return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
384
+ }
385
+ const fallback = query
386
+ ? await match(input.directory, query, 50)
387
+ : (await directories(input.directory)).map((item) => item.absolute)
388
+ if (!active()) return []
389
+ return fallback
390
+ }
391
+ const segments = query.replace(/^\/+/, "").split("/")
392
+ const head = segments.slice(0, -1).filter((part) => part && part !== ".")
393
+ const tail = segments.at(-1) ?? ""
394
+ let paths = [input.directory]
395
+ for (const part of head) {
396
+ if (!active()) return []
397
+ if (part === "..") {
398
+ paths = paths.map(pickerParent)
399
+ continue
400
+ }
401
+ paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12)
402
+ if (!active() || paths.length === 0) return []
403
+ }
404
+ const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat()))
405
+ if (!active()) return []
406
+ const base = raw.startsWith("~") ? trimPickerPath(input.directory) : ""
407
+ if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50)
408
+ const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase())
409
+ if (!target) return matches.slice(0, 50)
410
+ const children = await match(target, "", 30)
411
+ if (!active()) return []
412
+ return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50)
413
+ }
414
+ }
packages/app/src/components/directory-picker-policy.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { ServerConnection } from "@/context/server"
2
+ import type { Platform } from "@/context/platform"
3
+
4
+ export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
5
+ if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
6
+ return "server" as const
7
+ }
packages/app/src/components/directory-picker.test.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, test } from "bun:test"
2
+ import { directoryPickerKind } from "./directory-picker-policy"
3
+
4
+ const local = {
5
+ type: "sidecar",
6
+ variant: "base",
7
+ http: { url: "http://localhost:4096" },
8
+ } as const
9
+ const remote = {
10
+ type: "ssh",
11
+ host: "example.test",
12
+ http: { url: "http://localhost:4096" },
13
+ } as const
14
+
15
+ describe("directoryPickerKind", () => {
16
+ test("uses the native picker only for local desktop projects", () => {
17
+ expect(directoryPickerKind("desktop", local)).toBe("native")
18
+ expect(directoryPickerKind("desktop", remote)).toBe("server")
19
+ expect(directoryPickerKind("web", local)).toBe("server")
20
+ })
21
+ })
packages/app/src/components/directory-picker.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
2
+ import { ServerConnection } from "@/context/server"
3
+ import { usePlatform } from "@/context/platform"
4
+ import { useSettings } from "@/context/settings"
5
+ import { lazy } from "solid-js"
6
+ import { DialogSelectDirectory } from "./dialog-select-directory"
7
+ import { directoryPickerKind } from "./directory-picker-policy"
8
+
9
+ const DialogSelectDirectoryV2 = lazy(() =>
10
+ import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
11
+ )
12
+
13
+ type DirectoryPickerInput = {
14
+ server: ServerConnection.Any
15
+ title?: string
16
+ multiple?: boolean
17
+ onSelect: (result: string | string[] | null) => void
18
+ }
19
+
20
+ export function useDirectoryPicker() {
21
+ const platform = usePlatform()
22
+ const settings = useSettings()
23
+ const dialog = useDialog()
24
+
25
+ return (input: DirectoryPickerInput) => {
26
+ if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
27
+ void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
28
+ return
29
+ }
30
+
31
+ let selected = false
32
+ const onSelect = (result: string | string[] | null) => {
33
+ selected = result !== null
34
+ input.onSelect(result)
35
+ }
36
+ const cancel = () => {
37
+ if (!selected) input.onSelect(null)
38
+ }
39
+ if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
40
+ dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
41
+ return
42
+ }
43
+ dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
44
+ }
45
+ }
packages/app/src/components/edit-project.ts ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getFilename } from "@opencode-ai/core/util/path"
2
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
3
+ import { useMutation } from "@tanstack/solid-query"
4
+ import { normalizeProjectInfo } from "@/context/global-sync/utils"
5
+ import { createMemo } from "solid-js"
6
+ import { createStore } from "solid-js/store"
7
+ import { useGlobal } from "@/context/global"
8
+ import { type LocalProject } from "@/context/layout"
9
+ import { ServerConnection } from "@/context/server"
10
+
11
+ export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
12
+ const dialog = useDialog()
13
+ const global = useGlobal()
14
+ const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
15
+ const folderName = createMemo(() => getFilename(props.project.worktree))
16
+ const defaultName = createMemo(() => props.project.name || folderName())
17
+ const [store, setStore] = createStore({
18
+ name: defaultName(),
19
+ color: props.project.icon?.color,
20
+ iconOverride: props.project.icon?.override,
21
+ startup: props.project.commands?.start ?? "",
22
+ dragOver: false,
23
+ iconHover: false,
24
+ })
25
+ let iconInput: HTMLInputElement | undefined
26
+
27
+ function selectFile(file: File) {
28
+ if (!file.type.startsWith("image/")) return
29
+ const reader = new FileReader()
30
+ reader.onload = (event) => {
31
+ const result = event.target?.result
32
+ if (typeof result !== "string") return
33
+ setStore("iconOverride", result)
34
+ setStore("iconHover", false)
35
+ }
36
+ reader.readAsDataURL(file)
37
+ }
38
+
39
+ function drop(event: DragEvent) {
40
+ event.preventDefault()
41
+ setStore("dragOver", false)
42
+ const file = event.dataTransfer?.files[0]
43
+ if (file) selectFile(file)
44
+ }
45
+
46
+ function dragOver(event: DragEvent) {
47
+ event.preventDefault()
48
+ setStore("dragOver", true)
49
+ }
50
+
51
+ function dragLeave() {
52
+ setStore("dragOver", false)
53
+ }
54
+
55
+ function inputChange(event: Event) {
56
+ const file = (event.currentTarget as HTMLInputElement).files?.[0]
57
+ if (file) selectFile(file)
58
+ }
59
+
60
+ function iconClick() {
61
+ if (store.iconOverride && store.iconHover) {
62
+ setStore("iconOverride", "")
63
+ return
64
+ }
65
+ iconInput?.click()
66
+ }
67
+
68
+ const save = useMutation(() => ({
69
+ mutationFn: async () => {
70
+ const name = store.name.trim() === folderName() ? "" : store.name.trim()
71
+ const start = store.startup.trim()
72
+
73
+ if (props.project.id && props.project.id !== "global") {
74
+ if ((await serverCtx().sdk.protocol) !== "v1") return
75
+ const project = await serverCtx()
76
+ .sdk.client.project.update({
77
+ projectID: props.project.id,
78
+ directory: props.project.worktree,
79
+ name,
80
+ icon: { color: store.color || "", override: store.iconOverride || "" },
81
+ commands: { start },
82
+ })
83
+ .then((result) => result.data)
84
+ if (!project) return
85
+ // const project = await serverCtx().sdk.api.project.update({
86
+ // projectID: props.project.id,
87
+ // name,
88
+ // icon: { color: store.color || "", override: store.iconOverride || "" },
89
+ // commands: { start },
90
+ // })
91
+ serverCtx().sync.set("project", (items) =>
92
+ items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
93
+ )
94
+ serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
95
+ dialog.close()
96
+ return
97
+ }
98
+
99
+ serverCtx().sync.project.meta(props.project.worktree, {
100
+ name,
101
+ icon: { color: store.color || undefined, override: store.iconOverride || undefined },
102
+ commands: { start: start || undefined },
103
+ })
104
+ dialog.close()
105
+ },
106
+ }))
107
+
108
+ function submit(event: SubmitEvent) {
109
+ event.preventDefault()
110
+ if (save.isPending) return
111
+ save.mutate()
112
+ }
113
+
114
+ return {
115
+ store,
116
+ setStore,
117
+ folderName,
118
+ defaultName,
119
+ save,
120
+ submit,
121
+ drop,
122
+ dragOver,
123
+ dragLeave,
124
+ inputChange,
125
+ iconClick,
126
+ close() {
127
+ dialog.close()
128
+ },
129
+ setIconInput(input: HTMLInputElement) {
130
+ iconInput = input
131
+ },
132
+ }
133
+ }
packages/app/src/components/external-link.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComponentProps, splitProps } from "solid-js"
2
+
3
+ export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
4
+ href: string
5
+ }
6
+
7
+ export function ExternalLink(props: ExternalLinkProps) {
8
+ const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
9
+
10
+ return (
11
+ <a
12
+ href={local.href}
13
+ class={`text-text-strong underline ${local.class ?? ""}`}
14
+ target={local.target ?? "_blank"}
15
+ rel={local.rel ?? "noopener noreferrer"}
16
+ {...rest}
17
+ >
18
+ {local.children}
19
+ </a>
20
+ )
21
+ }
packages/app/src/components/file-tree-v2-model.test.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, test } from "bun:test"
2
+ import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
3
+ import type { FileNode } from "@opencode-ai/sdk/v2"
4
+
5
+ describe("buildFileTreeV2Model", () => {
6
+ test("builds a sorted tree and flattens expanded directories", () => {
7
+ const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
8
+
9
+ expect(model.total).toBe(8)
10
+ expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([
11
+ ["docs", "directory", 0],
12
+ ["docs/guide.md", "file", 1],
13
+ ["src", "directory", 0],
14
+ ["src/lib", "directory", 1],
15
+ ["src/lib/a.ts", "file", 2],
16
+ ["src/lib/b.ts", "file", 2],
17
+ ["src/z.ts", "file", 1],
18
+ ["README.md", "file", 0],
19
+ ])
20
+ })
21
+
22
+ test("skips children of collapsed directories", () => {
23
+ const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
24
+
25
+ expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
26
+ "src",
27
+ "src/lib",
28
+ "src/z.ts",
29
+ ])
30
+ })
31
+
32
+ test("normalizes duplicate and messy paths", () => {
33
+ const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
34
+ const rows = flattenFileTreeV2(model, () => true)
35
+
36
+ expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
37
+ expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
38
+ })
39
+
40
+ test("handles deeply nested paths", () => {
41
+ const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
42
+ const model = buildFileTreeV2Model([file])
43
+
44
+ expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
45
+ })
46
+ })
47
+
48
+ describe("flattenLiveFileTreeV2", () => {
49
+ test("flattens live children using original paths for nested lookups", () => {
50
+ const nodes: Record<string, FileNode[]> = {
51
+ "": [
52
+ { name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
53
+ { name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
54
+ ],
55
+ src: [
56
+ { name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
57
+ { name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
58
+ ],
59
+ "src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
60
+ }
61
+
62
+ expect(
63
+ flattenLiveFileTreeV2(
64
+ (path) => nodes[path] ?? [],
65
+ (path) => path === "src",
66
+ ).map((row) => [row.node.path, row.node.originalPath, row.level]),
67
+ ).toEqual([
68
+ ["src", "src", 0],
69
+ ["src/a.ts", "src/a.ts", 1],
70
+ ["src/lib", "src/lib", 1],
71
+ ["README.md", "README.md", 0],
72
+ ])
73
+ })
74
+ })
packages/app/src/components/file-tree-v2-model.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FileNode } from "@opencode-ai/sdk/v2"
2
+
3
+ export type FileTreeV2Model = {
4
+ children: ReadonlyMap<string, readonly FileTreeV2Node[]>
5
+ total: number
6
+ }
7
+
8
+ export type FileTreeV2Node = FileNode & { originalPath: string }
9
+
10
+ export type FileTreeV2Row = {
11
+ node: FileTreeV2Node
12
+ level: number
13
+ }
14
+
15
+ export function normalizeFileTreeV2Path(value: string) {
16
+ return value
17
+ .replaceAll("\\", "/")
18
+ .replace(/^\/+|\/+$/g, "")
19
+ .replace(/\/{2,}/g, "/")
20
+ }
21
+
22
+ export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model {
23
+ const nodes = new Map<string, FileTreeV2Node>()
24
+
25
+ paths.forEach((value) => {
26
+ const file = normalizeFileTreeV2Path(value)
27
+ if (!file) return
28
+
29
+ const parts = file.split("/")
30
+ parts.forEach((name, index) => {
31
+ const path = parts.slice(0, index + 1).join("/")
32
+ if (nodes.has(path)) return
33
+ nodes.set(path, {
34
+ name,
35
+ path,
36
+ absolute: path,
37
+ type: index === parts.length - 1 ? "file" : "directory",
38
+ ignored: false,
39
+ originalPath: index === parts.length - 1 ? value : path,
40
+ })
41
+ })
42
+ })
43
+
44
+ const children = new Map<string, FileTreeV2Node[]>()
45
+ nodes.forEach((node) => {
46
+ const index = node.path.lastIndexOf("/")
47
+ const parent = index === -1 ? "" : node.path.slice(0, index)
48
+ const list = children.get(parent)
49
+ if (list) list.push(node)
50
+ else children.set(parent, [node])
51
+ })
52
+ children.forEach((nodes) =>
53
+ nodes.sort((a, b) => {
54
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1
55
+ return a.name.localeCompare(b.name)
56
+ }),
57
+ )
58
+
59
+ return { children, total: nodes.size }
60
+ }
61
+
62
+ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) {
63
+ const rows: FileTreeV2Row[] = []
64
+ const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 }))
65
+
66
+ while (stack.length > 0) {
67
+ const row = stack.pop()!
68
+ rows.push(row)
69
+ if (row.node.type !== "directory" || !expanded(row.node.path)) continue
70
+ const children = model.children.get(row.node.path) ?? []
71
+ for (let index = children.length - 1; index >= 0; index--) {
72
+ stack.push({ node: children[index]!, level: row.level + 1 })
73
+ }
74
+ }
75
+
76
+ return rows
77
+ }
78
+
79
+ export function flattenLiveFileTreeV2(
80
+ children: (path: string) => readonly FileNode[],
81
+ expanded: (path: string) => boolean,
82
+ ) {
83
+ const rows: FileTreeV2Row[] = []
84
+ const stack = children("")
85
+ .toReversed()
86
+ .map((node) => ({ node: toLiveNode(node), level: 0 }))
87
+
88
+ while (stack.length > 0) {
89
+ const row = stack.pop()!
90
+ rows.push(row)
91
+ if (row.node.type !== "directory" || !expanded(row.node.path)) continue
92
+ const nested = children(row.node.originalPath)
93
+ for (let index = nested.length - 1; index >= 0; index--) {
94
+ stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
95
+ }
96
+ }
97
+
98
+ return rows
99
+ }
100
+
101
+ function toLiveNode(node: FileNode): FileTreeV2Node {
102
+ return {
103
+ ...node,
104
+ path: normalizeFileTreeV2Path(node.path),
105
+ originalPath: node.path,
106
+ }
107
+ }
packages/app/src/components/file-tree-v2.tsx ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useFile } from "@/context/file"
2
+ import { FileIcon } from "@opencode-ai/ui/file-icon"
3
+ import "@opencode-ai/ui/v2/file-tree-v2.css"
4
+ import {
5
+ createEffect,
6
+ createMemo,
7
+ createSignal,
8
+ For,
9
+ Show,
10
+ splitProps,
11
+ type ComponentProps,
12
+ type ParentProps,
13
+ } from "solid-js"
14
+ import { Dynamic } from "solid-js/web"
15
+ import type { FileNode } from "@opencode-ai/sdk/v2"
16
+ import { Icon } from "@opencode-ai/ui/v2/icon"
17
+ import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
18
+ import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
19
+ import {
20
+ buildFileTreeV2Model,
21
+ flattenFileTreeV2,
22
+ flattenLiveFileTreeV2,
23
+ normalizeFileTreeV2Path,
24
+ type FileTreeV2Node,
25
+ } from "@/components/file-tree-v2-model"
26
+ import { virtualScrollElement } from "@/components/virtual-scroll-element"
27
+
28
+ export type { Kind } from "@/components/file-tree"
29
+
30
+ const INDENT_STEP = 16
31
+
32
+ function rowPaddingStart(level: number, type: FileNode["type"]) {
33
+ if (type === "directory") return 8 + level * INDENT_STEP
34
+ if (level === 0) return 8
35
+ return 8 + level * INDENT_STEP - INDENT_STEP
36
+ }
37
+
38
+ function guideLineStart(level: number) {
39
+ return rowPaddingStart(level, "directory") + 8
40
+ }
41
+
42
+ export const kindLabel = (kind: Kind) => {
43
+ if (kind === "add") return "A"
44
+ if (kind === "del") return "D"
45
+ return "M"
46
+ }
47
+
48
+ export const kindChange = (kind: Kind) => {
49
+ if (kind === "add") return "added"
50
+ if (kind === "del") return "deleted"
51
+ return "modified"
52
+ }
53
+
54
+ const FileTreeNodeV2 = (
55
+ p: ParentProps &
56
+ ComponentProps<"div"> &
57
+ ComponentProps<"button"> & {
58
+ node: FileNode
59
+ level: number
60
+ active?: string
61
+ draggable: boolean
62
+ kinds?: ReadonlyMap<string, Kind>
63
+ as?: "div" | "button"
64
+ },
65
+ ) => {
66
+ const [local, rest] = splitProps(p, [
67
+ "node",
68
+ "level",
69
+ "active",
70
+ "draggable",
71
+ "kinds",
72
+ "as",
73
+ "children",
74
+ "class",
75
+ "classList",
76
+ ])
77
+ const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
78
+
79
+ return (
80
+ <Dynamic
81
+ component={local.as ?? "div"}
82
+ data-slot="file-tree-v2-row"
83
+ data-path={local.node.path}
84
+ data-selected={local.node.path === local.active ? "" : undefined}
85
+ data-ignored={local.node.ignored ? "" : undefined}
86
+ classList={{
87
+ ...local.classList,
88
+ [local.class ?? ""]: !!local.class,
89
+ }}
90
+ style={`padding-inline-start: ${rowPaddingStart(local.level, local.node.type)}px`}
91
+ draggable={local.draggable}
92
+ onDragStart={(event: DragEvent) => {
93
+ if (!local.draggable) return
94
+ event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
95
+ event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
96
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
97
+ withFileDragImage(event)
98
+ }}
99
+ {...rest}
100
+ >
101
+ {local.children}
102
+ <span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
103
+ <bdi dir="auto">{local.node.name}</bdi>
104
+ </span>
105
+ {(() => {
106
+ const value = kind()
107
+ if (!value || local.node.type !== "file") return null
108
+ return (
109
+ <span data-slot="file-tree-v2-change" data-change={kindChange(value)}>
110
+ {kindLabel(value)}
111
+ </span>
112
+ )
113
+ })()}
114
+ </Dynamic>
115
+ )
116
+ }
117
+
118
+ function GuideLines(props: { level: number }) {
119
+ return (
120
+ <For each={Array.from({ length: props.level })}>
121
+ {(_, index) => <div data-slot="file-tree-v2-guide" style={`inset-inline-start: ${guideLineStart(index())}px`} />}
122
+ </For>
123
+ )
124
+ }
125
+
126
+ export default function FileTreeV2(props: {
127
+ active?: string
128
+ allowed?: readonly string[]
129
+ kinds?: ReadonlyMap<string, Kind>
130
+ draggable?: boolean
131
+ onFileClick?: (file: FileNode) => void
132
+ onFileDoubleClick?: (file: FileNode) => void
133
+ }) {
134
+ const file = useFile()
135
+ const live = () => props.allowed === undefined
136
+ const draggable = () => props.draggable ?? true
137
+ const active = () => normalizeFileTreeV2Path(props.active ?? "")
138
+ const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
139
+ const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live()
140
+ const rows = createMemo(() => {
141
+ if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded)
142
+ return flattenFileTreeV2(model()!, expanded)
143
+ })
144
+ const [root, setRoot] = createSignal<HTMLDivElement>()
145
+ const [focused, setFocused] = createSignal<string>()
146
+ const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
147
+ get count() {
148
+ return rows().length
149
+ },
150
+ getScrollElement: () => virtualScrollElement(root()),
151
+ initialRect: { width: 0, height: 600 },
152
+ estimateSize: () => 28,
153
+ gap: 2,
154
+ overscan: 10,
155
+ get getItemKey() {
156
+ const current = rows()
157
+ return (index: number) => current[index]?.node.path ?? index
158
+ },
159
+ rangeExtractor: (range) => {
160
+ const indexes = defaultRangeExtractor(range)
161
+ const path = focused()
162
+ const index = path ? rows().findIndex((row) => row.node.path === path) : -1
163
+ if (index < 0 || indexes.includes(index)) return indexes
164
+ return [...indexes, index].sort((a, b) => a - b)
165
+ },
166
+ })
167
+
168
+ createEffect(() => {
169
+ if (!live()) return
170
+ void file.tree.list("")
171
+ })
172
+
173
+ // Only scroll when the active path changes (or first appears in the tree).
174
+ // Do not re-scroll when expand/collapse reshuffles `rows()`.
175
+ let scrolledActive: string | undefined
176
+ createEffect(() => {
177
+ const path = active()
178
+ if (!path) {
179
+ scrolledActive = undefined
180
+ return
181
+ }
182
+ const index = rows().findIndex((row) => row.node.path === path)
183
+ if (index < 0) return
184
+ if (scrolledActive === path) return
185
+ scrolledActive = path
186
+ queueMicrotask(() => {
187
+ const next = rows().findIndex((row) => row.node.path === path)
188
+ if (next < 0) return
189
+ if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return
190
+ virtualizer.scrollToIndex(next, { align: "auto" })
191
+ })
192
+ })
193
+
194
+ const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => {
195
+ action?.({
196
+ ...node,
197
+ path: node.originalPath,
198
+ absolute: node.originalPath,
199
+ })
200
+ }
201
+
202
+ const toggleDirectory = (path: string, originalPath: string) => {
203
+ if (expanded(path)) {
204
+ file.tree.collapse(originalPath)
205
+ return
206
+ }
207
+ file.tree.expand(originalPath, live() ? undefined : { list: false })
208
+ }
209
+
210
+ const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
211
+ const virtualItemByKey = createMemo(
212
+ () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
213
+ )
214
+ const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
215
+
216
+ return (
217
+ <div
218
+ ref={setRoot}
219
+ data-component="file-tree-v2"
220
+ data-total-rows={live() ? rows().length : model()!.total}
221
+ class="group/file-tree-v2"
222
+ style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
223
+ >
224
+ <For each={virtualRowKeys()}>
225
+ {(key) => (
226
+ <Show when={virtualItemByKey().get(key)}>
227
+ {(item) => (
228
+ <div
229
+ style={{
230
+ position: "absolute",
231
+ top: "0",
232
+ "inset-inline-start": "0",
233
+ width: "100%",
234
+ height: `${item().size}px`,
235
+ transform: `translateY(${item().start}px)`,
236
+ }}
237
+ >
238
+ <Show when={rowByKey().get(key as string)}>
239
+ {(row) => (
240
+ <Show
241
+ when={row().node.type === "directory"}
242
+ fallback={
243
+ <FileTreeNodeV2
244
+ node={row().node}
245
+ level={row().level}
246
+ active={active()}
247
+ draggable={draggable()}
248
+ kinds={props.kinds}
249
+ as="button"
250
+ type="button"
251
+ class="relative"
252
+ onFocus={() => setFocused(row().node.path)}
253
+ onBlur={() => setFocused(undefined)}
254
+ onClick={() => selectFile(row().node, props.onFileClick)}
255
+ onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
256
+ >
257
+ <GuideLines level={row().level} />
258
+ <Show when={row().level > 0}>
259
+ <div class="w-4 shrink-0" />
260
+ </Show>
261
+ <span class="filetree-iconpair size-4">
262
+ <FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
263
+ <FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
264
+ </span>
265
+ </FileTreeNodeV2>
266
+ }
267
+ >
268
+ <FileTreeNodeV2
269
+ node={row().node}
270
+ level={row().level}
271
+ active={active()}
272
+ draggable={draggable()}
273
+ kinds={props.kinds}
274
+ as="button"
275
+ type="button"
276
+ class="relative"
277
+ onFocus={() => setFocused(row().node.path)}
278
+ onBlur={() => setFocused(undefined)}
279
+ aria-expanded={expanded(row().node.path)}
280
+ onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
281
+ >
282
+ <GuideLines level={row().level} />
283
+ <div
284
+ data-slot="file-tree-v2-chevron"
285
+ data-expanded={expanded(row().node.path) ? "" : undefined}
286
+ class="size-4 flex items-center justify-center"
287
+ >
288
+ <Icon name="chevron-down" />
289
+ </div>
290
+ </FileTreeNodeV2>
291
+ </Show>
292
+ )}
293
+ </Show>
294
+ </div>
295
+ )}
296
+ </Show>
297
+ )}
298
+ </For>
299
+ </div>
300
+ )
301
+ }
packages/app/src/components/file-tree.test.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeAll, describe, expect, mock, test } from "bun:test"
2
+
3
+ let shouldListRoot: typeof import("./file-tree").shouldListRoot
4
+ let shouldListExpanded: typeof import("./file-tree").shouldListExpanded
5
+ let dirsToExpand: typeof import("./file-tree").dirsToExpand
6
+
7
+ beforeAll(async () => {
8
+ mock.module("@solidjs/router", () => ({
9
+ useNavigate: () => () => undefined,
10
+ useParams: () => ({}),
11
+ useLocation: () => ({}),
12
+ useSearchParams: () => [{}, () => undefined],
13
+ }))
14
+ mock.module("@/context/file", () => ({
15
+ useFile: () => ({
16
+ tree: {
17
+ state: () => undefined,
18
+ list: () => Promise.resolve(),
19
+ children: () => [],
20
+ expand: () => {},
21
+ collapse: () => {},
22
+ },
23
+ }),
24
+ }))
25
+ mock.module("@opencode-ai/ui/collapsible", () => ({
26
+ Collapsible: {
27
+ Trigger: (props: { children?: unknown }) => props.children,
28
+ Content: (props: { children?: unknown }) => props.children,
29
+ },
30
+ }))
31
+ mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null }))
32
+ mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null }))
33
+ mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children }))
34
+ const mod = await import("./file-tree")
35
+ shouldListRoot = mod.shouldListRoot
36
+ shouldListExpanded = mod.shouldListExpanded
37
+ dirsToExpand = mod.dirsToExpand
38
+ })
39
+
40
+ describe("file tree fetch discipline", () => {
41
+ test("root lists on mount unless already loaded or loading", () => {
42
+ expect(shouldListRoot({ level: 0 })).toBe(true)
43
+ expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
44
+ expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
45
+ expect(shouldListRoot({ level: 1 })).toBe(false)
46
+ })
47
+
48
+ test("nested dirs list only when expanded and stale", () => {
49
+ expect(shouldListExpanded({ level: 1 })).toBe(false)
50
+ expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false)
51
+ expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true)
52
+ expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false)
53
+ expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false)
54
+ expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false)
55
+ })
56
+
57
+ test("allowed auto-expand picks only collapsed dirs", () => {
58
+ const expanded = new Set<string>()
59
+ const filter = { dirs: new Set(["src", "src/components"]) }
60
+
61
+ const first = dirsToExpand({
62
+ level: 0,
63
+ filter,
64
+ expanded: (dir) => expanded.has(dir),
65
+ })
66
+
67
+ expect(first).toEqual(["src", "src/components"])
68
+
69
+ for (const dir of first) expanded.add(dir)
70
+
71
+ const second = dirsToExpand({
72
+ level: 0,
73
+ filter,
74
+ expanded: (dir) => expanded.has(dir),
75
+ })
76
+
77
+ expect(second).toEqual([])
78
+ expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([])
79
+ })
80
+ })
packages/app/src/components/file-tree.tsx ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useFile } from "@/context/file"
2
+ import { encodeFilePath } from "@/context/file/path"
3
+ import { Collapsible } from "@opencode-ai/ui/collapsible"
4
+ import { FileIcon } from "@opencode-ai/ui/file-icon"
5
+ import { Icon } from "@opencode-ai/ui/icon"
6
+ import {
7
+ createEffect,
8
+ createMemo,
9
+ For,
10
+ Match,
11
+ on,
12
+ Show,
13
+ splitProps,
14
+ Switch,
15
+ untrack,
16
+ type ComponentProps,
17
+ type ParentProps,
18
+ } from "solid-js"
19
+ import { Dynamic } from "solid-js/web"
20
+ import type { FileNode } from "@opencode-ai/sdk/v2"
21
+
22
+ const MAX_DEPTH = 128
23
+
24
+ export function pathToFileUrl(filepath: string): string {
25
+ return `file://${encodeFilePath(filepath)}`
26
+ }
27
+
28
+ export type Kind = "add" | "del" | "mix"
29
+
30
+ export type Filter = {
31
+ files: Set<string>
32
+ dirs: Set<string>
33
+ }
34
+
35
+ export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
36
+ if (input.level !== 0) return false
37
+ if (input.dir?.loaded) return false
38
+ if (input.dir?.loading) return false
39
+ return true
40
+ }
41
+
42
+ export function shouldListExpanded(input: {
43
+ level: number
44
+ dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean }
45
+ }) {
46
+ if (input.level === 0) return false
47
+ if (!input.dir?.expanded) return false
48
+ if (input.dir.loaded) return false
49
+ if (input.dir.loading) return false
50
+ return true
51
+ }
52
+
53
+ export function dirsToExpand(input: {
54
+ level: number
55
+ filter?: { dirs: Set<string> }
56
+ expanded: (dir: string) => boolean
57
+ }) {
58
+ if (input.level !== 0) return []
59
+ if (!input.filter) return []
60
+ return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
61
+ }
62
+
63
+ const kindLabel = (kind: Kind) => {
64
+ if (kind === "add") return "A"
65
+ if (kind === "del") return "D"
66
+ return "M"
67
+ }
68
+
69
+ const kindTextColor = (kind: Kind) => {
70
+ if (kind === "add") return "color: var(--icon-diff-add-base)"
71
+ if (kind === "del") return "color: var(--icon-diff-delete-base)"
72
+ return "color: var(--icon-diff-modified-base)"
73
+ }
74
+
75
+ const kindDotColor = (kind: Kind) => {
76
+ if (kind === "add") return "background-color: var(--icon-diff-add-base)"
77
+ if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
78
+ return "background-color: var(--icon-diff-modified-base)"
79
+ }
80
+
81
+ export const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
82
+ const kind = kinds?.get(node.path)
83
+ if (!kind) return
84
+ if (!marks?.has(node.path)) return
85
+ return kind
86
+ }
87
+
88
+ const buildDragImage = (target: HTMLElement) => {
89
+ const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
90
+ const text = target.querySelector("span")
91
+ if (!icon || !text) return
92
+
93
+ const image = document.createElement("div")
94
+ image.className =
95
+ "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
96
+ image.style.position = "absolute"
97
+ image.style.top = "-1000px"
98
+ image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
99
+ return image
100
+ }
101
+
102
+ export const withFileDragImage = (event: DragEvent) => {
103
+ const image = buildDragImage(event.currentTarget as HTMLElement)
104
+ if (!image) return
105
+ document.body.appendChild(image)
106
+ event.dataTransfer?.setDragImage(image, 0, 12)
107
+ setTimeout(() => document.body.removeChild(image), 0)
108
+ }
109
+
110
+ const FileTreeNode = (
111
+ p: ParentProps &
112
+ ComponentProps<"div"> &
113
+ ComponentProps<"button"> & {
114
+ node: FileNode
115
+ level: number
116
+ active?: string
117
+ nodeClass?: string
118
+ draggable: boolean
119
+ kinds?: ReadonlyMap<string, Kind>
120
+ marks?: Set<string>
121
+ as?: "div" | "button"
122
+ },
123
+ ) => {
124
+ const [local, rest] = splitProps(p, [
125
+ "node",
126
+ "level",
127
+ "active",
128
+ "nodeClass",
129
+ "draggable",
130
+ "kinds",
131
+ "marks",
132
+ "as",
133
+ "children",
134
+ "class",
135
+ "classList",
136
+ ])
137
+ const kind = () => visibleKind(local.node, local.kinds, local.marks)
138
+ const active = () => !!kind() && !local.node.ignored
139
+ const color = () => {
140
+ const value = kind()
141
+ if (!value) return
142
+ return kindTextColor(value)
143
+ }
144
+
145
+ return (
146
+ <Dynamic
147
+ component={local.as ?? "div"}
148
+ classList={{
149
+ "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-start hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
150
+ "bg-surface-base-active": local.node.path === local.active,
151
+ ...local.classList,
152
+ [local.class ?? ""]: !!local.class,
153
+ [local.nodeClass ?? ""]: !!local.nodeClass,
154
+ }}
155
+ style={`padding-inline-start: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
156
+ draggable={local.draggable}
157
+ onDragStart={(event: DragEvent) => {
158
+ if (!local.draggable) return
159
+ event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
160
+ event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
161
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
162
+ withFileDragImage(event)
163
+ }}
164
+ {...rest}
165
+ >
166
+ {local.children}
167
+ <span
168
+ classList={{
169
+ "flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
170
+ "text-text-weaker": local.node.ignored,
171
+ "text-text-weak": !local.node.ignored && !active(),
172
+ }}
173
+ style={active() ? color() : undefined}
174
+ >
175
+ {local.node.name}
176
+ </span>
177
+ {(() => {
178
+ const value = kind()
179
+ if (!value) return null
180
+ if (local.node.type === "file") {
181
+ return (
182
+ <span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
183
+ {kindLabel(value)}
184
+ </span>
185
+ )
186
+ }
187
+ return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
188
+ })()}
189
+ </Dynamic>
190
+ )
191
+ }
192
+
193
+ export default function FileTree(props: {
194
+ path: string
195
+ class?: string
196
+ nodeClass?: string
197
+ active?: string
198
+ level?: number
199
+ allowed?: readonly string[]
200
+ modified?: readonly string[]
201
+ kinds?: ReadonlyMap<string, Kind>
202
+ draggable?: boolean
203
+ onFileClick?: (file: FileNode) => void
204
+ onFileDoubleClick?: (file: FileNode) => void
205
+
206
+ _filter?: Filter
207
+ _marks?: Set<string>
208
+ _deeps?: Map<string, number>
209
+ _kinds?: ReadonlyMap<string, Kind>
210
+ _chain?: readonly string[]
211
+ }) {
212
+ const file = useFile()
213
+ const level = props.level ?? 0
214
+ const draggable = () => props.draggable ?? true
215
+
216
+ const key = (p: string) =>
217
+ file
218
+ .normalize(p)
219
+ .replace(/[\\/]+$/, "")
220
+ .replaceAll("\\", "/")
221
+ const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
222
+
223
+ const filter = createMemo(() => {
224
+ if (props._filter) return props._filter
225
+
226
+ const allowed = props.allowed
227
+ if (!allowed) return
228
+
229
+ const files = new Set(allowed)
230
+ const dirs = new Set<string>()
231
+
232
+ for (const item of allowed) {
233
+ const parts = item.split("/")
234
+ const parents = parts.slice(0, -1)
235
+ for (const [idx] of parents.entries()) {
236
+ const dir = parents.slice(0, idx + 1).join("/")
237
+ if (dir) dirs.add(dir)
238
+ }
239
+ }
240
+
241
+ return { files, dirs }
242
+ })
243
+
244
+ const marks = createMemo(() => {
245
+ if (props._marks) return props._marks
246
+
247
+ const out = new Set<string>()
248
+ for (const item of props.modified ?? []) out.add(item)
249
+ for (const item of props.kinds?.keys() ?? []) out.add(item)
250
+ if (out.size === 0) return
251
+ return out
252
+ })
253
+
254
+ const kinds = createMemo(() => {
255
+ if (props._kinds) return props._kinds
256
+ return props.kinds
257
+ })
258
+
259
+ const deeps = createMemo(() => {
260
+ if (props._deeps) return props._deeps
261
+
262
+ const out = new Map<string, number>()
263
+
264
+ const root = props.path
265
+ if (!(file.tree.state(root)?.expanded ?? false)) return out
266
+
267
+ const seen = new Set<string>()
268
+ const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
269
+
270
+ const push = (dir: string, lvl: number) => {
271
+ const id = key(dir)
272
+ if (seen.has(id)) return
273
+ seen.add(id)
274
+
275
+ const kids = file.tree
276
+ .children(dir)
277
+ .filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
278
+ .map((node) => node.path)
279
+
280
+ stack.push({ dir, lvl, i: 0, kids, max: lvl })
281
+ }
282
+
283
+ push(root, level - 1)
284
+
285
+ while (stack.length > 0) {
286
+ const top = stack[stack.length - 1]!
287
+
288
+ if (top.i < top.kids.length) {
289
+ const next = top.kids[top.i]!
290
+ top.i++
291
+ push(next, top.lvl + 1)
292
+ continue
293
+ }
294
+
295
+ out.set(top.dir, top.max)
296
+ stack.pop()
297
+
298
+ const parent = stack[stack.length - 1]
299
+ if (!parent) continue
300
+ parent.max = Math.max(parent.max, top.max)
301
+ }
302
+
303
+ return out
304
+ })
305
+
306
+ createEffect(() => {
307
+ const current = filter()
308
+ const dirs = dirsToExpand({
309
+ level,
310
+ filter: current,
311
+ expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
312
+ })
313
+ for (const dir of dirs) file.tree.expand(dir)
314
+ })
315
+
316
+ createEffect(
317
+ on(
318
+ () => props.path,
319
+ (path) => {
320
+ const dir = untrack(() => file.tree.state(path))
321
+ if (!shouldListRoot({ level, dir })) return
322
+ void file.tree.list(path)
323
+ },
324
+ { defer: false },
325
+ ),
326
+ )
327
+
328
+ const nodes = createMemo(() => {
329
+ const nodes = file.tree.children(props.path)
330
+ const current = filter()
331
+ if (!current) return nodes
332
+
333
+ const parent = (path: string) => {
334
+ const idx = path.lastIndexOf("/")
335
+ if (idx === -1) return ""
336
+ return path.slice(0, idx)
337
+ }
338
+
339
+ const leaf = (path: string) => {
340
+ const idx = path.lastIndexOf("/")
341
+ return idx === -1 ? path : path.slice(idx + 1)
342
+ }
343
+
344
+ const out = nodes.filter((node) => {
345
+ if (node.type === "file") return current.files.has(node.path)
346
+ return current.dirs.has(node.path)
347
+ })
348
+
349
+ const seen = new Set(out.map((node) => node.path))
350
+
351
+ for (const dir of current.dirs) {
352
+ if (parent(dir) !== props.path) continue
353
+ if (seen.has(dir)) continue
354
+ out.push({
355
+ name: leaf(dir),
356
+ path: dir,
357
+ absolute: dir,
358
+ type: "directory",
359
+ ignored: false,
360
+ })
361
+ seen.add(dir)
362
+ }
363
+
364
+ for (const item of current.files) {
365
+ if (parent(item) !== props.path) continue
366
+ if (seen.has(item)) continue
367
+ out.push({
368
+ name: leaf(item),
369
+ path: item,
370
+ absolute: item,
371
+ type: "file",
372
+ ignored: false,
373
+ })
374
+ seen.add(item)
375
+ }
376
+
377
+ out.sort((a, b) => {
378
+ if (a.type !== b.type) {
379
+ return a.type === "directory" ? -1 : 1
380
+ }
381
+ return a.name.localeCompare(b.name)
382
+ })
383
+
384
+ return out
385
+ })
386
+
387
+ return (
388
+ <div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
389
+ <For each={nodes()}>
390
+ {(node) => {
391
+ const expanded = () => file.tree.state(node.path)?.expanded ?? false
392
+ const deep = () => deeps().get(node.path) ?? -1
393
+ const kind = () => visibleKind(node, kinds(), marks())
394
+ const active = () => !!kind() && !node.ignored
395
+
396
+ return (
397
+ <Switch>
398
+ <Match when={node.type === "directory"}>
399
+ <Collapsible
400
+ variant="ghost"
401
+ class="w-full"
402
+ data-scope="filetree"
403
+ forceMount={false}
404
+ open={expanded()}
405
+ onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
406
+ >
407
+ <Collapsible.Trigger>
408
+ <FileTreeNode
409
+ node={node}
410
+ level={level}
411
+ active={props.active}
412
+ nodeClass={props.nodeClass}
413
+ draggable={draggable()}
414
+ kinds={kinds()}
415
+ marks={marks()}
416
+ >
417
+ <div class="size-4 flex items-center justify-center text-icon-weak">
418
+ <Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
419
+ </div>
420
+ </FileTreeNode>
421
+ </Collapsible.Trigger>
422
+ <Collapsible.Content class="relative pt-0.5">
423
+ <div
424
+ classList={{
425
+ "absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
426
+ "group-hover/filetree:opacity-100": expanded() && deep() === level,
427
+ "group-hover/filetree:opacity-50": !(expanded() && deep() === level),
428
+ }}
429
+ style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
430
+ />
431
+ <Show
432
+ when={level < MAX_DEPTH && !chain.includes(key(node.path))}
433
+ fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
434
+ >
435
+ <FileTree
436
+ path={node.path}
437
+ level={level + 1}
438
+ allowed={props.allowed}
439
+ modified={props.modified}
440
+ kinds={props.kinds}
441
+ active={props.active}
442
+ draggable={props.draggable}
443
+ onFileClick={props.onFileClick}
444
+ onFileDoubleClick={props.onFileDoubleClick}
445
+ _filter={filter()}
446
+ _marks={marks()}
447
+ _deeps={deeps()}
448
+ _kinds={kinds()}
449
+ _chain={chain}
450
+ />
451
+ </Show>
452
+ </Collapsible.Content>
453
+ </Collapsible>
454
+ </Match>
455
+ <Match when={node.type === "file"}>
456
+ <FileTreeNode
457
+ node={node}
458
+ level={level}
459
+ active={props.active}
460
+ nodeClass={props.nodeClass}
461
+ draggable={draggable()}
462
+ kinds={kinds()}
463
+ marks={marks()}
464
+ as="button"
465
+ type="button"
466
+ onClick={() => props.onFileClick?.(node)}
467
+ onDblClick={() => props.onFileDoubleClick?.(node)}
468
+ >
469
+ <div class="w-4 shrink-0" />
470
+ <Switch>
471
+ <Match when={node.ignored}>
472
+ <FileIcon
473
+ node={node}
474
+ class="size-4 filetree-icon filetree-icon--mono"
475
+ style="color: var(--icon-weak-base)"
476
+ mono
477
+ />
478
+ </Match>
479
+ <Match when={active()}>
480
+ <FileIcon
481
+ node={node}
482
+ class="size-4 filetree-icon filetree-icon--mono"
483
+ style={kindTextColor(kind()!)}
484
+ mono
485
+ />
486
+ </Match>
487
+ <Match when={!node.ignored}>
488
+ <span class="filetree-iconpair size-4">
489
+ <FileIcon
490
+ node={node}
491
+ class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
492
+ />
493
+ <FileIcon
494
+ node={node}
495
+ class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
496
+ mono
497
+ />
498
+ </span>
499
+ </Match>
500
+ </Switch>
501
+ </FileTreeNode>
502
+ </Match>
503
+ </Switch>
504
+ )
505
+ }}
506
+ </For>
507
+ </div>
508
+ )
509
+ }
packages/app/src/components/help-button.tsx ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
2
+ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
3
+ import { isRTL } from "@kobalte/core/i18n"
4
+ import { createSignal, Show } from "solid-js"
5
+ import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
6
+ import { usePlatform } from "@/context/platform"
7
+ import { useSettings } from "@/context/settings"
8
+ import { useLanguage } from "@/context/language"
9
+ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
10
+ import homeImage from "@/assets/help/home.png"
11
+ import tabsImage from "@/assets/help/tabs.png"
12
+
13
+ // TODO: wire to changelog / seen-state when available
14
+ const showPopover = () => true
15
+
16
+ // can remove this after the tabs rollout has been out for a while
17
+ export function TabsInfoPopup() {
18
+ const settings = useSettings()
19
+ const platform = usePlatform()
20
+ const language = useLanguage()
21
+ const [drawerOpen, setDrawerOpen] = createSignal(false)
22
+ const windows = () => platform.platform === "desktop" && platform.os === "windows"
23
+ const rtl = () => isRTL(language.intl())
24
+
25
+ return (
26
+ <Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side={rtl() ? "left" : "right"}>
27
+ <Show when={settings.general.shouldDisplayTabsToast()}>
28
+ <div
29
+ class="fixed bottom-5 end-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
30
+ aria-label={language.t("help.tabs.toast.ariaLabel")}
31
+ >
32
+ <button
33
+ type="button"
34
+ aria-label={language.t("help.tabs.toast.dismiss")}
35
+ class="absolute top-3 end-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
36
+ onClick={settings.general.dismissTabsToast}
37
+ >
38
+ <svg
39
+ width="16"
40
+ height="16"
41
+ viewBox="0 0 16 16"
42
+ fill="none"
43
+ xmlns="http://www.w3.org/2000/svg"
44
+ aria-hidden="true"
45
+ >
46
+ <path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
47
+ </svg>
48
+ </button>
49
+ <button
50
+ type="button"
51
+ class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-start"
52
+ onClick={() => {
53
+ settings.general.dismissTabsToast()
54
+ setDrawerOpen(true)
55
+ }}
56
+ >
57
+ <video
58
+ src={introducingTabsVideo}
59
+ class="absolute inset-0 h-full w-full object-cover"
60
+ loop
61
+ muted
62
+ autoplay
63
+ playsinline
64
+ aria-hidden="true"
65
+ onContextMenu={(event) => event.preventDefault()}
66
+ />
67
+ <div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
68
+ <p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
69
+ {language.t("help.tabs.title")}
70
+ </p>
71
+ <p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
72
+ {language.t("help.tabs.description")}
73
+ </p>
74
+ </div>
75
+ </button>
76
+ </div>
77
+ </Show>
78
+ <DrawerContent
79
+ style={
80
+ windows()
81
+ ? {
82
+ top: "0",
83
+ bottom: "0",
84
+ "inset-inline-end": "0",
85
+ "inset-inline-start": "auto",
86
+ "max-height": "100vh",
87
+ "max-width": "100vw",
88
+ "border-radius": "0",
89
+ }
90
+ : undefined
91
+ }
92
+ >
93
+ <Show when={windows()}>
94
+ <DrawerClose
95
+ as={IconButtonV2}
96
+ type="button"
97
+ size="small"
98
+ variant="neutral"
99
+ aria-label={language.t("common.close")}
100
+ icon={<IconV2 name="xmark-small" />}
101
+ class="absolute top-[10px] start-[-36px]"
102
+ />
103
+ </Show>
104
+ <div
105
+ class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
106
+ classList={{
107
+ "h-[40px] px-4": windows(),
108
+ "h-[52px] p-4": !windows(),
109
+ }}
110
+ >
111
+ <p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
112
+ {language.t("help.tabs.date")}
113
+ </p>
114
+ <Show when={!windows()}>
115
+ <DrawerClose
116
+ as={IconButtonV2}
117
+ type="button"
118
+ size="small"
119
+ variant="ghost-muted"
120
+ aria-label={language.t("common.close")}
121
+ icon={<IconV2 name="xmark-small" />}
122
+ />
123
+ </Show>
124
+ </div>
125
+ <div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
126
+ <p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
127
+ {language.t("help.tabs.title")}
128
+ </p>
129
+ <div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
130
+ <p>{language.t("help.tabs.introduction")}</p>
131
+ <img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
132
+ <p>{language.t("help.tabs.sessions")}</p>
133
+ <p>{language.t("help.tabs.organize")}</p>
134
+ <p>{language.t("help.tabs.home")}</p>
135
+ <img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
136
+ <p>{language.t("help.tabs.persistence")}</p>
137
+ <p>{language.t("help.tabs.worktrees")}</p>
138
+ </div>
139
+ </div>
140
+ </DrawerContent>
141
+ </Drawer>
142
+ )
143
+ }
packages/app/src/components/model-tooltip.tsx ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Show, type Component, type JSX } 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
+ function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
27
+ return (
28
+ <div class="flex min-w-0 items-center gap-4">
29
+ <span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
30
+ <span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
31
+ </div>
32
+ )
33
+ }
34
+
35
+ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = (
36
+ props,
37
+ ) => {
38
+ const language = useLanguage()
39
+ const sourceName = (model: ModelInfo) => {
40
+ const value = `${model.id} ${model.name}`.toLowerCase()
41
+
42
+ if (/claude|anthropic/.test(value)) return language.t("model.provider.anthropic")
43
+ if (/gpt|o[1-4]|codex|openai/.test(value)) return language.t("model.provider.openai")
44
+ if (/gemini|palm|bard|google/.test(value)) return language.t("model.provider.google")
45
+ if (/grok|xai/.test(value)) return language.t("model.provider.xai")
46
+ if (/llama|meta/.test(value)) return language.t("model.provider.meta")
47
+
48
+ return model.provider.name
49
+ }
50
+ const inputLabel = (value: string) => {
51
+ if (value === "text") return language.t("model.input.text")
52
+ if (value === "image") return language.t("model.input.image")
53
+ if (value === "audio") return language.t("model.input.audio")
54
+ if (value === "video") return language.t("model.input.video")
55
+ if (value === "pdf") return language.t("model.input.pdf")
56
+ return value
57
+ }
58
+ const title = () => {
59
+ const tags: Array<string> = []
60
+ if (props.latest) tags.push(language.t("model.tag.latest"))
61
+ if (props.free) tags.push(language.t("model.tag.free"))
62
+ const suffix = tags.length ? ` (${tags.join(", ")})` : ""
63
+ return `${sourceName(props.model)} ${props.model.name}${suffix}`
64
+ }
65
+ const name = () => {
66
+ const tags: Array<string> = []
67
+ if (props.latest) tags.push(language.t("model.tag.latest"))
68
+ if (props.free) tags.push(language.t("model.tag.free"))
69
+ const suffix = tags.length ? ` (${tags.join(", ")})` : ""
70
+ return `${props.model.name}${suffix}`
71
+ }
72
+ const inputs = () => {
73
+ if (props.model.capabilities) {
74
+ const input = props.model.capabilities.input
75
+ const order: Array<InputKey> = ["text", "image", "audio", "video", "pdf"]
76
+ const entries = order.filter((key) => input[key]).map((key) => inputLabel(key))
77
+ return entries.length ? entries.join(", ") : undefined
78
+ }
79
+ const raw = props.model.modalities?.input
80
+ if (!raw) return
81
+ const entries = raw.map((value) => inputLabel(value))
82
+ return entries.length ? entries.join(", ") : undefined
83
+ }
84
+ const reasoning = () => {
85
+ if (props.model.capabilities)
86
+ return props.model.capabilities.reasoning
87
+ ? language.t("model.tooltip.reasoning.allowed")
88
+ : language.t("model.tooltip.reasoning.none")
89
+ return props.model.reasoning
90
+ ? language.t("model.tooltip.reasoning.allowed")
91
+ : language.t("model.tooltip.reasoning.none")
92
+ }
93
+ const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
94
+ const contextLimit = () => props.model.limit.context.toLocaleString(language.intl())
95
+
96
+ if (props.v2) {
97
+ return (
98
+ <div class="flex w-[180px] flex-col gap-2">
99
+ <ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
100
+ <ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
101
+ <Show when={inputs()}>
102
+ {(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
103
+ </Show>
104
+ <ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
105
+ <ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
106
+ </div>
107
+ )
108
+ }
109
+
110
+ return (
111
+ <div class="flex flex-col gap-1 py-1">
112
+ <div class="text-13-medium">{title()}</div>
113
+ <Show when={inputs()}>
114
+ {(value) => (
115
+ <div class="text-12-regular text-text-invert-base">
116
+ {language.t("model.tooltip.allows", { inputs: value() })}
117
+ </div>
118
+ )}
119
+ </Show>
120
+ <div class="text-12-regular text-text-invert-base">{reasoning()}</div>
121
+ <div class="text-12-regular text-text-invert-base">{context()}</div>
122
+ </div>
123
+ )
124
+ }
packages/app/src/components/pierre-tree.test.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import { FileTree, type FileTreeDirectoryHandle } from "@pierre/trees"
3
+
4
+ test("reports directory expansion changes", () => {
5
+ const changes: Array<{ path: string; expanded: boolean }> = []
6
+ const tree = new FileTree({
7
+ paths: ["src/"],
8
+ onExpansionChange: (change) => changes.push(change),
9
+ })
10
+
11
+ const src = tree.getItem("src/")
12
+ if (!src || !src.isDirectory()) throw new Error("Expected src to be a directory")
13
+ const directory = src as FileTreeDirectoryHandle
14
+
15
+ directory.expand()
16
+ directory.collapse()
17
+
18
+ expect(changes).toEqual([
19
+ { path: "src/", expanded: true },
20
+ { path: "src/", expanded: false },
21
+ ])
22
+ tree.cleanUp()
23
+ })
packages/app/src/components/prompt-input-v2.tsx ADDED
@@ -0,0 +1,589 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ImagePreview } from "@opencode-ai/ui/image-preview"
2
+ import { useDialog } from "@opencode-ai/ui/context/dialog"
3
+ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
4
+ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
5
+ import { Icon } from "@opencode-ai/ui/v2/icon"
6
+ import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
7
+ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
8
+ import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
9
+ import { createEffect, createMemo, on, Show } from "solid-js"
10
+ import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
11
+ import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
12
+ import type { PromptInputProps } from "@/components/prompt-input/contracts"
13
+ import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
14
+ import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
15
+ import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
16
+ import { createPromptSubmit } from "@/components/prompt-input/submit"
17
+ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
18
+ import { useComments } from "@/context/comments"
19
+ import { useCommand } from "@/context/command"
20
+ import { useLanguage } from "@/context/language"
21
+ import { useLayout } from "@/context/layout"
22
+ import { usePermission } from "@/context/permission"
23
+ import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
24
+ import { usePlatform } from "@/context/platform"
25
+ import { useSDK } from "@/context/sdk"
26
+ import { useSync } from "@/context/sync"
27
+ import { createSessionTabs } from "@/pages/session/helpers"
28
+ import { showToast } from "@/utils/toast"
29
+ import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
30
+ import {
31
+ createPromptInputV2Controller,
32
+ createPromptInputV2State,
33
+ type PromptInputV2Interaction,
34
+ } from "@opencode-ai/session-ui/v2/prompt-input/interaction"
35
+
36
+ export type PromptInputV2ComposerProps = {
37
+ class?: string
38
+ controller: PromptInputV2ComposerController
39
+ borderUnderlay?: boolean
40
+ }
41
+
42
+ export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
43
+ export type PromptInputV2ComposerController = PromptInputV2Interaction & {
44
+ readonly model: PromptInputProps["controls"]["model"]
45
+ }
46
+
47
+ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
48
+ const dialog = useDialog()
49
+ const command = useCommand()
50
+ const language = useLanguage()
51
+
52
+ return (
53
+ <div class="flex flex-col gap-3">
54
+ <PromptInputV2
55
+ controller={props.controller}
56
+ borderUnderlay={props.borderUnderlay}
57
+ class={props.class}
58
+ variantControlVisible={!props.controller.model.loading}
59
+ attachKeybind={command.keybindParts("file.attach")}
60
+ attachShortcut={command.keybind("file.attach")}
61
+ modelControl={
62
+ <PromptInputV2ModelControl
63
+ loading={props.controller.model.loading}
64
+ paid={props.controller.model.paid}
65
+ title={language.t("command.model.choose")}
66
+ keybind={command.keybindParts("model.choose")}
67
+ model={props.controller.model.selection}
68
+ providerID={props.controller.model.selection.current()?.provider?.id}
69
+ modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
70
+ onClose={props.controller.restoreFocus}
71
+ onUnpaidClick={() =>
72
+ dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
73
+ }
74
+ />
75
+ }
76
+ />
77
+ </div>
78
+ )
79
+ }
80
+
81
+ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
82
+ const sdk = useSDK()
83
+ const sync = useSync()
84
+ const files = useFile()
85
+ const layout = useLayout()
86
+ const comments = useComments()
87
+ const dialog = useDialog()
88
+ const command = useCommand()
89
+ const permission = usePermission()
90
+ const language = useLanguage()
91
+ const platform = usePlatform()
92
+ const prompt = props.state ?? usePrompt()
93
+ let editor: HTMLDivElement | undefined
94
+
95
+ const interaction = createPromptInputV2State()
96
+ const mode = () => interaction[0].mode
97
+ const history = props.history ?? createPersistedPromptInputHistory()
98
+ const tabs = () => props.controls.session.tabs
99
+ const activeFileTab = createSessionTabs({
100
+ tabs,
101
+ pathFromTab: files.pathFromTab,
102
+ normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
103
+ }).activeFileTab
104
+ const recent = createMemo(() => {
105
+ const all = tabs().all()
106
+ const active = activeFileTab()
107
+ const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
108
+ return order.reduce<string[]>((result, tab) => {
109
+ const path = files.pathFromTab(tab)
110
+ if (!path || result.includes(path)) return result
111
+ return [...result, path]
112
+ }, [])
113
+ })
114
+ const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
115
+ const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
116
+ const attachments = createMemo(() =>
117
+ prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
118
+ )
119
+ const commentCount = createMemo(() => {
120
+ if (mode() === "shell") return 0
121
+ return prompt.context.items().filter((item) => !!item.comment?.trim()).length
122
+ })
123
+ const blank = createMemo(() => {
124
+ const text = prompt
125
+ .current()
126
+ .map((part) => ("content" in part ? part.content : ""))
127
+ .join("")
128
+ return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
129
+ })
130
+ const stopping = createMemo(() => working() && blank())
131
+ const placeholder = createMemo(() =>
132
+ promptPlaceholder({
133
+ mode: mode(),
134
+ commentCount: commentCount(),
135
+ example: mode() === "shell" ? "git status" : "",
136
+ suggest: false,
137
+ t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
138
+ }),
139
+ )
140
+ const designPlaceholder = () =>
141
+ promptDesignPlaceholder(mode(), placeholder(), (key, params) =>
142
+ language.t(key as Parameters<typeof language.t>[0], params as never),
143
+ )
144
+
145
+ const historyComments = () => {
146
+ const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
147
+ return prompt.context.items().flatMap((item) => {
148
+ const comment = item.comment?.trim()
149
+ if (!comment) return []
150
+ const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
151
+ const nextSelection =
152
+ selection ??
153
+ (item.selection
154
+ ? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
155
+ : undefined)
156
+ if (!nextSelection) return []
157
+ return [
158
+ {
159
+ id: item.commentID ?? item.key,
160
+ path: item.path,
161
+ selection: { ...nextSelection },
162
+ comment,
163
+ time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
164
+ origin: item.commentOrigin,
165
+ preview: item.preview,
166
+ } satisfies PromptHistoryComment,
167
+ ]
168
+ })
169
+ }
170
+ const restoreHistoryComments = (items: PromptHistoryComment[]) => {
171
+ comments.replace(
172
+ items.map((item) => ({
173
+ id: item.id,
174
+ file: item.path,
175
+ selection: { ...item.selection },
176
+ comment: item.comment,
177
+ time: item.time,
178
+ })),
179
+ )
180
+ prompt.context.replaceComments(
181
+ items.map((item) => ({
182
+ type: "file",
183
+ path: item.path,
184
+ selection: selectionFromLines(item.selection),
185
+ comment: item.comment,
186
+ commentID: item.id,
187
+ commentOrigin: item.origin,
188
+ preview: item.preview,
189
+ })),
190
+ )
191
+ }
192
+
193
+ const accepting = createMemo(() => {
194
+ const id = props.controls.session.id
195
+ if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
196
+ return permission.isAutoAccepting(id, sdk().directory)
197
+ })
198
+ const submission = createPromptSubmit({
199
+ prompt,
200
+ info,
201
+ imageAttachments: attachments,
202
+ commentCount,
203
+ autoAccept: accepting,
204
+ mode,
205
+ working,
206
+ editor: () => editor,
207
+ queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
208
+ promptLength,
209
+ addToHistory: (value, mode) => controller.addHistory(value, mode),
210
+ resetHistoryNavigation: () => controller.resetHistory(),
211
+ setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
212
+ setPopover: (popover) => {
213
+ if (!popover) controller.dispatch({ type: "popover.close" })
214
+ },
215
+ newSessionWorktree: () => props.newSessionWorktree,
216
+ onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
217
+ shouldQueue: props.shouldQueue,
218
+ onQueue: props.onQueue,
219
+ onAbort: props.onAbort,
220
+ onSubmit: props.onSubmit,
221
+ model: props.controls.model.selection,
222
+ })
223
+
224
+ const referenceDescription = (reference: ReferenceInfo) =>
225
+ reference.source.type === "git" ? reference.source.repository : reference.source.path
226
+ const references = createMemo(() =>
227
+ sync()
228
+ .data.reference.filter((reference) => !reference.hidden)
229
+ .map((reference) => ({
230
+ id: `reference:${reference.name}`,
231
+ kind: "reference" as const,
232
+ label: `@${reference.name}`,
233
+ path: reference.path,
234
+ description: reference.description ?? referenceDescription(reference),
235
+ mention: {
236
+ type: "file" as const,
237
+ path: reference.path,
238
+ content: `@${reference.name}`,
239
+ start: 0,
240
+ end: 0,
241
+ mime: "application/x-directory",
242
+ filename: reference.name,
243
+ },
244
+ })),
245
+ )
246
+ const resources = createMemo(() =>
247
+ Object.values(sync().data.mcp_resource).map((resource) => ({
248
+ id: `resource:${resource.server}:${resource.uri}`,
249
+ kind: "resource" as const,
250
+ label: `@${resource.name}`,
251
+ path: resource.uri,
252
+ description: resource.description,
253
+ mention: {
254
+ type: "file" as const,
255
+ path: resource.uri,
256
+ content: `@${resource.name}`,
257
+ start: 0,
258
+ end: 0,
259
+ mime: resource.mimeType ?? "text/plain",
260
+ filename: resource.name,
261
+ url: resource.uri,
262
+ source: {
263
+ type: "resource" as const,
264
+ text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
265
+ clientName: resource.server,
266
+ uri: resource.uri,
267
+ },
268
+ },
269
+ resource,
270
+ })),
271
+ )
272
+ const context = createMemo<PromptInputV2Suggestion[]>(() => [
273
+ ...references(),
274
+ ...props.controls.agents.available
275
+ .filter((agent) => !agent.hidden && agent.mode !== "primary")
276
+ .map((agent) => ({
277
+ id: `agent:${agent.name}`,
278
+ kind: "agent" as const,
279
+ label: `@${agent.name}`,
280
+ mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
281
+ })),
282
+ ...resources(),
283
+ ...recent().map((path) => ({
284
+ id: `file:${path}`,
285
+ kind: "file" as const,
286
+ label: path,
287
+ path,
288
+ recent: true,
289
+ mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
290
+ })),
291
+ ])
292
+ const slashCommands = createMemo(() => [
293
+ ...sync().data.command.map((item) => ({
294
+ id: `custom.${item.name}`,
295
+ trigger: item.name,
296
+ title: item.name,
297
+ description: item.description,
298
+ type: "custom" as const,
299
+ })),
300
+ ...command.options
301
+ .filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
302
+ .map((item) => ({
303
+ id: item.id,
304
+ trigger: item.slash!,
305
+ title: item.title,
306
+ description: item.description,
307
+ type: "builtin" as const,
308
+ })),
309
+ ])
310
+ const commands = createMemo<PromptInputV2Suggestion[]>(() =>
311
+ slashCommands().map((item) => ({
312
+ id: item.id,
313
+ kind: "command",
314
+ label: `/${item.trigger}`,
315
+ trigger: item.trigger,
316
+ title: item.title,
317
+ description: item.description,
318
+ keybind: command.keybindParts(item.id),
319
+ })),
320
+ )
321
+ const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
322
+ const controller = createPromptInputV2Controller({
323
+ store: () => prompt.capture().store,
324
+ state: interaction,
325
+ identity: () => prompt.capture(),
326
+ history: {
327
+ entries: (mode) =>
328
+ history.entries(mode).map((value) => {
329
+ const entry = normalizePromptHistoryEntry(value)
330
+ return { prompt: entry.prompt, metadata: entry.comments }
331
+ }),
332
+ add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
333
+ capture: historyComments,
334
+ restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
335
+ },
336
+ commands,
337
+ context,
338
+ searchContextFiles: async (query) =>
339
+ (await files.searchFilesAndDirectories(query)).map((path) => ({
340
+ id: `file:${path}`,
341
+ kind: "file",
342
+ label: path,
343
+ path,
344
+ mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
345
+ })),
346
+ onContextRemove(item) {
347
+ if (item?.commentID) comments.remove(item.path, item.commentID)
348
+ },
349
+ openAttachment: (attachment) =>
350
+ dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
351
+ openContext(key) {
352
+ const item = controller.contextItem(key)
353
+ if (item) openComment(item, props, sync, layout, files, comments)
354
+ },
355
+ onEditor(element) {
356
+ editor = element as HTMLDivElement
357
+ props.ref?.(editor)
358
+ },
359
+ onSuggestionSelect(item) {
360
+ if (item.kind !== "command") return
361
+ const selected = slashCommands().find((entry) => entry.id === item.id)
362
+ if (!selected || selected.type === "custom") return
363
+ return () => command.trigger(selected.id, "slash")
364
+ },
365
+ attachments: {
366
+ picker: platform.openAttachmentPickerDialog,
367
+ directory: () => sdk().directory,
368
+ isDialogActive: () => !!dialog.active,
369
+ warn: () =>
370
+ showToast({
371
+ title: language.t("prompt.toast.pasteUnsupported.title"),
372
+ description: language.t("prompt.toast.pasteUnsupported.description"),
373
+ }),
374
+ duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
375
+ onError: (error) =>
376
+ showToast({
377
+ variant: "error",
378
+ title: language.t("common.requestFailed"),
379
+ description: error instanceof Error ? error.message : String(error),
380
+ }),
381
+ readClipboardImage: platform.readClipboardImage,
382
+ getPathForFile: platform.getPathForFile,
383
+ store: platform.draftStore?.putBlob,
384
+ },
385
+ view: {
386
+ placeholder: designPlaceholder,
387
+ get agent() {
388
+ return props.controls.agents.visible && props.controls.agents.options.length > 0
389
+ ? {
390
+ options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
391
+ current: () => props.controls.agents.current,
392
+ onSelect: (value: string) => props.controls.agents.select(value),
393
+ keybind: () => command.keybindParts("agent.cycle"),
394
+ }
395
+ : undefined
396
+ },
397
+ variant: {
398
+ options: () => variants().map((value) => ({ id: value, label: value })),
399
+ current: () => props.controls.model.selection.variant.current() ?? "default",
400
+ onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
401
+ keybind: () => command.keybindParts("model.variant.cycle"),
402
+ },
403
+ submit: {
404
+ stopping,
405
+ working,
406
+ onSubmit: () => void submission.handleSubmit(new Event("submit")),
407
+ onStop: () => void submission.abort(),
408
+ },
409
+ },
410
+ })
411
+ Object.defineProperty(controller, "model", { get: () => props.controls.model })
412
+
413
+ command.register("prompt-input", () => [
414
+ {
415
+ id: "file.attach",
416
+ title: language.t("prompt.action.attachFile"),
417
+ category: language.t("command.category.file"),
418
+ keybind: "mod+u",
419
+ disabled: controller.state.mode !== "normal",
420
+ onSelect: () => controller.attach(),
421
+ },
422
+ {
423
+ id: "prompt.mode.shell",
424
+ title: language.t("command.prompt.mode.shell"),
425
+ category: language.t("command.category.session"),
426
+ keybind: "mod+shift+x",
427
+ disabled: controller.state.mode === "shell",
428
+ onSelect: () => controller.dispatch({ type: "mode.shell" }),
429
+ },
430
+ {
431
+ id: "prompt.mode.normal",
432
+ title: language.t("command.prompt.mode.normal"),
433
+ category: language.t("command.category.session"),
434
+ keybind: "mod+shift+e",
435
+ disabled: controller.state.mode === "normal",
436
+ onSelect: () => controller.dispatch({ type: "mode.normal" }),
437
+ },
438
+ ])
439
+
440
+ createEffect(
441
+ on(
442
+ () => props.edit?.id,
443
+ (id) => {
444
+ const edit = props.edit
445
+ if (!id || !edit) return
446
+ prompt.context.items().forEach((item) => prompt.context.remove(item.key))
447
+ edit.context.forEach((item) =>
448
+ prompt.context.add({
449
+ type: item.type,
450
+ path: item.path,
451
+ selection: item.selection,
452
+ comment: item.comment,
453
+ commentID: item.commentID,
454
+ commentOrigin: item.commentOrigin,
455
+ preview: item.preview,
456
+ }),
457
+ )
458
+ controller.dispatch({ type: "mode.normal" })
459
+ controller.resetHistory()
460
+ prompt.set(edit.prompt, promptLength(edit.prompt))
461
+ controller.restoreFocus()
462
+ props.onEditLoaded?.()
463
+ },
464
+ { defer: true },
465
+ ),
466
+ )
467
+
468
+ return controller as PromptInputV2ComposerController
469
+ }
470
+
471
+ function PromptInputV2ModelControl(props: {
472
+ loading: boolean
473
+ paid: boolean
474
+ title: string
475
+ keybind: string[]
476
+ model: PromptInputV2ComposerController["model"]["selection"]
477
+ providerID?: string
478
+ modelName: string
479
+ onClose: () => void
480
+ onUnpaidClick: () => void
481
+ }) {
482
+ const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
483
+ const content = () => (
484
+ <>
485
+ <Show when={props.providerID}>
486
+ {(providerID) => (
487
+ <ProviderIcon
488
+ id={providerID()}
489
+ class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
490
+ style={{ "will-change": "opacity", transform: "translateZ(0)" }}
491
+ />
492
+ )}
493
+ </Show>
494
+ <span class="truncate leading-4">{props.modelName}</span>
495
+ <span class="-ml-0.5 -mr-1 flex shrink-0">
496
+ <Icon name="chevron-down" />
497
+ </span>
498
+ </>
499
+ )
500
+ return (
501
+ <Show when={!props.loading}>
502
+ <TooltipV2
503
+ placement="top"
504
+ gutter={4}
505
+ value={
506
+ <>
507
+ {props.title}
508
+ <KeybindV2 keys={props.keybind} variant="neutral" />
509
+ </>
510
+ }
511
+ >
512
+ <Show
513
+ when={props.paid}
514
+ fallback={
515
+ <ButtonV2
516
+ data-action="prompt-model"
517
+ data-control-type="dialog"
518
+ variant="ghost-muted"
519
+ size="normal"
520
+ class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
521
+ classList={{ "animate-in fade-in": shouldAnimate() }}
522
+ style={{ height: "28px" }}
523
+ onClick={props.onUnpaidClick}
524
+ >
525
+ {content()}
526
+ </ButtonV2>
527
+ }
528
+ >
529
+ <ModelSelectorPopoverV2
530
+ model={props.model}
531
+ trigger={(triggerProps) => (
532
+ <ButtonV2
533
+ {...triggerProps}
534
+ variant="ghost-muted"
535
+ size="normal"
536
+ style={{ height: "28px" }}
537
+ class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
538
+ classList={{ "animate-in fade-in": shouldAnimate() }}
539
+ data-action="prompt-model"
540
+ data-control-type="popover"
541
+ >
542
+ {content()}
543
+ </ButtonV2>
544
+ )}
545
+ onClose={props.onClose}
546
+ />
547
+ </Show>
548
+ </TooltipV2>
549
+ </Show>
550
+ )
551
+ }
552
+
553
+ function openComment(
554
+ item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
555
+ props: PromptInputV2ControllerProps,
556
+ sync: ReturnType<typeof useSync>,
557
+ layout: ReturnType<typeof useLayout>,
558
+ files: ReturnType<typeof useFile>,
559
+ comments: ReturnType<typeof useComments>,
560
+ ) {
561
+ if (!item.commentID) return
562
+ const focus = { file: item.path, id: item.commentID }
563
+ comments.setActive(focus)
564
+ const queueFocus = (attempts = 6) => {
565
+ requestAnimationFrame(() => {
566
+ comments.setFocus({ ...focus })
567
+ if (attempts <= 0) return
568
+ requestAnimationFrame(() => {
569
+ const current = comments.focus()
570
+ if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
571
+ })
572
+ })
573
+ }
574
+ const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
575
+ const review =
576
+ item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
577
+ if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
578
+ if (review) {
579
+ layout.fileTree.setTab("changes")
580
+ props.controls.session.tabs.setActive("review")
581
+ queueFocus()
582
+ return
583
+ }
584
+ layout.fileTree.setTab("all")
585
+ const tab = files.tab(item.path)
586
+ void props.controls.session.tabs.open(tab)
587
+ props.controls.session.tabs.setActive(tab)
588
+ void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
589
+ }
packages/app/src/components/prompt-input.stories.tsx ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-nocheck
2
+ import { createStore } from "solid-js/store"
3
+ import type { Todo } from "@opencode-ai/sdk/v2"
4
+ import { createPromptState } from "@/context/prompt"
5
+ import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
6
+ import { createPromptInputHistory, PromptInput } from "./prompt-input"
7
+
8
+ function createPromptInputStoryRuntime() {
9
+ const state = createPromptState()
10
+ return {
11
+ state,
12
+ history: createPromptInputHistory(),
13
+ submission: {
14
+ abort() {},
15
+ handleSubmit(event: Event) {
16
+ event.preventDefault()
17
+ state.reset()
18
+ },
19
+ },
20
+ }
21
+ }
22
+
23
+ function PromptInputExample() {
24
+ const input = createPromptInputStoryRuntime()
25
+ const [controls, setControls] = createStore({
26
+ agent: "build",
27
+ variant: undefined as string | undefined,
28
+ comments: 0,
29
+ tabs: [] as string[],
30
+ activeTab: undefined as string | undefined,
31
+ reviewOpen: false,
32
+ })
33
+ const storyModel = {
34
+ id: "claude-3-7-sonnet",
35
+ name: "Claude 3.7 Sonnet",
36
+ provider: { id: "anthropic", name: "Anthropic" },
37
+ }
38
+ const model = {
39
+ current: () => storyModel,
40
+ list: () => [storyModel],
41
+ visible: () => true,
42
+ set: () => {},
43
+ variant: {
44
+ list: () => ["fast", "thinking"],
45
+ current: () => controls.variant,
46
+ set: (variant?: string) => setControls("variant", variant),
47
+ },
48
+ }
49
+ const inputControls = {
50
+ agents: {
51
+ available: [{ name: "review", hidden: false, mode: "subagent" }],
52
+ options: ["build", "review", "plan"],
53
+ get current() {
54
+ return controls.agent
55
+ },
56
+ loading: false,
57
+ visible: true,
58
+ select: (agent?: string) => setControls("agent", agent ?? "build"),
59
+ },
60
+ model: {
61
+ selection: model,
62
+ paid: true,
63
+ loading: false,
64
+ },
65
+ session: {
66
+ id: "story-session",
67
+ tabs: {
68
+ active: () => controls.activeTab,
69
+ all: () => controls.tabs,
70
+ open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
71
+ setActive: (tab: string) => setControls("activeTab", tab),
72
+ },
73
+ reviewPanel: {
74
+ opened: () => controls.reviewOpen,
75
+ open: () => setControls("reviewOpen", true),
76
+ },
77
+ },
78
+ }
79
+ const addReviewComment = () => {
80
+ const comment = controls.comments + 1
81
+ setControls("comments", comment)
82
+ input.state.context.add({
83
+ type: "file",
84
+ path: "src/components/prompt-input.tsx",
85
+ selection: {
86
+ startLine: 84 + comment,
87
+ startChar: 0,
88
+ endLine: 84 + comment,
89
+ endChar: 0,
90
+ },
91
+ comment: `Review comment ${comment}`,
92
+ commentID: `review-comment-${comment}`,
93
+ commentOrigin: "review",
94
+ preview: "export const PromptInput = ...",
95
+ })
96
+ }
97
+
98
+ return (
99
+ <div class="flex flex-col gap-3">
100
+ <PromptInput controls={inputControls} {...input} />
101
+ <div>
102
+ <button
103
+ type="button"
104
+ class="rounded-md border border-border-weak-base bg-background-base px-2.5 py-1.5 text-12-medium text-text-base hover:bg-background-stronger"
105
+ onClick={addReviewComment}
106
+ >
107
+ Add review comment
108
+ </button>
109
+ </div>
110
+ </div>
111
+ )
112
+ }
113
+
114
+ const todos: Todo[] = [
115
+ { id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
116
+ { id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
117
+ { id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
118
+ ]
119
+
120
+ function PromptInputWithOpenDock() {
121
+ const input = createPromptInputStoryRuntime()
122
+ const [controls, setControls] = createStore({
123
+ agent: "build",
124
+ activeTab: undefined as string | undefined,
125
+ todoCollapsed: false,
126
+ })
127
+ const inputControls = {
128
+ agents: {
129
+ available: [],
130
+ options: ["build"],
131
+ get current() {
132
+ return controls.agent
133
+ },
134
+ loading: false,
135
+ visible: true,
136
+ select: (agent?: string) => setControls("agent", agent ?? "build"),
137
+ },
138
+ model: {
139
+ selection: {
140
+ current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
141
+ variant: { list: () => [], current: () => undefined, set: () => {} },
142
+ },
143
+ paid: true,
144
+ loading: false,
145
+ },
146
+ session: {
147
+ id: "story-session",
148
+ tabs: {
149
+ active: () => controls.activeTab,
150
+ all: () => [],
151
+ open: () => {},
152
+ setActive: (tab: string) => setControls("activeTab", tab),
153
+ },
154
+ reviewPanel: { opened: () => false, open: () => {} },
155
+ },
156
+ }
157
+ const state = {
158
+ blocked: () => false,
159
+ questionRequest: () => undefined,
160
+ permissionRequest: () => undefined,
161
+ permissionResponding: () => false,
162
+ decide: () => {},
163
+ todos: () => todos,
164
+ dock: () => true,
165
+ closing: () => false,
166
+ opening: () => false,
167
+ }
168
+ return (
169
+ <SessionComposerRegion
170
+ controller={createSessionComposerRegionController({
171
+ state,
172
+ sessionKey: () => "story-session",
173
+ sessionID: () => "story-session",
174
+ prompt: input.state,
175
+ ready: () => true,
176
+ centered: () => false,
177
+ todo: {
178
+ collapsed: () => controls.todoCollapsed,
179
+ onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
180
+ },
181
+ followup: () => undefined,
182
+ revert: () => undefined,
183
+ onResponseSubmit: () => {},
184
+ openParent: () => {},
185
+ setPromptRef: () => {},
186
+ setDockRef: () => {},
187
+ })}
188
+ promptInput={
189
+ <PromptInput
190
+ controls={inputControls}
191
+ {...input}
192
+ ref={() => {}}
193
+ newSessionWorktree=""
194
+ onNewSessionWorktreeReset={() => {}}
195
+ />
196
+ }
197
+ />
198
+ )
199
+ }
200
+
201
+ export default {
202
+ title: "App/PromptInput",
203
+ id: "app-prompt-input",
204
+ component: PromptInput,
205
+ }
206
+
207
+ export const Basic = {
208
+ render: () => (
209
+ <div class="pt-10">
210
+ <h1 class="mb-4">Prompt Input</h1>
211
+ <PromptInputExample />
212
+ </div>
213
+ ),
214
+ }
215
+
216
+ export const DockAlreadyOpen = {
217
+ render: () => (
218
+ <div class="pt-10">
219
+ <h1 class="mb-4">Prompt Input with open Todo dock</h1>
220
+ <PromptInputWithOpenDock />
221
+ </div>
222
+ ),
223
+ }