ikanban-web 0.2.10 → 0.2.12

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 (36) hide show
  1. package/dist/assets/{ghostty-web-CkHg_Jfr.js → ghostty-web-CEQVZrGO.js} +1 -1
  2. package/dist/assets/home-BfGWAM3p.js +1 -0
  3. package/dist/assets/{index-DbuHW0gF.css → index-7dPULZd1.css} +1 -1
  4. package/dist/assets/{index-n2BMSLCz.js → index-WJDzUYhg.js} +106 -106
  5. package/dist/assets/session-BDqUCVwy.js +24 -0
  6. package/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/app.tsx +82 -61
  9. package/src/components/dialog-command-palette.tsx +70 -0
  10. package/src/components/dialog-fork.tsx +1 -1
  11. package/src/components/dialog-select-file.tsx +1 -1
  12. package/src/components/dialog-select-server.test.ts +16 -0
  13. package/src/components/dialog-select-server.tsx +59 -51
  14. package/src/components/prompt-input/submit.ts +1 -1
  15. package/src/components/server-list-item-frame.tsx +15 -0
  16. package/src/components/titlebar.tsx +12 -31
  17. package/src/context/global-sync/bootstrap-mode.ts +17 -0
  18. package/src/context/global-sync/bootstrap.ts +18 -10
  19. package/src/context/global-sync.test.ts +57 -0
  20. package/src/context/global-sync.tsx +5 -0
  21. package/src/context/notification.tsx +2 -2
  22. package/src/i18n/en.ts +13 -13
  23. package/src/i18n/zh.ts +13 -13
  24. package/src/pages/directory-layout.tsx +2 -2
  25. package/src/pages/home.tsx +104 -19
  26. package/src/pages/layout/sidebar-items.tsx +3 -3
  27. package/src/pages/layout/sidebar-project.tsx +1 -3
  28. package/src/pages/layout/sidebar-shell-helpers.ts +1 -1
  29. package/src/pages/layout/sidebar-shell.test.ts +2 -2
  30. package/src/pages/layout/sidebar-workspace.tsx +1 -1
  31. package/src/pages/layout.tsx +89 -179
  32. package/src/pages/session/message-timeline.tsx +4 -4
  33. package/src/pages/session/use-session-commands.tsx +1 -1
  34. package/src/pages/session.tsx +1 -1
  35. package/dist/assets/home-BTCNns6Z.js +0 -1
  36. package/dist/assets/session-S93i-VnN.js +0 -24
package/dist/index.html CHANGED
@@ -27,8 +27,8 @@
27
27
  <meta property="og:image" content="/ikanban/social-share.png" />
28
28
  <meta property="twitter:image" content="/social-share.png" />
29
29
  <script id="oc-theme-preload-script" src="/ikanban/oc-theme-preload.js"></script>
30
- <script type="module" crossorigin src="/ikanban/assets/index-n2BMSLCz.js"></script>
31
- <link rel="stylesheet" crossorigin href="/ikanban/assets/index-DbuHW0gF.css">
30
+ <script type="module" crossorigin src="/ikanban/assets/index-WJDzUYhg.js"></script>
31
+ <link rel="stylesheet" crossorigin href="/ikanban/assets/index-7dPULZd1.css">
32
32
  </head>
33
33
  <body class="antialiased overscroll-none text-12-regular overflow-hidden">
34
34
  <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.10",
3
+ "version": "0.2.12",
4
4
  "description": "iKanban web app",
5
5
  "private": false,
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -1,43 +1,54 @@
1
- import "@/index.css"
2
- import { File } from "ikanban-ui/file"
3
- import { I18nProvider } from "ikanban-ui/context"
4
- import { DialogProvider } from "ikanban-ui/context/dialog"
5
- import { FileComponentProvider } from "ikanban-ui/context/file"
6
- import { MarkedProvider } from "ikanban-ui/context/marked"
7
- import { Font } from "ikanban-ui/font"
8
- import { ThemeProvider } from "ikanban-ui/theme"
9
- import { MetaProvider } from "@solidjs/meta"
10
- import { Navigate, Route, Router } from "@solidjs/router"
11
- import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
12
- import { CommandProvider } from "@/context/command"
13
- import { CommentsProvider } from "@/context/comments"
14
- import { FileProvider } from "@/context/file"
15
- import { GlobalSDKProvider } from "@/context/global-sdk"
16
- import { GlobalSyncProvider } from "@/context/global-sync"
17
- import { HighlightsProvider } from "@/context/highlights"
18
- import { LanguageProvider, useLanguage } from "@/context/language"
19
- import { LayoutProvider } from "@/context/layout"
20
- import { ModelsProvider } from "@/context/models"
21
- import { NotificationProvider } from "@/context/notification"
22
- import { PermissionProvider } from "@/context/permission"
23
- import { usePlatform } from "@/context/platform"
24
- import { PromptProvider } from "@/context/prompt"
25
- import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
26
- import { SettingsProvider } from "@/context/settings"
27
- import { TerminalProvider } from "@/context/terminal"
28
- import DirectoryLayout from "@/pages/directory-layout"
29
- import Layout from "@/pages/layout"
30
- import { ErrorPage } from "./pages/error"
1
+ import "@/index.css";
2
+ import { File } from "ikanban-ui/file";
3
+ import { I18nProvider } from "ikanban-ui/context";
4
+ import { DialogProvider } from "ikanban-ui/context/dialog";
5
+ import { FileComponentProvider } from "ikanban-ui/context/file";
6
+ import { MarkedProvider } from "ikanban-ui/context/marked";
7
+ import { Font } from "ikanban-ui/font";
8
+ import { ThemeProvider } from "ikanban-ui/theme";
9
+ import { MetaProvider } from "@solidjs/meta";
10
+ import { Route, Router } from "@solidjs/router";
11
+ import {
12
+ ErrorBoundary,
13
+ type JSX,
14
+ lazy,
15
+ type ParentProps,
16
+ Show,
17
+ Suspense,
18
+ } from "solid-js";
19
+ import { CommandProvider } from "@/context/command";
20
+ import { CommentsProvider } from "@/context/comments";
21
+ import { FileProvider } from "@/context/file";
22
+ import { GlobalSDKProvider } from "@/context/global-sdk";
23
+ import { GlobalSyncProvider } from "@/context/global-sync";
24
+ import { HighlightsProvider } from "@/context/highlights";
25
+ import { LanguageProvider, useLanguage } from "@/context/language";
26
+ import { LayoutProvider } from "@/context/layout";
27
+ import { ModelsProvider } from "@/context/models";
28
+ import { NotificationProvider } from "@/context/notification";
29
+ import { PermissionProvider } from "@/context/permission";
30
+ import { usePlatform } from "@/context/platform";
31
+ import { PromptProvider } from "@/context/prompt";
32
+ import {
33
+ type ServerConnection,
34
+ ServerProvider,
35
+ useServer,
36
+ } from "@/context/server";
37
+ import { SettingsProvider } from "@/context/settings";
38
+ import { TerminalProvider } from "@/context/terminal";
39
+ import DirectoryLayout from "@/pages/directory-layout";
40
+ import Layout from "@/pages/layout";
41
+ import { ErrorPage } from "./pages/error";
31
42
 
32
- const Home = lazy(() => import("@/pages/home"))
33
- const Session = lazy(() => import("@/pages/session"))
34
- const Loading = () => <div class="size-full" />
43
+ const Home = lazy(() => import("@/pages/home"));
44
+ const Session = lazy(() => import("@/pages/session"));
45
+ const Loading = () => <div class="size-full" />;
35
46
 
36
47
  const HomeRoute = () => (
37
48
  <Suspense fallback={<Loading />}>
38
49
  <Home />
39
50
  </Suspense>
40
- )
51
+ );
41
52
 
42
53
  const SessionRoute = () => (
43
54
  <SessionProviders>
@@ -45,32 +56,36 @@ const SessionRoute = () => (
45
56
  <Session />
46
57
  </Suspense>
47
58
  </SessionProviders>
48
- )
59
+ );
49
60
 
50
- const SessionIndexRoute = () => (
51
- <Suspense fallback={<Loading />}>
52
- <Home />
53
- </Suspense>
54
- )
61
+ const SessionIndexRoute = () => <SessionRoute />;
55
62
 
56
63
  function UiI18nBridge(props: ParentProps) {
57
- const language = useLanguage()
58
- return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
64
+ const language = useLanguage();
65
+ return (
66
+ <I18nProvider value={{ locale: language.locale, t: language.t }}>
67
+ {props.children}
68
+ </I18nProvider>
69
+ );
59
70
  }
60
71
 
61
72
  declare global {
62
73
  interface Window {
63
74
  __OPENCODE__?: {
64
- updaterEnabled?: boolean
65
- deepLinks?: string[]
66
- wsl?: boolean
67
- }
75
+ updaterEnabled?: boolean;
76
+ deepLinks?: string[];
77
+ wsl?: boolean;
78
+ };
68
79
  }
69
80
  }
70
81
 
71
82
  function MarkedProviderWithNativeParser(props: ParentProps) {
72
- const platform = usePlatform()
73
- return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
83
+ const platform = usePlatform();
84
+ return (
85
+ <MarkedProvider nativeParser={platform.parseMarkdown}>
86
+ {props.children}
87
+ </MarkedProvider>
88
+ );
74
89
  }
75
90
 
76
91
  function AppShellProviders(props: ParentProps) {
@@ -90,7 +105,7 @@ function AppShellProviders(props: ParentProps) {
90
105
  </LayoutProvider>
91
106
  </PermissionProvider>
92
107
  </SettingsProvider>
93
- )
108
+ );
94
109
  }
95
110
 
96
111
  function SessionProviders(props: ParentProps) {
@@ -102,7 +117,7 @@ function SessionProviders(props: ParentProps) {
102
117
  </PromptProvider>
103
118
  </FileProvider>
104
119
  </TerminalProvider>
105
- )
120
+ );
106
121
  }
107
122
 
108
123
  function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
@@ -111,7 +126,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
111
126
  {props.appChildren}
112
127
  {props.children}
113
128
  </AppShellProviders>
114
- )
129
+ );
115
130
  }
116
131
 
117
132
  export function AppBaseProviders(props: ParentProps) {
@@ -124,7 +139,9 @@ export function AppBaseProviders(props: ParentProps) {
124
139
  <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
125
140
  <DialogProvider>
126
141
  <MarkedProviderWithNativeParser>
127
- <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
142
+ <FileComponentProvider component={File}>
143
+ {props.children}
144
+ </FileComponentProvider>
128
145
  </MarkedProviderWithNativeParser>
129
146
  </DialogProvider>
130
147
  </ErrorBoundary>
@@ -132,22 +149,22 @@ export function AppBaseProviders(props: ParentProps) {
132
149
  </LanguageProvider>
133
150
  </ThemeProvider>
134
151
  </MetaProvider>
135
- )
152
+ );
136
153
  }
137
154
 
138
155
  function ServerKey(props: ParentProps) {
139
- const server = useServer()
156
+ const server = useServer();
140
157
  return (
141
158
  <Show when={server.key} keyed>
142
159
  {props.children}
143
160
  </Show>
144
- )
161
+ );
145
162
  }
146
163
 
147
164
  export function AppInterface(props: {
148
- children?: JSX.Element
149
- defaultServer: ServerConnection.Key
150
- servers?: Array<ServerConnection.Any>
165
+ children?: JSX.Element;
166
+ defaultServer: ServerConnection.Key;
167
+ servers?: Array<ServerConnection.Any>;
151
168
  }) {
152
169
  return (
153
170
  <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
@@ -156,17 +173,21 @@ export function AppInterface(props: {
156
173
  <GlobalSyncProvider>
157
174
  <Router
158
175
  base={(import.meta.env.BASE_URL ?? "/").replace(/\/+$/, "")}
159
- root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
176
+ root={(routerProps) => (
177
+ <RouterRoot appChildren={props.children}>
178
+ {routerProps.children}
179
+ </RouterRoot>
180
+ )}
160
181
  >
161
182
  <Route path="/" component={HomeRoute} />
162
183
  <Route path="/:dir" component={DirectoryLayout}>
163
184
  <Route path="/" component={SessionIndexRoute} />
164
- <Route path="/session/:id?" component={SessionRoute} />
185
+ <Route path="/:id?" component={SessionRoute} />
165
186
  </Route>
166
187
  </Router>
167
188
  </GlobalSyncProvider>
168
189
  </GlobalSDKProvider>
169
190
  </ServerKey>
170
191
  </ServerProvider>
171
- )
192
+ );
172
193
  }
@@ -0,0 +1,70 @@
1
+ import { useDialog } from "ikanban-ui/context/dialog"
2
+ import { Dialog } from "ikanban-ui/dialog"
3
+ import { Keybind } from "ikanban-ui/keybind"
4
+ import { List } from "ikanban-ui/list"
5
+ import { createMemo, onCleanup, Show } from "solid-js"
6
+ import { type CommandOption, formatKeybind, useCommand } from "@/context/command"
7
+ import { useLanguage } from "@/context/language"
8
+
9
+ export function DialogCommandPalette() {
10
+ const command = useCommand()
11
+ const dialog = useDialog()
12
+ const language = useLanguage()
13
+ const state = {
14
+ cleanup: undefined as (() => void) | void,
15
+ committed: false,
16
+ }
17
+
18
+ const items = createMemo(() =>
19
+ command.options.filter((option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open"),
20
+ )
21
+
22
+ const handleMove = (item: CommandOption | undefined) => {
23
+ state.cleanup?.()
24
+ state.cleanup = item?.onHighlight?.()
25
+ }
26
+
27
+ onCleanup(() => {
28
+ if (state.committed) return
29
+ state.cleanup?.()
30
+ })
31
+
32
+ return (
33
+ <Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
34
+ <List
35
+ search={{
36
+ placeholder: language.t("palette.search.placeholder"),
37
+ autofocus: true,
38
+ hideIcon: true,
39
+ }}
40
+ emptyMessage={language.t("palette.empty")}
41
+ items={items}
42
+ key={(item) => item.id}
43
+ filterKeys={["title", "description", "category"]}
44
+ groupBy={(item) => item.category ?? language.t("palette.group.commands")}
45
+ onMove={handleMove}
46
+ onSelect={(item) => {
47
+ if (!item) return
48
+ state.committed = true
49
+ state.cleanup = undefined
50
+ dialog.close()
51
+ item.onSelect?.("palette")
52
+ }}
53
+ >
54
+ {(item) => (
55
+ <div class="w-full flex items-center justify-between gap-4">
56
+ <div class="flex items-center gap-2 min-w-0">
57
+ <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
58
+ <Show when={item.description}>
59
+ <span class="text-14-regular text-text-weak truncate">{item.description}</span>
60
+ </Show>
61
+ </div>
62
+ <Show when={item.keybind}>
63
+ <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
64
+ </Show>
65
+ </div>
66
+ )}
67
+ </List>
68
+ </Dialog>
69
+ )
70
+ }
@@ -75,7 +75,7 @@ export const DialogFork: Component = () => {
75
75
  return
76
76
  }
77
77
  dialog.close()
78
- navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
78
+ navigate(`/${base64Encode(sdk.directory)}/${forked.data.id}`)
79
79
  requestAnimationFrame(() => {
80
80
  prompt.set(restored)
81
81
  })
@@ -365,7 +365,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
365
365
 
366
366
  if (item.type === "session") {
367
367
  if (!item.directory || !item.sessionID) return
368
- navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
368
+ navigate(`/${base64Encode(item.directory)}/${item.sessionID}`)
369
369
  return
370
370
  }
371
371
 
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { readFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+
5
+ const dialogSource = readFileSync(join(import.meta.dir, "dialog-select-server.tsx"), "utf8")
6
+ const frameSource = readFileSync(join(import.meta.dir, "server-list-item-frame.tsx"), "utf8")
7
+
8
+ describe("DialogSelectServer structure", () => {
9
+ test("wraps each http server row so menu actions stay outside the list button", () => {
10
+ expect(dialogSource).toContain("itemWrapper={(item, node) =>")
11
+ expect(dialogSource).toContain("<ServerListItemFrame")
12
+ expect(dialogSource).toContain("row={node}")
13
+ expect(dialogSource).toContain("actions={")
14
+ expect(frameSource).toContain('data-slot="server-list-item-actions"')
15
+ })
16
+ })
@@ -11,6 +11,7 @@ import { useNavigate } from "@solidjs/router"
11
11
  import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
12
12
  import { createStore, reconcile } from "solid-js/store"
13
13
  import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
14
+ import { ServerListItemFrame } from "@/components/server-list-item-frame"
14
15
  import { useLanguage } from "@/context/language"
15
16
  import { usePlatform } from "@/context/platform"
16
17
  import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
@@ -532,6 +533,61 @@ export function DialogSelectServer() {
532
533
  emptyMessage={language.t("dialog.server.empty")}
533
534
  items={sortedItems}
534
535
  key={(x) => x.http.url}
536
+ itemWrapper={(item, node) => (
537
+ <Show
538
+ when={item.type === "http"}
539
+ fallback={node}
540
+ >
541
+ <ServerListItemFrame
542
+ row={node}
543
+ actions={
544
+ <DropdownMenu>
545
+ <DropdownMenu.Trigger
546
+ as={IconButton}
547
+ icon="dot-grid"
548
+ variant="ghost"
549
+ class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
550
+ onClick={(e: MouseEvent) => e.stopPropagation()}
551
+ onPointerDown={(e: PointerEvent) => e.stopPropagation()}
552
+ />
553
+ <DropdownMenu.Portal>
554
+ <DropdownMenu.Content class="mt-1">
555
+ <DropdownMenu.Item
556
+ onSelect={() => {
557
+ if (item.type !== "http") return
558
+ startEdit(item)
559
+ }}
560
+ >
561
+ <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
562
+ </DropdownMenu.Item>
563
+ <Show when={canDefault() && defaultUrl() !== item.http.url}>
564
+ <DropdownMenu.Item onSelect={() => setDefault(item.http.url)}>
565
+ <DropdownMenu.ItemLabel>
566
+ {language.t("dialog.server.menu.default")}
567
+ </DropdownMenu.ItemLabel>
568
+ </DropdownMenu.Item>
569
+ </Show>
570
+ <Show when={canDefault() && defaultUrl() === item.http.url}>
571
+ <DropdownMenu.Item onSelect={() => setDefault(null)}>
572
+ <DropdownMenu.ItemLabel>
573
+ {language.t("dialog.server.menu.defaultRemove")}
574
+ </DropdownMenu.ItemLabel>
575
+ </DropdownMenu.Item>
576
+ </Show>
577
+ <DropdownMenu.Separator />
578
+ <DropdownMenu.Item
579
+ onSelect={() => handleRemove(ServerConnection.key(item))}
580
+ class="text-text-on-critical-base hover:bg-surface-critical-weak"
581
+ >
582
+ <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
583
+ </DropdownMenu.Item>
584
+ </DropdownMenu.Content>
585
+ </DropdownMenu.Portal>
586
+ </DropdownMenu>
587
+ }
588
+ />
589
+ </Show>
590
+ )}
535
591
  onSelect={(x) => {
536
592
  if (x) select(x)
537
593
  }}
@@ -559,57 +615,9 @@ export function DialogSelectServer() {
559
615
  }
560
616
  showCredentials
561
617
  />
562
- <div class="flex items-center justify-center gap-4 pl-4">
563
- <Show when={ServerConnection.key(current()) === key}>
564
- <Icon name="check" class="h-6" />
565
- </Show>
566
-
567
- <Show when={i.type === "http"}>
568
- <DropdownMenu>
569
- <DropdownMenu.Trigger
570
- as={IconButton}
571
- icon="dot-grid"
572
- variant="ghost"
573
- class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
574
- onClick={(e: MouseEvent) => e.stopPropagation()}
575
- onPointerDown={(e: PointerEvent) => e.stopPropagation()}
576
- />
577
- <DropdownMenu.Portal>
578
- <DropdownMenu.Content class="mt-1">
579
- <DropdownMenu.Item
580
- onSelect={() => {
581
- if (i.type !== "http") return
582
- startEdit(i)
583
- }}
584
- >
585
- <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
586
- </DropdownMenu.Item>
587
- <Show when={canDefault() && defaultUrl() !== i.http.url}>
588
- <DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
589
- <DropdownMenu.ItemLabel>
590
- {language.t("dialog.server.menu.default")}
591
- </DropdownMenu.ItemLabel>
592
- </DropdownMenu.Item>
593
- </Show>
594
- <Show when={canDefault() && defaultUrl() === i.http.url}>
595
- <DropdownMenu.Item onSelect={() => setDefault(null)}>
596
- <DropdownMenu.ItemLabel>
597
- {language.t("dialog.server.menu.defaultRemove")}
598
- </DropdownMenu.ItemLabel>
599
- </DropdownMenu.Item>
600
- </Show>
601
- <DropdownMenu.Separator />
602
- <DropdownMenu.Item
603
- onSelect={() => handleRemove(ServerConnection.key(i))}
604
- class="text-text-on-critical-base hover:bg-surface-critical-weak"
605
- >
606
- <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
607
- </DropdownMenu.Item>
608
- </DropdownMenu.Content>
609
- </DropdownMenu.Portal>
610
- </DropdownMenu>
611
- </Show>
612
- </div>
618
+ <Show when={ServerConnection.key(current()) === key}>
619
+ <Icon name="check" class="h-6" />
620
+ </Show>
613
621
  </div>
614
622
  )
615
623
  }}
@@ -203,7 +203,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
203
203
  if (session) {
204
204
  if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
205
205
  layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
206
- navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
206
+ navigate(`/${base64Encode(sessionDirectory)}/${session.id}`)
207
207
  }
208
208
  }
209
209
  if (!session) {
@@ -0,0 +1,15 @@
1
+ /** @jsxImportSource solid-js */
2
+ import { Show, type JSX } from "solid-js"
3
+
4
+ export function ServerListItemFrame(props: { row: JSX.Element; actions?: JSX.Element }) {
5
+ return (
6
+ <div data-slot="server-list-item-frame" class="flex min-w-0 w-full items-center gap-1">
7
+ <div class="min-w-0 flex-1">{props.row}</div>
8
+ <Show when={props.actions}>
9
+ <div data-slot="server-list-item-actions" class="flex shrink-0 items-center justify-center pr-3">
10
+ {props.actions}
11
+ </div>
12
+ </Show>
13
+ </div>
14
+ )
15
+ }
@@ -2,7 +2,6 @@ import { createEffect, createMemo, Show, untrack } from "solid-js"
2
2
  import { createStore } from "solid-js/store"
3
3
  import { useLocation, useNavigate, useParams } from "@solidjs/router"
4
4
  import { IconButton } from "ikanban-ui/icon-button"
5
- import { Icon } from "ikanban-ui/icon"
6
5
  import { Button } from "ikanban-ui/button"
7
6
  import { Tooltip, TooltipKeybind } from "ikanban-ui/tooltip"
8
7
  import { useTheme } from "ikanban-ui/theme"
@@ -47,7 +46,6 @@ export function Titlebar() {
47
46
 
48
47
  const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
49
48
  const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
50
- const web = createMemo(() => platform.platform === "web")
51
49
  const zoom = () => platform.webviewZoom?.() ?? 1
52
50
  const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined)
53
51
 
@@ -192,34 +190,17 @@ export function Titlebar() {
192
190
  </div>
193
191
  </Show>
194
192
  <div class="flex items-center gap-1 shrink-0">
195
- <TooltipKeybind
196
- class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
197
- placement="bottom"
198
- title={language.t("command.sidebar.toggle")}
199
- keybind={command.keybind("sidebar.toggle")}
200
- >
201
- <Button
202
- variant="ghost"
203
- class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
204
- onClick={layout.sidebar.toggle}
205
- aria-label={language.t("command.sidebar.toggle")}
206
- aria-expanded={layout.sidebar.opened()}
207
- >
208
- <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
209
- <Icon
210
- size="small"
211
- name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
212
- class="group-hover/sidebar-toggle:hidden"
213
- />
214
- <Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
215
- <Icon
216
- size="small"
217
- name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
218
- class="hidden group-active/sidebar-toggle:inline-block"
219
- />
220
- </div>
221
- </Button>
222
- </TooltipKeybind>
193
+ <Show when={params.dir}>
194
+ <Tooltip placement="bottom" value={language.t("home.title")} openDelay={2000}>
195
+ <Button
196
+ variant="ghost"
197
+ icon="arrow-left"
198
+ class="titlebar-icon w-8 h-6 p-0 box-border"
199
+ onClick={() => navigate("/")}
200
+ aria-label={language.t("home.title")}
201
+ />
202
+ </Tooltip>
203
+ </Show>
223
204
  <div class="hidden xl:flex items-center shrink-0">
224
205
  <Show when={params.dir}>
225
206
  <TooltipKeybind
@@ -234,7 +215,7 @@ export function Titlebar() {
234
215
  class="titlebar-icon w-8 h-6 p-0 box-border"
235
216
  onClick={() => {
236
217
  if (!params.dir) return
237
- navigate(`/${params.dir}/session`)
218
+ navigate(`/${params.dir}`)
238
219
  }}
239
220
  aria-label={language.t("command.session.new")}
240
221
  />
@@ -0,0 +1,17 @@
1
+ function normalizeBase(base: string) {
2
+ const trimmed = base.replace(/\/+$/, "")
3
+ if (!trimmed) return "/"
4
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`
5
+ }
6
+
7
+ function stripBase(pathname: string, base: string) {
8
+ const normalizedBase = normalizeBase(base)
9
+ if (normalizedBase === "/") return pathname || "/"
10
+ if (pathname === normalizedBase) return "/"
11
+ if (pathname.startsWith(`${normalizedBase}/`)) return pathname.slice(normalizedBase.length) || "/"
12
+ return pathname || "/"
13
+ }
14
+
15
+ export function shouldLoadProjectsOnBootstrap(pathname: string, base: string) {
16
+ return stripBase(pathname, base) !== "/"
17
+ }
@@ -39,6 +39,7 @@ export async function bootstrapGlobal(input: {
39
39
  unknownError: string
40
40
  invalidConfigurationError: string
41
41
  formatMoreCount: (count: number) => string
42
+ loadProjects?: boolean
42
43
  setGlobalStore: SetStoreFunction<GlobalStore>
43
44
  }) {
44
45
  const health = await input.globalSDK.global
@@ -66,16 +67,6 @@ export async function bootstrapGlobal(input: {
66
67
  input.setGlobalStore("config", x.data!)
67
68
  }),
68
69
  ),
69
- retry(() =>
70
- input.globalSDK.project.list().then((x) => {
71
- const projects = (x.data ?? [])
72
- .filter((p) => !!p?.id)
73
- .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
74
- .slice()
75
- .sort((a, b) => cmp(a.id, b.id))
76
- input.setGlobalStore("project", projects)
77
- }),
78
- ),
79
70
  retry(() =>
80
71
  input.globalSDK.provider.list().then((x) => {
81
72
  input.setGlobalStore("provider", normalizeProviderList(x.data!))
@@ -88,6 +79,23 @@ export async function bootstrapGlobal(input: {
88
79
  ),
89
80
  ]
90
81
 
82
+ if (input.loadProjects !== false) {
83
+ tasks.splice(
84
+ 2,
85
+ 0,
86
+ retry(() =>
87
+ input.globalSDK.project.list().then((x) => {
88
+ const projects = (x.data ?? [])
89
+ .filter((p) => !!p?.id)
90
+ .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
91
+ .slice()
92
+ .sort((a, b) => cmp(a.id, b.id))
93
+ input.setGlobalStore("project", projects)
94
+ }),
95
+ ),
96
+ )
97
+ }
98
+
91
99
  const results = await Promise.allSettled(tasks)
92
100
  const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
93
101
  if (errors.length) {