ikanban-web 0.2.3 → 0.2.4

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.
package/dist/index.html CHANGED
@@ -14,8 +14,8 @@
14
14
  <meta property="og:image" content="/social-share.png" />
15
15
  <meta property="twitter:image" content="/social-share.png" />
16
16
  <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
17
- <script type="module" crossorigin src="/assets/index-D3zkBiK6.js"></script>
18
- <link rel="stylesheet" crossorigin href="/assets/index-CLdyKsqF.css">
17
+ <script type="module" crossorigin src="/assets/index-HcsA-1S9.js"></script>
18
+ <link rel="stylesheet" crossorigin href="/assets/index-CitLv2ln.css">
19
19
  </head>
20
20
  <body class="antialiased overscroll-none text-12-regular overflow-hidden">
21
21
  <noscript>You need to enable JavaScript to run this app.</noscript>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikanban-web",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "iKanban web app",
5
5
  "private": false,
6
6
  "type": "module",
@@ -24,10 +24,6 @@
24
24
  "test": "bun run test:unit",
25
25
  "test:unit": "bun test --preload ./happydom.ts ./src",
26
26
  "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
27
- "test:e2e": "playwright test",
28
- "test:e2e:local": "bun script/e2e-local.ts",
29
- "test:e2e:ui": "playwright test --ui",
30
- "test:e2e:report": "playwright show-report e2e/playwright-report",
31
27
  "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js",
32
28
  "build:watch": "vite build --watch"
33
29
  },
@@ -64,7 +60,6 @@
64
60
  },
65
61
  "devDependencies": {
66
62
  "@happy-dom/global-registrator": "20.8.3",
67
- "@playwright/test": "1.58.2",
68
63
  "@tailwindcss/vite": "4.2.1",
69
64
  "@tsconfig/bun": "1.0.10",
70
65
  "@types/bun": "1.3.10",
@@ -18,6 +18,12 @@ import { useLanguage } from "@/context/language"
18
18
  const isFree = (provider: string, cost: { input: number } | undefined) =>
19
19
  provider === "opencode" && (!cost || cost.input === 0)
20
20
 
21
+ type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number] & {
22
+ _recentKey?: string
23
+ }
24
+
25
+ const RECENT_GROUP = "\u0000recent"
26
+
21
27
  const ModelList: Component<{
22
28
  provider?: string
23
29
  class?: string
@@ -27,31 +33,62 @@ const ModelList: Component<{
27
33
  const local = useLocal()
28
34
  const language = useLanguage()
29
35
 
30
- const models = createMemo(() =>
36
+ const visibleModels = createMemo(() =>
31
37
  local.model
32
38
  .list()
33
39
  .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
34
40
  .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
35
41
  )
36
42
 
43
+ const recentModels = createMemo(() =>
44
+ local.model
45
+ .recent()
46
+ .filter((m) => m && local.model.visible({ modelID: m.id, providerID: m.provider.id }))
47
+ .filter((m) => m && (props.provider ? m.provider.id === props.provider : true)) as NonNullable<
48
+ ReturnType<ReturnType<typeof useLocal>["model"]["recent"]>[number]
49
+ >[],
50
+ )
51
+
52
+ const items = (filter: string): ModelItem[] => {
53
+ const visible = visibleModels()
54
+ if (filter.trim()) return visible
55
+ const recent = recentModels()
56
+ if (recent.length === 0) return visible
57
+ const recentKeys = new Set(recent.map((m) => `${m.provider.id}:${m.id}`))
58
+ const nonRecentVisible = visible.filter((m) => !recentKeys.has(`${m.provider.id}:${m.id}`))
59
+ const recentItems: ModelItem[] = recent.map((m) => ({ ...m, _recentKey: `${m.provider.id}:${m.id}` }))
60
+ return [...recentItems, ...nonRecentVisible]
61
+ }
62
+
63
+ const recentGroupLabel = () => language.t("dialog.model.recent")
64
+
37
65
  return (
38
66
  <List
39
67
  class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
40
68
  search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
41
69
  emptyMessage={language.t("dialog.model.empty")}
42
- key={(x) => `${x.provider.id}:${x.id}`}
43
- items={models}
44
- current={local.model.current()}
70
+ key={(x) => (x._recentKey ? `${RECENT_GROUP}:${x._recentKey}` : `${x.provider.id}:${x.id}`)}
71
+ items={items}
72
+ current={local.model.current() as ModelItem | undefined}
45
73
  filterKeys={["provider.name", "name", "id"]}
46
- sortBy={(a, b) => a.name.localeCompare(b.name)}
47
- groupBy={(x) => x.provider.name}
74
+ sortBy={(a, b) => {
75
+ if (a._recentKey && b._recentKey) return 0
76
+ if (a._recentKey || b._recentKey) return 0
77
+ return a.name.localeCompare(b.name)
78
+ }}
79
+ groupBy={(x) => (x._recentKey ? RECENT_GROUP : x.provider.name)}
48
80
  sortGroupsBy={(a, b) => {
81
+ if (a.category === RECENT_GROUP) return -1
82
+ if (b.category === RECENT_GROUP) return 1
49
83
  const aProvider = a.items[0].provider.id
50
84
  const bProvider = b.items[0].provider.id
51
85
  if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
52
86
  if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
53
87
  return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
54
88
  }}
89
+ groupHeader={(group) =>
90
+ group.category === RECENT_GROUP ? recentGroupLabel() : group.category
91
+ }
55
92
  itemWrapper={(item, node) => (
56
93
  <Tooltip
57
94
  class="w-full"
@@ -400,6 +400,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
400
400
 
401
401
  const isFocused = createFocusSignal(() => editorRef)
402
402
  const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
403
+ const stopKeyLabel = () => (platform.platform === "desktop" && platform.os === "macos" ? "Cmd+C" : "Ctrl+C")
403
404
 
404
405
  const pick = () => fileInputRef?.click()
405
406
 
@@ -1037,13 +1038,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
1037
1038
  return
1038
1039
  }
1039
1040
 
1040
- if (working()) {
1041
- abort()
1042
- event.preventDefault()
1043
- event.stopPropagation()
1044
- return
1045
- }
1046
-
1047
1041
  if (escBlur()) {
1048
1042
  editorRef.blur()
1049
1043
  event.preventDefault()
@@ -1097,7 +1091,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
1097
1091
  }
1098
1092
  }
1099
1093
 
1100
- if (ctrl && event.code === "KeyG") {
1094
+ if (ctrl && (event.code === "KeyG" || event.code === "KeyC")) {
1101
1095
  if (store.popover) {
1102
1096
  closePopover()
1103
1097
  event.preventDefault()
@@ -1290,7 +1284,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
1290
1284
  <Match when={working()}>
1291
1285
  <div class="flex items-center gap-2">
1292
1286
  <span>{language.t("prompt.action.stop")}</span>
1293
- <span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
1287
+ <span class="text-icon-base text-12-medium text-[10px]!">{stopKeyLabel()}</span>
1294
1288
  </div>
1295
1289
  </Match>
1296
1290
  <Match when={true}>
@@ -1,224 +1,102 @@
1
- import * as i18n from "@solid-primitives/i18n"
2
- import { createEffect, createMemo } from "solid-js"
3
- import { createStore } from "solid-js/store"
4
- import { createSimpleContext } from "ikanban-ui/context"
5
- import { Persist, persisted } from "@/utils/persist"
6
- import { dict as en } from "@/i18n/en"
7
- import { dict as zh } from "@/i18n/zh"
8
- import { dict as zht } from "@/i18n/zht"
9
- import { dict as ko } from "@/i18n/ko"
10
- import { dict as de } from "@/i18n/de"
11
- import { dict as es } from "@/i18n/es"
12
- import { dict as fr } from "@/i18n/fr"
13
- import { dict as da } from "@/i18n/da"
14
- import { dict as ja } from "@/i18n/ja"
15
- import { dict as pl } from "@/i18n/pl"
16
- import { dict as ru } from "@/i18n/ru"
17
- import { dict as ar } from "@/i18n/ar"
18
- import { dict as no } from "@/i18n/no"
19
- import { dict as br } from "@/i18n/br"
20
- import { dict as th } from "@/i18n/th"
21
- import { dict as bs } from "@/i18n/bs"
22
- import { dict as tr } from "@/i18n/tr"
23
- import { dict as uiEn } from "ikanban-ui/i18n/en"
24
- import { dict as uiZh } from "ikanban-ui/i18n/zh"
25
- import { dict as uiZht } from "ikanban-ui/i18n/zht"
26
- import { dict as uiKo } from "ikanban-ui/i18n/ko"
27
- import { dict as uiDe } from "ikanban-ui/i18n/de"
28
- import { dict as uiEs } from "ikanban-ui/i18n/es"
29
- import { dict as uiFr } from "ikanban-ui/i18n/fr"
30
- import { dict as uiDa } from "ikanban-ui/i18n/da"
31
- import { dict as uiJa } from "ikanban-ui/i18n/ja"
32
- import { dict as uiPl } from "ikanban-ui/i18n/pl"
33
- import { dict as uiRu } from "ikanban-ui/i18n/ru"
34
- import { dict as uiAr } from "ikanban-ui/i18n/ar"
35
- import { dict as uiNo } from "ikanban-ui/i18n/no"
36
- import { dict as uiBr } from "ikanban-ui/i18n/br"
37
- import { dict as uiTh } from "ikanban-ui/i18n/th"
38
- import { dict as uiBs } from "ikanban-ui/i18n/bs"
39
- import { dict as uiTr } from "ikanban-ui/i18n/tr"
40
-
41
- export type Locale =
42
- | "en"
43
- | "zh"
44
- | "zht"
45
- | "ko"
46
- | "de"
47
- | "es"
48
- | "fr"
49
- | "da"
50
- | "ja"
51
- | "pl"
52
- | "ru"
53
- | "ar"
54
- | "no"
55
- | "br"
56
- | "th"
57
- | "bs"
58
- | "tr"
59
-
60
- type RawDictionary = typeof en & typeof uiEn
61
- type Dictionary = i18n.Flatten<RawDictionary>
1
+ import * as i18n from "@solid-primitives/i18n";
2
+ import { createEffect, createMemo } from "solid-js";
3
+ import { createStore } from "solid-js/store";
4
+ import { createSimpleContext } from "ikanban-ui/context";
5
+ import { Persist, persisted } from "@/utils/persist";
6
+ import { dict as en } from "@/i18n/en";
7
+ import { dict as zh } from "@/i18n/zh";
8
+ import { dict as uiEn } from "ikanban-ui/i18n/en";
9
+ import { dict as uiZh } from "ikanban-ui/i18n/zh";
10
+
11
+ export type Locale = "en" | "zh";
12
+
13
+ type RawDictionary = typeof en & typeof uiEn;
14
+ type Dictionary = i18n.Flatten<RawDictionary>;
62
15
 
63
16
  function cookie(locale: Locale) {
64
- return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
17
+ return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`;
65
18
  }
66
19
 
67
- const LOCALES: readonly Locale[] = [
68
- "en",
69
- "zh",
70
- "zht",
71
- "ko",
72
- "de",
73
- "es",
74
- "fr",
75
- "da",
76
- "ja",
77
- "pl",
78
- "ru",
79
- "bs",
80
- "ar",
81
- "no",
82
- "br",
83
- "th",
84
- "tr",
85
- ]
20
+ const LOCALES: readonly Locale[] = ["en", "zh"];
86
21
 
87
22
  const LABEL_KEY: Record<Locale, keyof Dictionary> = {
88
23
  en: "language.en",
89
24
  zh: "language.zh",
90
- zht: "language.zht",
91
- ko: "language.ko",
92
- de: "language.de",
93
- es: "language.es",
94
- fr: "language.fr",
95
- da: "language.da",
96
- ja: "language.ja",
97
- pl: "language.pl",
98
- ru: "language.ru",
99
- ar: "language.ar",
100
- no: "language.no",
101
- br: "language.br",
102
- th: "language.th",
103
- bs: "language.bs",
104
- tr: "language.tr",
105
- }
25
+ };
106
26
 
107
- const base = i18n.flatten({ ...en, ...uiEn })
27
+ const base = i18n.flatten({ ...en, ...uiEn });
108
28
  const DICT: Record<Locale, Dictionary> = {
109
29
  en: base,
110
30
  zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
111
- zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
112
- ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
113
- de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
114
- es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
115
- fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
116
- da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
117
- ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
118
- pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
119
- ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
120
- ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
121
- no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
122
- br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
123
- th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
124
- bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
125
- tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
126
- }
31
+ };
32
+
33
+ const localeMatchers: Array<{
34
+ locale: Locale;
35
+ match: (language: string) => boolean;
36
+ }> = [{ locale: "zh", match: (language) => language.startsWith("zh") }];
127
37
 
128
- const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
129
- { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
130
- { locale: "zh", match: (language) => language.startsWith("zh") },
131
- { locale: "ko", match: (language) => language.startsWith("ko") },
132
- { locale: "de", match: (language) => language.startsWith("de") },
133
- { locale: "es", match: (language) => language.startsWith("es") },
134
- { locale: "fr", match: (language) => language.startsWith("fr") },
135
- { locale: "da", match: (language) => language.startsWith("da") },
136
- { locale: "ja", match: (language) => language.startsWith("ja") },
137
- { locale: "pl", match: (language) => language.startsWith("pl") },
138
- { locale: "ru", match: (language) => language.startsWith("ru") },
139
- { locale: "ar", match: (language) => language.startsWith("ar") },
140
- {
141
- locale: "no",
142
- match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
143
- },
144
- { locale: "br", match: (language) => language.startsWith("pt") },
145
- { locale: "th", match: (language) => language.startsWith("th") },
146
- { locale: "bs", match: (language) => language.startsWith("bs") },
147
- { locale: "tr", match: (language) => language.startsWith("tr") },
148
- ]
149
-
150
- type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
151
- const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
38
+ type ParityKey =
39
+ | "command.session.previous.unseen"
40
+ | "command.session.next.unseen";
41
+ const PARITY_CHECK: Record<"zh", Record<ParityKey, string>> = {
152
42
  zh,
153
- zht,
154
- ko,
155
- de,
156
- es,
157
- fr,
158
- da,
159
- ja,
160
- pl,
161
- ru,
162
- ar,
163
- no,
164
- br,
165
- th,
166
- bs,
167
- tr,
168
- }
169
- void PARITY_CHECK
43
+ };
44
+ void PARITY_CHECK;
170
45
 
171
46
  function detectLocale(): Locale {
172
- if (typeof navigator !== "object") return "en"
47
+ if (typeof navigator !== "object") return "en";
173
48
 
174
- const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
49
+ const languages = navigator.languages?.length
50
+ ? navigator.languages
51
+ : [navigator.language];
175
52
  for (const language of languages) {
176
- if (!language) continue
177
- const normalized = language.toLowerCase()
178
- const match = localeMatchers.find((entry) => entry.match(normalized))
179
- if (match) return match.locale
53
+ if (!language) continue;
54
+ const normalized = language.toLowerCase();
55
+ const match = localeMatchers.find((entry) => entry.match(normalized));
56
+ if (match) return match.locale;
180
57
  }
181
58
 
182
- return "en"
59
+ return "en";
183
60
  }
184
61
 
185
62
  function normalizeLocale(value: string): Locale {
186
- return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
63
+ return LOCALES.includes(value as Locale) ? (value as Locale) : "en";
187
64
  }
188
65
 
189
- export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
190
- name: "Language",
191
- init: () => {
192
- const [store, setStore, _, ready] = persisted(
193
- Persist.global("language", ["language.v1"]),
194
- createStore({
195
- locale: detectLocale() as Locale,
196
- }),
197
- )
198
-
199
- const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
200
-
201
- const dict = createMemo<Dictionary>(() => DICT[locale()])
202
-
203
- const t = i18n.translator(dict, i18n.resolveTemplate)
204
-
205
- const label = (value: Locale) => t(LABEL_KEY[value])
206
-
207
- createEffect(() => {
208
- if (typeof document !== "object") return
209
- document.documentElement.lang = locale()
210
- document.cookie = cookie(locale())
211
- })
212
-
213
- return {
214
- ready,
215
- locale,
216
- locales: LOCALES,
217
- label,
218
- t,
219
- setLocale(next: Locale) {
220
- setStore("locale", normalizeLocale(next))
221
- },
222
- }
223
- },
224
- })
66
+ export const { use: useLanguage, provider: LanguageProvider } =
67
+ createSimpleContext({
68
+ name: "Language",
69
+ init: () => {
70
+ const [store, setStore, _, ready] = persisted(
71
+ Persist.global("language", ["language.v1"]),
72
+ createStore({
73
+ locale: detectLocale() as Locale,
74
+ }),
75
+ );
76
+
77
+ const locale = createMemo<Locale>(() => normalizeLocale(store.locale));
78
+
79
+ const dict = createMemo<Dictionary>(() => DICT[locale()]);
80
+
81
+ const t = i18n.translator(dict, i18n.resolveTemplate);
82
+
83
+ const label = (value: Locale) => t(LABEL_KEY[value]);
84
+
85
+ createEffect(() => {
86
+ if (typeof document !== "object") return;
87
+ document.documentElement.lang = locale();
88
+ document.cookie = cookie(locale());
89
+ });
90
+
91
+ return {
92
+ ready,
93
+ locale,
94
+ locales: LOCALES,
95
+ label,
96
+ t,
97
+ setLocale(next: Locale) {
98
+ setStore("locale", normalizeLocale(next));
99
+ },
100
+ };
101
+ },
102
+ });
package/src/i18n/en.ts CHANGED
@@ -112,6 +112,7 @@ export const dict = {
112
112
  "dialog.model.search.placeholder": "Search models",
113
113
  "dialog.model.empty": "No model results",
114
114
  "dialog.model.manage": "Manage models",
115
+ "dialog.model.recent": "Recent",
115
116
  "dialog.model.manage.description": "Customize which models appear in the model selector.",
116
117
  "dialog.model.manage.provider.toggle": "Toggle all {{provider}} models",
117
118