@jonsoc/app 1.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +51 -0
  3. package/bunfig.toml +2 -0
  4. package/e2e/context.spec.ts +45 -0
  5. package/e2e/file-open.spec.ts +23 -0
  6. package/e2e/file-viewer.spec.ts +35 -0
  7. package/e2e/fixtures.ts +40 -0
  8. package/e2e/home.spec.ts +21 -0
  9. package/e2e/model-picker.spec.ts +43 -0
  10. package/e2e/navigation.spec.ts +9 -0
  11. package/e2e/palette.spec.ts +15 -0
  12. package/e2e/prompt-mention.spec.ts +26 -0
  13. package/e2e/prompt-slash-open.spec.ts +22 -0
  14. package/e2e/prompt.spec.ts +62 -0
  15. package/e2e/session.spec.ts +21 -0
  16. package/e2e/settings.spec.ts +44 -0
  17. package/e2e/sidebar.spec.ts +21 -0
  18. package/e2e/terminal-init.spec.ts +25 -0
  19. package/e2e/terminal.spec.ts +16 -0
  20. package/e2e/tsconfig.json +8 -0
  21. package/e2e/utils.ts +38 -0
  22. package/happydom.ts +75 -0
  23. package/index.html +23 -0
  24. package/package.json +72 -0
  25. package/playwright.config.ts +43 -0
  26. package/public/_headers +17 -0
  27. package/public/apple-touch-icon-v3.png +1 -0
  28. package/public/apple-touch-icon.png +1 -0
  29. package/public/favicon-96x96-v3.png +1 -0
  30. package/public/favicon-96x96.png +1 -0
  31. package/public/favicon-v3.ico +1 -0
  32. package/public/favicon-v3.svg +1 -0
  33. package/public/favicon.ico +1 -0
  34. package/public/favicon.svg +1 -0
  35. package/public/oc-theme-preload.js +28 -0
  36. package/public/site.webmanifest +1 -0
  37. package/public/social-share-zen.png +1 -0
  38. package/public/social-share.png +1 -0
  39. package/public/web-app-manifest-192x192.png +1 -0
  40. package/public/web-app-manifest-512x512.png +1 -0
  41. package/script/e2e-local.ts +143 -0
  42. package/src/addons/serialize.test.ts +319 -0
  43. package/src/addons/serialize.ts +591 -0
  44. package/src/app.tsx +150 -0
  45. package/src/components/dialog-connect-provider.tsx +428 -0
  46. package/src/components/dialog-edit-project.tsx +259 -0
  47. package/src/components/dialog-fork.tsx +104 -0
  48. package/src/components/dialog-manage-models.tsx +59 -0
  49. package/src/components/dialog-select-directory.tsx +208 -0
  50. package/src/components/dialog-select-file.tsx +196 -0
  51. package/src/components/dialog-select-mcp.tsx +96 -0
  52. package/src/components/dialog-select-model-unpaid.tsx +130 -0
  53. package/src/components/dialog-select-model.tsx +162 -0
  54. package/src/components/dialog-select-provider.tsx +70 -0
  55. package/src/components/dialog-select-server.tsx +249 -0
  56. package/src/components/dialog-settings.tsx +112 -0
  57. package/src/components/file-tree.tsx +112 -0
  58. package/src/components/link.tsx +17 -0
  59. package/src/components/model-tooltip.tsx +91 -0
  60. package/src/components/prompt-input.tsx +2076 -0
  61. package/src/components/session/index.ts +5 -0
  62. package/src/components/session/session-context-tab.tsx +428 -0
  63. package/src/components/session/session-header.tsx +343 -0
  64. package/src/components/session/session-new-view.tsx +93 -0
  65. package/src/components/session/session-sortable-tab.tsx +56 -0
  66. package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
  67. package/src/components/session-context-usage.tsx +113 -0
  68. package/src/components/session-lsp-indicator.tsx +42 -0
  69. package/src/components/session-mcp-indicator.tsx +34 -0
  70. package/src/components/settings-agents.tsx +15 -0
  71. package/src/components/settings-commands.tsx +15 -0
  72. package/src/components/settings-general.tsx +306 -0
  73. package/src/components/settings-keybinds.tsx +437 -0
  74. package/src/components/settings-mcp.tsx +15 -0
  75. package/src/components/settings-models.tsx +15 -0
  76. package/src/components/settings-permissions.tsx +234 -0
  77. package/src/components/settings-providers.tsx +15 -0
  78. package/src/components/terminal.tsx +315 -0
  79. package/src/components/titlebar.tsx +156 -0
  80. package/src/context/command.tsx +308 -0
  81. package/src/context/comments.tsx +140 -0
  82. package/src/context/file.tsx +409 -0
  83. package/src/context/global-sdk.tsx +106 -0
  84. package/src/context/global-sync.tsx +898 -0
  85. package/src/context/language.tsx +161 -0
  86. package/src/context/layout-scroll.test.ts +73 -0
  87. package/src/context/layout-scroll.ts +118 -0
  88. package/src/context/layout.tsx +648 -0
  89. package/src/context/local.tsx +578 -0
  90. package/src/context/notification.tsx +173 -0
  91. package/src/context/permission.tsx +167 -0
  92. package/src/context/platform.tsx +59 -0
  93. package/src/context/prompt.tsx +245 -0
  94. package/src/context/sdk.tsx +48 -0
  95. package/src/context/server.tsx +214 -0
  96. package/src/context/settings.tsx +166 -0
  97. package/src/context/sync.tsx +320 -0
  98. package/src/context/terminal.tsx +267 -0
  99. package/src/custom-elements.d.ts +17 -0
  100. package/src/entry.tsx +76 -0
  101. package/src/env.d.ts +8 -0
  102. package/src/hooks/use-providers.ts +31 -0
  103. package/src/i18n/ar.ts +656 -0
  104. package/src/i18n/br.ts +667 -0
  105. package/src/i18n/da.ts +582 -0
  106. package/src/i18n/de.ts +591 -0
  107. package/src/i18n/en.ts +665 -0
  108. package/src/i18n/es.ts +585 -0
  109. package/src/i18n/fr.ts +592 -0
  110. package/src/i18n/ja.ts +579 -0
  111. package/src/i18n/ko.ts +580 -0
  112. package/src/i18n/no.ts +602 -0
  113. package/src/i18n/pl.ts +661 -0
  114. package/src/i18n/ru.ts +664 -0
  115. package/src/i18n/zh.ts +574 -0
  116. package/src/i18n/zht.ts +570 -0
  117. package/src/index.css +57 -0
  118. package/src/index.ts +2 -0
  119. package/src/pages/directory-layout.tsx +57 -0
  120. package/src/pages/error.tsx +290 -0
  121. package/src/pages/home.tsx +125 -0
  122. package/src/pages/layout.tsx +2599 -0
  123. package/src/pages/session.tsx +2505 -0
  124. package/src/sst-env.d.ts +10 -0
  125. package/src/utils/dom.ts +51 -0
  126. package/src/utils/id.ts +99 -0
  127. package/src/utils/index.ts +1 -0
  128. package/src/utils/perf.ts +135 -0
  129. package/src/utils/persist.ts +377 -0
  130. package/src/utils/prompt.ts +203 -0
  131. package/src/utils/same.ts +6 -0
  132. package/src/utils/solid-dnd.tsx +55 -0
  133. package/src/utils/sound.ts +110 -0
  134. package/src/utils/speech.ts +302 -0
  135. package/src/utils/worktree.ts +58 -0
  136. package/sst-env.d.ts +9 -0
  137. package/tsconfig.json +26 -0
  138. package/vite.config.ts +15 -0
  139. package/vite.js +26 -0
@@ -0,0 +1,57 @@
1
+ import { createMemo, Show, type ParentProps } from "solid-js"
2
+ import { useNavigate, useParams } from "@solidjs/router"
3
+ import { SDKProvider, useSDK } from "@/context/sdk"
4
+ import { SyncProvider, useSync } from "@/context/sync"
5
+ import { LocalProvider } from "@/context/local"
6
+
7
+ import { base64Decode } from "@jonsoc/util/encode"
8
+ import { DataProvider } from "@jonsoc/ui/context"
9
+ import { iife } from "@jonsoc/util/iife"
10
+ import type { QuestionAnswer } from "@jonsoc/sdk/v2"
11
+
12
+ export default function Layout(props: ParentProps) {
13
+ const params = useParams()
14
+ const navigate = useNavigate()
15
+ const directory = createMemo(() => {
16
+ return base64Decode(params.dir!)
17
+ })
18
+ return (
19
+ <Show when={params.dir}>
20
+ <SDKProvider directory={directory()}>
21
+ <SyncProvider>
22
+ {iife(() => {
23
+ const sync = useSync()
24
+ const sdk = useSDK()
25
+ const respond = (input: {
26
+ sessionID: string
27
+ permissionID: string
28
+ response: "once" | "always" | "reject"
29
+ }) => sdk.client.permission.respond(input)
30
+
31
+ const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) =>
32
+ sdk.client.question.reply(input)
33
+
34
+ const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input)
35
+
36
+ const navigateToSession = (sessionID: string) => {
37
+ navigate(`/${params.dir}/session/${sessionID}`)
38
+ }
39
+
40
+ return (
41
+ <DataProvider
42
+ data={sync.data}
43
+ directory={directory()}
44
+ onPermissionRespond={respond}
45
+ onQuestionReply={replyToQuestion}
46
+ onQuestionReject={rejectQuestion}
47
+ onNavigateToSession={navigateToSession}
48
+ >
49
+ <LocalProvider>{props.children}</LocalProvider>
50
+ </DataProvider>
51
+ )
52
+ })}
53
+ </SyncProvider>
54
+ </SDKProvider>
55
+ </Show>
56
+ )
57
+ }
@@ -0,0 +1,290 @@
1
+ import { TextField } from "@jonsoc/ui/text-field"
2
+ import { Logo } from "@jonsoc/ui/logo"
3
+ import { Button } from "@jonsoc/ui/button"
4
+ import { Component, Show } from "solid-js"
5
+ import { createStore } from "solid-js/store"
6
+ import { usePlatform } from "@/context/platform"
7
+ import { useLanguage } from "@/context/language"
8
+ import { Icon } from "@jonsoc/ui/icon"
9
+
10
+ export type InitError = {
11
+ name: string
12
+ data: Record<string, unknown>
13
+ }
14
+
15
+ type Translator = ReturnType<typeof useLanguage>["t"]
16
+
17
+ function isInitError(error: unknown): error is InitError {
18
+ return (
19
+ typeof error === "object" &&
20
+ error !== null &&
21
+ "name" in error &&
22
+ "data" in error &&
23
+ typeof (error as InitError).data === "object"
24
+ )
25
+ }
26
+
27
+ function safeJson(value: unknown): string {
28
+ const seen = new WeakSet<object>()
29
+ const json = JSON.stringify(
30
+ value,
31
+ (_key, val) => {
32
+ if (typeof val === "bigint") return val.toString()
33
+ if (typeof val === "object" && val) {
34
+ if (seen.has(val)) return "[Circular]"
35
+ seen.add(val)
36
+ }
37
+ return val
38
+ },
39
+ 2,
40
+ )
41
+ return json ?? String(value)
42
+ }
43
+
44
+ function formatInitError(error: InitError, t: Translator): string {
45
+ const data = error.data
46
+ switch (error.name) {
47
+ case "MCPFailed": {
48
+ const name = typeof data.name === "string" ? data.name : ""
49
+ return t("error.chain.mcpFailed", { name })
50
+ }
51
+ case "ProviderAuthError": {
52
+ const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
53
+ const message = typeof data.message === "string" ? data.message : safeJson(data.message)
54
+ return t("error.chain.providerAuthFailed", { provider: providerID, message })
55
+ }
56
+ case "APIError": {
57
+ const message = typeof data.message === "string" ? data.message : t("error.chain.apiError")
58
+ const lines: string[] = [message]
59
+
60
+ if (typeof data.statusCode === "number") {
61
+ lines.push(t("error.chain.status", { status: data.statusCode }))
62
+ }
63
+
64
+ if (typeof data.isRetryable === "boolean") {
65
+ lines.push(t("error.chain.retryable", { retryable: data.isRetryable }))
66
+ }
67
+
68
+ if (typeof data.responseBody === "string" && data.responseBody) {
69
+ lines.push(t("error.chain.responseBody", { body: data.responseBody }))
70
+ }
71
+
72
+ return lines.join("\n")
73
+ }
74
+ case "ProviderModelNotFoundError": {
75
+ const { providerID, modelID, suggestions } = data as {
76
+ providerID: string
77
+ modelID: string
78
+ suggestions?: string[]
79
+ }
80
+
81
+ const suggestionsLine =
82
+ Array.isArray(suggestions) && suggestions.length
83
+ ? [t("error.chain.didYouMean", { suggestions: suggestions.join(", ") })]
84
+ : []
85
+
86
+ return [
87
+ t("error.chain.modelNotFound", { provider: providerID, model: modelID }),
88
+ ...suggestionsLine,
89
+ t("error.chain.checkConfig"),
90
+ ].join("\n")
91
+ }
92
+ case "ProviderInitError": {
93
+ const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
94
+ return t("error.chain.providerInitFailed", { provider: providerID })
95
+ }
96
+ case "ConfigJsonError": {
97
+ const path = typeof data.path === "string" ? data.path : safeJson(data.path)
98
+ const message = typeof data.message === "string" ? data.message : ""
99
+ if (message) return t("error.chain.configJsonInvalidWithMessage", { path, message })
100
+ return t("error.chain.configJsonInvalid", { path })
101
+ }
102
+ case "ConfigDirectoryTypoError": {
103
+ const path = typeof data.path === "string" ? data.path : safeJson(data.path)
104
+ const dir = typeof data.dir === "string" ? data.dir : safeJson(data.dir)
105
+ const suggestion = typeof data.suggestion === "string" ? data.suggestion : safeJson(data.suggestion)
106
+ return t("error.chain.configDirectoryTypo", { dir, path, suggestion })
107
+ }
108
+ case "ConfigFrontmatterError": {
109
+ const path = typeof data.path === "string" ? data.path : safeJson(data.path)
110
+ const message = typeof data.message === "string" ? data.message : safeJson(data.message)
111
+ return t("error.chain.configFrontmatterError", { path, message })
112
+ }
113
+ case "ConfigInvalidError": {
114
+ const issues = Array.isArray(data.issues)
115
+ ? data.issues.map(
116
+ (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
117
+ )
118
+ : []
119
+ const message = typeof data.message === "string" ? data.message : ""
120
+ const path = typeof data.path === "string" ? data.path : safeJson(data.path)
121
+
122
+ const line = message
123
+ ? t("error.chain.configInvalidWithMessage", { path, message })
124
+ : t("error.chain.configInvalid", { path })
125
+
126
+ return [line, ...issues].join("\n")
127
+ }
128
+ case "UnknownError":
129
+ return typeof data.message === "string" ? data.message : safeJson(data)
130
+ default:
131
+ if (typeof data.message === "string") return data.message
132
+ return safeJson(data)
133
+ }
134
+ }
135
+
136
+ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessage?: string): string {
137
+ if (!error) return t("error.chain.unknown")
138
+
139
+ if (isInitError(error)) {
140
+ const message = formatInitError(error, t)
141
+ if (depth > 0 && parentMessage === message) return ""
142
+ const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
143
+ return indent + `${error.name}\n${message}`
144
+ }
145
+
146
+ if (error instanceof Error) {
147
+ const isDuplicate = depth > 0 && parentMessage === error.message
148
+ const parts: string[] = []
149
+ const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
150
+
151
+ const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
152
+ const stack = error.stack?.trim()
153
+
154
+ if (stack) {
155
+ const startsWithHeader = stack.startsWith(header)
156
+
157
+ if (isDuplicate && startsWithHeader) {
158
+ const trace = stack.split("\n").slice(1).join("\n").trim()
159
+ if (trace) {
160
+ parts.push(indent + trace)
161
+ }
162
+ }
163
+
164
+ if (isDuplicate && !startsWithHeader) {
165
+ parts.push(indent + stack)
166
+ }
167
+
168
+ if (!isDuplicate && startsWithHeader) {
169
+ parts.push(indent + stack)
170
+ }
171
+
172
+ if (!isDuplicate && !startsWithHeader) {
173
+ parts.push(indent + `${header}\n${stack}`)
174
+ }
175
+ }
176
+
177
+ if (!stack && !isDuplicate) {
178
+ parts.push(indent + header)
179
+ }
180
+
181
+ if (error.cause) {
182
+ const causeResult = formatErrorChain(error.cause, t, depth + 1, error.message)
183
+ if (causeResult) {
184
+ parts.push(causeResult)
185
+ }
186
+ }
187
+
188
+ return parts.join("\n\n")
189
+ }
190
+
191
+ if (typeof error === "string") {
192
+ if (depth > 0 && parentMessage === error) return ""
193
+ const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
194
+ return indent + error
195
+ }
196
+
197
+ const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
198
+ return indent + safeJson(error)
199
+ }
200
+
201
+ function formatError(error: unknown, t: Translator): string {
202
+ return formatErrorChain(error, t, 0)
203
+ }
204
+
205
+ interface ErrorPageProps {
206
+ error: unknown
207
+ }
208
+
209
+ export const ErrorPage: Component<ErrorPageProps> = (props) => {
210
+ const platform = usePlatform()
211
+ const language = useLanguage()
212
+ const [store, setStore] = createStore({
213
+ checking: false,
214
+ version: undefined as string | undefined,
215
+ })
216
+
217
+ async function checkForUpdates() {
218
+ if (!platform.checkUpdate) return
219
+ setStore("checking", true)
220
+ const result = await platform.checkUpdate()
221
+ setStore("checking", false)
222
+ if (result.updateAvailable && result.version) setStore("version", result.version)
223
+ }
224
+
225
+ async function installUpdate() {
226
+ if (!platform.update || !platform.restart) return
227
+ await platform.update()
228
+ await platform.restart()
229
+ }
230
+
231
+ return (
232
+ <div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
233
+ <div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
234
+ <Logo class="w-58.5 opacity-12 shrink-0" />
235
+ <div class="flex flex-col items-center gap-2 text-center">
236
+ <h1 class="text-lg font-medium text-text-strong">{language.t("error.page.title")}</h1>
237
+ <p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
238
+ </div>
239
+ <TextField
240
+ value={formatError(props.error, language.t)}
241
+ readOnly
242
+ copyable
243
+ multiline
244
+ class="max-h-96 w-full font-mono text-xs no-scrollbar"
245
+ label={language.t("error.page.details.label")}
246
+ hideLabel
247
+ />
248
+ <div class="flex items-center gap-3">
249
+ <Button size="large" onClick={platform.restart}>
250
+ {language.t("error.page.action.restart")}
251
+ </Button>
252
+ <Show when={platform.checkUpdate}>
253
+ <Show
254
+ when={store.version}
255
+ fallback={
256
+ <Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
257
+ {store.checking
258
+ ? language.t("error.page.action.checking")
259
+ : language.t("error.page.action.checkUpdates")}
260
+ </Button>
261
+ }
262
+ >
263
+ <Button size="large" onClick={installUpdate}>
264
+ {language.t("error.page.action.updateTo", { version: store.version ?? "" })}
265
+ </Button>
266
+ </Show>
267
+ </Show>
268
+ </div>
269
+ <div class="flex flex-col items-center gap-2">
270
+ <div class="flex items-center justify-center gap-1">
271
+ {language.t("error.page.report.prefix")}
272
+ <button
273
+ type="button"
274
+ class="flex items-center text-text-interactive-base gap-1"
275
+ onClick={() => platform.openLink("https://jonsoc.com/desktop-feedback")}
276
+ >
277
+ <div>{language.t("error.page.report.discord")}</div>
278
+ <Icon name="discord" class="text-text-interactive-base" />
279
+ </button>
280
+ </div>
281
+ <Show when={platform.version}>
282
+ {(version) => (
283
+ <p class="text-xs text-text-weak">{language.t("error.page.version", { version: version() })}</p>
284
+ )}
285
+ </Show>
286
+ </div>
287
+ </div>
288
+ </div>
289
+ )
290
+ }
@@ -0,0 +1,125 @@
1
+ import { createMemo, For, Match, Show, Switch } from "solid-js"
2
+ import { Button } from "@jonsoc/ui/button"
3
+ import { Logo } from "@jonsoc/ui/logo"
4
+ import { useLayout } from "@/context/layout"
5
+ import { useNavigate } from "@solidjs/router"
6
+ import { base64Encode } from "@jonsoc/util/encode"
7
+ import { Icon } from "@jonsoc/ui/icon"
8
+ import { usePlatform } from "@/context/platform"
9
+ import { DateTime } from "luxon"
10
+ import { useDialog } from "@jonsoc/ui/context/dialog"
11
+ import { DialogSelectDirectory } from "@/components/dialog-select-directory"
12
+ import { DialogSelectServer } from "@/components/dialog-select-server"
13
+ import { useServer } from "@/context/server"
14
+ import { useGlobalSync } from "@/context/global-sync"
15
+ import { useLanguage } from "@/context/language"
16
+
17
+ export default function Home() {
18
+ const sync = useGlobalSync()
19
+ const layout = useLayout()
20
+ const platform = usePlatform()
21
+ const dialog = useDialog()
22
+ const navigate = useNavigate()
23
+ const server = useServer()
24
+ const language = useLanguage()
25
+ const homedir = createMemo(() => sync.data.path.home)
26
+
27
+ function openProject(directory: string) {
28
+ layout.projects.open(directory)
29
+ server.projects.touch(directory)
30
+ navigate(`/${base64Encode(directory)}`)
31
+ }
32
+
33
+ async function chooseProject() {
34
+ function resolve(result: string | string[] | null) {
35
+ if (Array.isArray(result)) {
36
+ for (const directory of result) {
37
+ openProject(directory)
38
+ }
39
+ } else if (result) {
40
+ openProject(result)
41
+ }
42
+ }
43
+
44
+ if (platform.openDirectoryPickerDialog && server.isLocal()) {
45
+ const result = await platform.openDirectoryPickerDialog?.({
46
+ title: language.t("command.project.open"),
47
+ multiple: true,
48
+ })
49
+ resolve(result)
50
+ } else {
51
+ dialog.show(
52
+ () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
53
+ () => resolve(null),
54
+ )
55
+ }
56
+ }
57
+
58
+ return (
59
+ <div class="mx-auto mt-55 w-full md:w-auto px-4">
60
+ <Logo class="md:w-xl opacity-12" />
61
+ <Button
62
+ size="large"
63
+ variant="ghost"
64
+ class="mt-4 mx-auto text-14-regular text-text-weak"
65
+ onClick={() => dialog.show(() => <DialogSelectServer />)}
66
+ >
67
+ <div
68
+ classList={{
69
+ "size-2 rounded-full": true,
70
+ "bg-icon-success-base": server.healthy() === true,
71
+ "bg-icon-critical-base": server.healthy() === false,
72
+ "bg-border-weak-base": server.healthy() === undefined,
73
+ }}
74
+ />
75
+ {server.name}
76
+ </Button>
77
+ <Switch>
78
+ <Match when={sync.data.project.length > 0}>
79
+ <div class="mt-20 w-full flex flex-col gap-4">
80
+ <div class="flex gap-2 items-center justify-between pl-3">
81
+ <div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
82
+ <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
83
+ {language.t("command.project.open")}
84
+ </Button>
85
+ </div>
86
+ <ul class="flex flex-col gap-2">
87
+ <For
88
+ each={sync.data.project
89
+ .toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
90
+ .slice(0, 5)}
91
+ >
92
+ {(project) => (
93
+ <Button
94
+ size="large"
95
+ variant="ghost"
96
+ class="text-14-mono text-left justify-between px-3"
97
+ onClick={() => openProject(project.worktree)}
98
+ >
99
+ {project.worktree.replace(homedir(), "~")}
100
+ <div class="text-14-regular text-text-weak">
101
+ {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
102
+ </div>
103
+ </Button>
104
+ )}
105
+ </For>
106
+ </ul>
107
+ </div>
108
+ </Match>
109
+ <Match when={true}>
110
+ <div class="mt-30 mx-auto flex flex-col items-center gap-3">
111
+ <Icon name="folder-add-left" size="large" />
112
+ <div class="flex flex-col gap-1 items-center justify-center">
113
+ <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
114
+ <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
115
+ </div>
116
+ <div />
117
+ <Button class="px-3" onClick={chooseProject}>
118
+ {language.t("command.project.open")}
119
+ </Button>
120
+ </div>
121
+ </Match>
122
+ </Switch>
123
+ </div>
124
+ )
125
+ }