ikanban-web 0.2.6 → 0.2.7

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="/ikanban/social-share.png" />
15
15
  <meta property="twitter:image" content="/social-share.png" />
16
16
  <script id="oc-theme-preload-script" src="/ikanban/oc-theme-preload.js"></script>
17
- <script type="module" crossorigin src="/ikanban/assets/index-BbYqEnGb.js"></script>
18
- <link rel="stylesheet" crossorigin href="/ikanban/assets/index-B1DFsnCu.css">
17
+ <script type="module" crossorigin src="/ikanban/assets/index-ehO8js0v.js"></script>
18
+ <link rel="stylesheet" crossorigin href="/ikanban/assets/index-pnX-hTQP.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.6",
3
+ "version": "0.2.7",
4
4
  "description": "iKanban web app",
5
5
  "private": false,
6
6
  "type": "module",
@@ -4,23 +4,19 @@ import { DropdownMenu } from "ikanban-ui/dropdown-menu"
4
4
  import { Icon } from "ikanban-ui/icon"
5
5
  import { IconButton } from "ikanban-ui/icon-button"
6
6
  import { Keybind } from "ikanban-ui/keybind"
7
- import { Popover } from "ikanban-ui/popover"
8
7
  import { Spinner } from "ikanban-ui/spinner"
9
- import { TextField } from "ikanban-ui/text-field"
10
8
  import { showToast } from "ikanban-ui/toast"
11
- import { Tooltip, TooltipKeybind } from "ikanban-ui/tooltip"
9
+ import { TooltipKeybind } from "ikanban-ui/tooltip"
12
10
  import { getFilename } from "ikanban-utils/path"
13
11
  import { useParams } from "@solidjs/router"
14
- import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
12
+ import { createEffect, createMemo, For, Show } from "solid-js"
15
13
  import { createStore } from "solid-js/store"
16
14
  import { Portal } from "solid-js/web"
17
15
  import { useCommand } from "@/context/command"
18
- import { useGlobalSDK } from "@/context/global-sdk"
19
16
  import { useLanguage } from "@/context/language"
20
17
  import { useLayout } from "@/context/layout"
21
18
  import { usePlatform } from "@/context/platform"
22
19
  import { useServer } from "@/context/server"
23
- import { useSync } from "@/context/sync"
24
20
  import { decode64 } from "@/utils/base64"
25
21
  import { Persist, persisted } from "@/utils/persist"
26
22
  import { StatusPopover } from "../status-popover"
@@ -134,99 +130,11 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
134
130
  })
135
131
  }
136
132
 
137
- function useSessionShare(args: {
138
- globalSDK: ReturnType<typeof useGlobalSDK>
139
- currentSession: () =>
140
- | {
141
- share?: {
142
- url?: string
143
- }
144
- }
145
- | undefined
146
- sessionID: () => string | undefined
147
- projectDirectory: () => string
148
- platform: ReturnType<typeof usePlatform>
149
- }) {
150
- const [state, setState] = createStore({
151
- share: false,
152
- unshare: false,
153
- copied: false,
154
- timer: undefined as number | undefined,
155
- })
156
- const shareUrl = createMemo(() => args.currentSession()?.share?.url)
157
-
158
- createEffect(() => {
159
- const url = shareUrl()
160
- if (url) return
161
- if (state.timer) window.clearTimeout(state.timer)
162
- setState({ copied: false, timer: undefined })
163
- })
164
-
165
- onCleanup(() => {
166
- if (state.timer) window.clearTimeout(state.timer)
167
- })
168
-
169
- const shareSession = () => {
170
- const sessionID = args.sessionID()
171
- if (!sessionID || state.share) return
172
- setState("share", true)
173
- args.globalSDK.client.session
174
- .share({ sessionID, directory: args.projectDirectory() })
175
- .catch((error) => {
176
- console.error("Failed to share session", error)
177
- })
178
- .finally(() => {
179
- setState("share", false)
180
- })
181
- }
182
-
183
- const unshareSession = () => {
184
- const sessionID = args.sessionID()
185
- if (!sessionID || state.unshare) return
186
- setState("unshare", true)
187
- args.globalSDK.client.session
188
- .unshare({ sessionID, directory: args.projectDirectory() })
189
- .catch((error) => {
190
- console.error("Failed to unshare session", error)
191
- })
192
- .finally(() => {
193
- setState("unshare", false)
194
- })
195
- }
196
-
197
- const copyLink = (onError: (error: unknown) => void) => {
198
- const url = shareUrl()
199
- if (!url) return
200
- navigator.clipboard
201
- .writeText(url)
202
- .then(() => {
203
- if (state.timer) window.clearTimeout(state.timer)
204
- setState("copied", true)
205
- const timer = window.setTimeout(() => {
206
- setState("copied", false)
207
- setState("timer", undefined)
208
- }, 3000)
209
- setState("timer", timer)
210
- })
211
- .catch(onError)
212
- }
213
-
214
- const viewShare = () => {
215
- const url = shareUrl()
216
- if (!url) return
217
- args.platform.openLink(url)
218
- }
219
-
220
- return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
221
- }
222
-
223
133
  export function SessionHeader() {
224
- const globalSDK = useGlobalSDK()
225
134
  const layout = useLayout()
226
135
  const params = useParams()
227
136
  const command = useCommand()
228
137
  const server = useServer()
229
- const sync = useSync()
230
138
  const platform = usePlatform()
231
139
  const language = useLanguage()
232
140
 
@@ -243,9 +151,6 @@ export function SessionHeader() {
243
151
  })
244
152
  const hotkey = createMemo(() => command.keybind("file.open"))
245
153
 
246
- const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
247
- const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
248
- const showShare = createMemo(() => shareEnabled() && !!params.id)
249
154
  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
250
155
  const view = createMemo(() => layout.view(sessionKey))
251
156
  const os = createMemo(() => detectOS(platform))
@@ -343,14 +248,6 @@ export function SessionHeader() {
343
248
  .catch((err: unknown) => showRequestError(language, err))
344
249
  }
345
250
 
346
- const share = useSessionShare({
347
- globalSDK,
348
- currentSession,
349
- sessionID: () => params.id,
350
- projectDirectory,
351
- platform,
352
- })
353
-
354
251
  const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
355
252
  const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
356
253
 
@@ -505,112 +402,6 @@ export function SessionHeader() {
505
402
  </Show>
506
403
  </div>
507
404
  </Show>
508
- <Show when={showShare()}>
509
- <div class="flex items-center">
510
- <Popover
511
- title={language.t("session.share.popover.title")}
512
- description={
513
- share.shareUrl()
514
- ? language.t("session.share.popover.description.shared")
515
- : language.t("session.share.popover.description.unshared")
516
- }
517
- gutter={4}
518
- placement="bottom-end"
519
- shift={-64}
520
- class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
521
- triggerAs={Button}
522
- triggerProps={{
523
- variant: "ghost",
524
- class:
525
- "rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
526
- classList: {
527
- "rounded-r-none": share.shareUrl() !== undefined,
528
- "border-r-0": share.shareUrl() !== undefined,
529
- },
530
- style: { scale: 1 },
531
- }}
532
- trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
533
- >
534
- <div class="flex flex-col gap-2">
535
- <Show
536
- when={share.shareUrl()}
537
- fallback={
538
- <div class="flex">
539
- <Button
540
- size="large"
541
- variant="primary"
542
- class="w-1/2"
543
- onClick={share.shareSession}
544
- disabled={share.state.share}
545
- >
546
- {share.state.share
547
- ? language.t("session.share.action.publishing")
548
- : language.t("session.share.action.publish")}
549
- </Button>
550
- </div>
551
- }
552
- >
553
- <div class="flex flex-col gap-2">
554
- <TextField
555
- value={share.shareUrl() ?? ""}
556
- readOnly
557
- copyable
558
- copyKind="link"
559
- tabIndex={-1}
560
- class="w-full"
561
- />
562
- <div class="grid grid-cols-2 gap-2">
563
- <Button
564
- size="large"
565
- variant="secondary"
566
- class="w-full shadow-none border border-border-weak-base"
567
- onClick={share.unshareSession}
568
- disabled={share.state.unshare}
569
- >
570
- {share.state.unshare
571
- ? language.t("session.share.action.unpublishing")
572
- : language.t("session.share.action.unpublish")}
573
- </Button>
574
- <Button
575
- size="large"
576
- variant="primary"
577
- class="w-full"
578
- onClick={share.viewShare}
579
- disabled={share.state.unshare}
580
- >
581
- {language.t("session.share.action.view")}
582
- </Button>
583
- </div>
584
- </div>
585
- </Show>
586
- </div>
587
- </Popover>
588
- <Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
589
- <Tooltip
590
- value={
591
- share.state.copied
592
- ? language.t("session.share.copy.copied")
593
- : language.t("session.share.copy.copyLink")
594
- }
595
- placement="top"
596
- gutter={8}
597
- >
598
- <IconButton
599
- icon={share.state.copied ? "check" : "link"}
600
- variant="ghost"
601
- class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
602
- onClick={() => share.copyLink((error) => showRequestError(language, error))}
603
- disabled={share.state.unshare}
604
- aria-label={
605
- share.state.copied
606
- ? language.t("session.share.copy.copied")
607
- : language.t("session.share.copy.copyLink")
608
- }
609
- />
610
- </Tooltip>
611
- </Show>
612
- </div>
613
- </Show>
614
405
  <div class="flex items-center gap-1">
615
406
  <div class="hidden md:flex items-center gap-1 shrink-0">
616
407
  <TooltipKeybind
@@ -102,6 +102,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
102
102
  Persist.global("server", ["server.v3"]),
103
103
  createStore({
104
104
  list: [] as StoredServer[],
105
+ deleted: [] as ServerConnection.Key[],
105
106
  projects: {} as Record<string, StoredProject[]>,
106
107
  lastProject: {} as Record<string, string>,
107
108
  }),
@@ -110,8 +111,9 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
110
111
  const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
111
112
 
112
113
  const allServers = createMemo((): Array<ServerConnection.Any> => {
114
+ const deletedSet = new Set(store.deleted)
113
115
  const servers = [
114
- ...(props.servers ?? []),
116
+ ...(props.servers ?? []).filter((s) => !deletedSet.has(ServerConnection.key(s))),
115
117
  ...store.list.map((value) =>
116
118
  typeof value === "string"
117
119
  ? {
@@ -172,6 +174,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
172
174
  const url_ = normalizeServerUrl(input.http.url)
173
175
  if (!url_) return
174
176
  const conn = { ...input, http: { ...input.http, url: url_ } }
177
+ const connKey = ServerConnection.key(conn)
175
178
  return batch(() => {
176
179
  const existing = store.list.findIndex((x) => url(x) === url_)
177
180
  if (existing !== -1) {
@@ -179,18 +182,35 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
179
182
  } else {
180
183
  setStore("list", store.list.length, conn)
181
184
  }
182
- setState("active", ServerConnection.key(conn))
185
+ // If this server was previously deleted, un-delete it
186
+ if (store.deleted.includes(connKey)) {
187
+ setStore("deleted", store.deleted.filter((k) => k !== connKey))
188
+ }
189
+ setState("active", connKey)
183
190
  return conn
184
191
  })
185
192
  }
186
193
 
187
194
  function remove(key: ServerConnection.Key) {
188
195
  const list = store.list.filter((x) => url(x) !== key)
196
+ const isPropServer = (props.servers ?? []).some((s) => ServerConnection.key(s) === key)
189
197
  batch(() => {
190
198
  setStore("list", list)
199
+ // If it's a prop-injected server, record it as deleted so it stays gone after reload
200
+ if (isPropServer && !store.deleted.includes(key)) {
201
+ setStore("deleted", [...store.deleted, key])
202
+ }
191
203
  if (state.active === key) {
192
- const next = list[0]
193
- setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
204
+ const nextStored = list[0]
205
+ const nextProp = (props.servers ?? []).find(
206
+ (s) => !store.deleted.includes(ServerConnection.key(s)) && ServerConnection.key(s) !== key,
207
+ )
208
+ const nextKey = nextStored
209
+ ? ServerConnection.Key.make(url(nextStored))
210
+ : nextProp
211
+ ? ServerConnection.key(nextProp)
212
+ : props.defaultServer
213
+ setState("active", nextKey)
194
214
  }
195
215
  })
196
216
  }
package/src/i18n/en.ts CHANGED
@@ -610,7 +610,7 @@ export const dict = {
610
610
  "sidebar.project.viewAllSessions": "View all sessions",
611
611
  "sidebar.project.clearNotifications": "Clear notifications",
612
612
 
613
- "app.name.desktop": "OpenCode Desktop",
613
+ "app.name.desktop": "iKanban",
614
614
 
615
615
  "settings.section.desktop": "Desktop",
616
616
  "settings.section.server": "Server",
@@ -1,32 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test"
2
2
  import { dict as en } from "./en"
3
- import { dict as ar } from "./ar"
4
- import { dict as br } from "./br"
5
- import { dict as bs } from "./bs"
6
- import { dict as da } from "./da"
7
- import { dict as de } from "./de"
8
- import { dict as es } from "./es"
9
- import { dict as fr } from "./fr"
10
- import { dict as ja } from "./ja"
11
- import { dict as ko } from "./ko"
12
- import { dict as no } from "./no"
13
- import { dict as pl } from "./pl"
14
- import { dict as ru } from "./ru"
15
- import { dict as th } from "./th"
16
3
  import { dict as zh } from "./zh"
17
- import { dict as zht } from "./zht"
18
- import { dict as tr } from "./tr"
19
4
 
20
- const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, tr, zh, zht]
21
5
  const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
22
6
 
23
7
  describe("i18n parity", () => {
24
- test("non-English locales translate targeted unseen session keys", () => {
25
- for (const locale of locales) {
26
- for (const key of keys) {
27
- expect(locale[key]).toBeDefined()
28
- expect(locale[key]).not.toBe(en[key])
29
- }
8
+ test("zh translates targeted unseen session keys", () => {
9
+ for (const key of keys) {
10
+ expect(zh[key]).toBeDefined()
11
+ expect(zh[key]).not.toBe(en[key])
30
12
  }
31
13
  })
32
14
  })
package/src/i18n/zh.ts CHANGED
@@ -581,7 +581,7 @@ export const dict = {
581
581
  "sidebar.project.viewAllSessions": "查看全部会话",
582
582
  "sidebar.project.clearNotifications": "清除通知",
583
583
 
584
- "app.name.desktop": "OpenCode Desktop",
584
+ "app.name.desktop": "iKanban",
585
585
 
586
586
  "settings.section.desktop": "桌面",
587
587
  "settings.section.server": "服务器",
@@ -2080,7 +2080,7 @@ export default function Layout(props: ParentProps) {
2080
2080
  settingsKeybind={() => command.keybind("settings.open")}
2081
2081
  onOpenSettings={openSettings}
2082
2082
  helpLabel={() => language.t("sidebar.help")}
2083
- onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
2083
+ onOpenHelp={() => platform.openLink("https://github.com/isomoes/ikanban/issues")}
2084
2084
  renderPanel={() => (
2085
2085
  <Show when={currentProject()} keyed>
2086
2086
  {(project) => <SidebarPanel project={project} />}
@@ -2149,7 +2149,7 @@ export default function Layout(props: ParentProps) {
2149
2149
  settingsKeybind={() => command.keybind("settings.open")}
2150
2150
  onOpenSettings={openSettings}
2151
2151
  helpLabel={() => language.t("sidebar.help")}
2152
- onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
2152
+ onOpenHelp={() => platform.openLink("https://github.com/isomoes/ikanban/issues")}
2153
2153
  renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
2154
2154
  />
2155
2155
  </nav>
@@ -404,126 +404,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
404
404
  }),
405
405
  ]);
406
406
 
407
- const shareCommands = createMemo(() => {
408
- if (sync.data.config.share === "disabled") return [];
409
- return [
410
- sessionCommand({
411
- id: "session.share",
412
- title: info()?.share?.url
413
- ? language.t("session.share.copy.copyLink")
414
- : language.t("command.session.share"),
415
- description: info()?.share?.url
416
- ? language.t("toast.session.share.success.description")
417
- : language.t("command.session.share.description"),
418
- slash: "share",
419
- disabled: !params.id,
420
- onSelect: async () => {
421
- if (!params.id) return;
422
-
423
- const write = (value: string) => {
424
- const body =
425
- typeof document === "undefined" ? undefined : document.body;
426
- if (body) {
427
- const textarea = document.createElement("textarea");
428
- textarea.value = value;
429
- textarea.setAttribute("readonly", "");
430
- textarea.style.position = "fixed";
431
- textarea.style.opacity = "0";
432
- textarea.style.pointerEvents = "none";
433
- body.appendChild(textarea);
434
- textarea.select();
435
- const copied = document.execCommand("copy");
436
- body.removeChild(textarea);
437
- if (copied) return Promise.resolve(true);
438
- }
439
-
440
- const clipboard =
441
- typeof navigator === "undefined"
442
- ? undefined
443
- : navigator.clipboard;
444
- if (!clipboard?.writeText) return Promise.resolve(false);
445
- return clipboard.writeText(value).then(
446
- () => true,
447
- () => false,
448
- );
449
- };
450
-
451
- const copy = async (url: string, existing: boolean) => {
452
- const ok = await write(url);
453
- if (!ok) {
454
- showToast({
455
- title: language.t("toast.session.share.copyFailed.title"),
456
- variant: "error",
457
- });
458
- return;
459
- }
460
-
461
- showToast({
462
- title: existing
463
- ? language.t("session.share.copy.copied")
464
- : language.t("toast.session.share.success.title"),
465
- description: language.t(
466
- "toast.session.share.success.description",
467
- ),
468
- variant: "success",
469
- });
470
- };
471
-
472
- const existing = info()?.share?.url;
473
- if (existing) {
474
- await copy(existing, true);
475
- return;
476
- }
477
-
478
- const url = await sdk.client.session
479
- .share({ sessionID: params.id })
480
- .then((res) => res.data?.share?.url)
481
- .catch(() => undefined);
482
- if (!url) {
483
- showToast({
484
- title: language.t("toast.session.share.failed.title"),
485
- description: language.t("toast.session.share.failed.description"),
486
- variant: "error",
487
- });
488
- return;
489
- }
490
-
491
- await copy(url, false);
492
- },
493
- }),
494
- sessionCommand({
495
- id: "session.unshare",
496
- title: language.t("command.session.unshare"),
497
- description: language.t("command.session.unshare.description"),
498
- slash: "unshare",
499
- disabled: !params.id || !info()?.share?.url,
500
- onSelect: async () => {
501
- if (!params.id) return;
502
- await sdk.client.session
503
- .unshare({ sessionID: params.id })
504
- .then(() =>
505
- showToast({
506
- title: language.t("toast.session.unshare.success.title"),
507
- description: language.t(
508
- "toast.session.unshare.success.description",
509
- ),
510
- variant: "success",
511
- }),
512
- )
513
- .catch(() =>
514
- showToast({
515
- title: language.t("toast.session.unshare.failed.title"),
516
- description: language.t(
517
- "toast.session.unshare.failed.description",
518
- ),
519
- variant: "error",
520
- }),
521
- );
522
- },
523
- }),
524
- ];
525
- });
526
-
527
407
  command.register("session", () =>
528
408
  [
529
409
  sessionCommands(),
@@ -534,7 +414,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
534
414
  agentCommands(),
535
415
  permissionCommands(),
536
416
  sessionActionCommands(),
537
- shareCommands(),
538
417
  ].flatMap((x) => x),
539
418
  );
540
419
  };