ikanban-web 0.2.9 → 0.2.11

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.
@@ -1,56 +1,188 @@
1
- import { createMemo, For, Match, Switch } from "solid-js"
2
- import { Button } from "ikanban-ui/button"
3
- import { Logo } from "ikanban-ui/logo"
4
- import { useLayout } from "@/context/layout"
5
- import { useNavigate } from "@solidjs/router"
6
- import { base64Encode } from "ikanban-utils/encode"
7
- import { Icon } from "ikanban-ui/icon"
8
- import { usePlatform } from "@/context/platform"
9
- import { DateTime } from "luxon"
10
- import { useDialog } from "ikanban-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"
1
+ import { createMemo, createEffect, createResource, For, Match, Switch } from "solid-js";
2
+ import { Button } from "ikanban-ui/button";
3
+ import { Logo } from "ikanban-ui/logo";
4
+ import { useLayout } from "@/context/layout";
5
+ import { useNavigate } from "@solidjs/router";
6
+ import { base64Encode } from "ikanban-utils/encode";
7
+ import { Icon } from "ikanban-ui/icon";
8
+ import { usePlatform } from "@/context/platform";
9
+ import { DateTime } from "luxon";
10
+ import { useDialog } from "ikanban-ui/context/dialog";
11
+ import { DialogSelectDirectory } from "@/components/dialog-select-directory";
12
+ import { DialogSelectServer } from "@/components/dialog-select-server";
13
+ import { useGlobalSDK } from "@/context/global-sdk";
14
+ import { useServer } from "@/context/server";
15
+ import { useGlobalSync } from "@/context/global-sync";
16
+ import { useLanguage } from "@/context/language";
17
+ import { IconButton } from "ikanban-ui/icon-button";
18
+ import type { Session } from "@opencode-ai/sdk/v2/client";
19
+
20
+ type BoardColumn = "progress" | "idle";
21
+
22
+ type BoardCard = {
23
+ session: Session;
24
+ projectDirectory: string;
25
+ updatedAt: number;
26
+ };
27
+
28
+ const homeStyles = {
29
+ border: { border: "1px solid var(--border-weak-base)" },
30
+ heroIcon: {
31
+ border: "1px solid var(--border-weak-base)",
32
+ background:
33
+ "linear-gradient(135deg, color-mix(in srgb, var(--text-interactive-base) 12%, var(--background-stronger)), var(--background-stronger))",
34
+ },
35
+ emptyPanel: {
36
+ "border-color": "var(--border-weak-base)",
37
+ background:
38
+ "linear-gradient(180deg, color-mix(in srgb, var(--surface-base-hover) 35%, var(--background-stronger)), var(--background-stronger))",
39
+ },
40
+ emptyIcon: {
41
+ border: "1px solid var(--border-weak-base)",
42
+ background:
43
+ "linear-gradient(135deg, color-mix(in srgb, var(--text-interactive-base) 10%, var(--surface-inset-base)), var(--surface-inset-base))",
44
+ },
45
+ card: {
46
+ border: "1px solid var(--border-weak-base)",
47
+ background: "var(--background-base)",
48
+ },
49
+ emptyState: {
50
+ "border-color": "var(--border-weak-base)",
51
+ background:
52
+ "linear-gradient(180deg, color-mix(in srgb, var(--surface-base-hover) 30%, var(--surface-inset-base)), var(--surface-inset-base))",
53
+ },
54
+ } as const;
16
55
 
17
56
  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
- const recent = createMemo(() => {
57
+ const sync = useGlobalSync();
58
+ const layout = useLayout();
59
+ const platform = usePlatform();
60
+ const dialog = useDialog();
61
+ const navigate = useNavigate();
62
+ const globalSDK = useGlobalSDK();
63
+ const server = useServer();
64
+ const language = useLanguage();
65
+ const projects = createMemo(() => {
27
66
  return sync.data.project
28
67
  .slice()
29
- .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
30
- .slice(0, 5)
31
- })
68
+ .sort(
69
+ (a, b) =>
70
+ (b.time.updated ?? b.time.created) -
71
+ (a.time.updated ?? a.time.created),
72
+ );
73
+ });
74
+
75
+ const [rootSessions, { mutate: mutateRootSessions }] = createResource(
76
+ () => projects().map((project) => project.worktree),
77
+ async (directories) => {
78
+ const entries = await Promise.all(
79
+ directories.map(async (directory) => {
80
+ const result = await globalSDK.client.session.list({
81
+ directory,
82
+ roots: true,
83
+ });
84
+
85
+ const sessions = (result.data ?? []).filter(
86
+ (session): session is Session => !!session?.id && !session.time?.archived,
87
+ );
88
+
89
+ return [directory, sessions] as const;
90
+ }),
91
+ );
92
+
93
+ return Object.fromEntries(entries) as Record<string, Session[]>;
94
+ },
95
+ );
96
+
97
+ const boardColumns = createMemo<Record<BoardColumn, BoardCard[]>>(() => {
98
+ const now = Date.now();
99
+ const columns: Record<BoardColumn, BoardCard[]> = {
100
+ progress: [],
101
+ idle: [],
102
+ };
103
+ const sessionsByProject = rootSessions() ?? {};
104
+
105
+ for (const project of projects()) {
106
+ const [store] = sync.child(project.worktree);
107
+ const sessions = sessionsByProject[project.worktree] ?? [];
108
+
109
+ for (const session of sessions) {
110
+ const status = store.session_status[session.id];
111
+ const updatedAt = session.time.updated ?? session.time.created;
112
+ const card = {
113
+ session,
114
+ projectDirectory: project.worktree,
115
+ updatedAt,
116
+ };
117
+
118
+ if (status?.type === "busy" || status?.type === "retry") {
119
+ columns.progress.push(card);
120
+ continue;
121
+ }
122
+
123
+ columns.idle.push(card);
124
+ }
125
+ }
126
+
127
+ for (const key of Object.keys(columns) as BoardColumn[]) {
128
+ columns[key].sort((a, b) => b.updatedAt - a.updatedAt);
129
+ }
130
+
131
+ return columns;
132
+ });
133
+
134
+ createEffect(() => {
135
+ for (const project of projects()) {
136
+ sync.child(project.worktree);
137
+ }
138
+ });
32
139
 
33
140
  const serverDotClass = createMemo(() => {
34
- const healthy = server.healthy()
35
- if (healthy === true) return "bg-icon-success-base"
36
- if (healthy === false) return "bg-icon-critical-base"
37
- return "bg-border-weak-base"
38
- })
141
+ const healthy = server.healthy();
142
+ if (healthy === true) return "bg-icon-success-base";
143
+ if (healthy === false) return "bg-icon-critical-base";
144
+ return "bg-border-weak-base";
145
+ });
39
146
 
40
147
  function openProject(directory: string) {
41
- layout.projects.open(directory)
42
- server.projects.touch(directory)
43
- navigate(`/${base64Encode(directory)}`)
148
+ layout.projects.open(directory);
149
+ server.projects.touch(directory);
150
+ navigate(`/${base64Encode(directory)}`);
151
+ }
152
+
153
+ function openSession(directory: string, sessionID: string) {
154
+ layout.projects.open(directory);
155
+ server.projects.touch(directory);
156
+ navigate(`/${base64Encode(directory)}/${sessionID}`);
157
+ }
158
+
159
+ async function archiveSession(directory: string, sessionID: string) {
160
+ const [, setStore] = sync.child(directory, { bootstrap: false });
161
+ await globalSDK.client.session.update({
162
+ sessionID,
163
+ time: { archived: Date.now() },
164
+ });
165
+ mutateRootSessions((current) => {
166
+ if (!current?.[directory]) return current;
167
+ return {
168
+ ...current,
169
+ [directory]: current[directory].filter((session) => session.id !== sessionID),
170
+ };
171
+ });
172
+ setStore(
173
+ "session",
174
+ (sessions) => sessions.filter((session) => session.id !== sessionID),
175
+ );
44
176
  }
45
177
 
46
178
  async function chooseProject() {
47
179
  function resolve(result: string | string[] | null) {
48
180
  if (Array.isArray(result)) {
49
181
  for (const directory of result) {
50
- openProject(directory)
182
+ openProject(directory);
51
183
  }
52
184
  } else if (result) {
53
- openProject(result)
185
+ openProject(result);
54
186
  }
55
187
  }
56
188
 
@@ -58,74 +190,227 @@ export default function Home() {
58
190
  const result = await platform.openDirectoryPickerDialog?.({
59
191
  title: language.t("command.project.open"),
60
192
  multiple: true,
61
- })
62
- resolve(result)
193
+ });
194
+ resolve(result);
63
195
  } else {
64
196
  dialog.show(
65
197
  () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
66
198
  () => resolve(null),
67
- )
199
+ );
68
200
  }
69
201
  }
70
202
 
71
203
  return (
72
- <div class="mx-auto mt-55 w-full md:w-auto px-4">
73
- <Logo class="md:w-xl opacity-12" />
74
- <Button
75
- size="large"
76
- variant="ghost"
77
- class="mt-4 mx-auto text-14-regular text-text-weak"
78
- onClick={() => dialog.show(() => <DialogSelectServer />)}
79
- >
80
- <div
81
- classList={{
82
- "size-2 rounded-full": true,
83
- [serverDotClass()]: true,
84
- }}
85
- />
86
- {server.name}
87
- </Button>
204
+ <div class="mx-auto w-full max-w-7xl px-4 py-6 md:px-6 md:py-8">
205
+ <div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
206
+ <div class="min-w-0">
207
+ <div class="flex items-center gap-3">
208
+ <div
209
+ class="flex size-10 items-center justify-center rounded-2xl border shadow-xs-border-base"
210
+ style={homeStyles.heroIcon}
211
+ >
212
+ <Logo class="w-6 opacity-90" />
213
+ </div>
214
+ <div>
215
+ <div class="text-18-medium text-text-strong">
216
+ {language.t("home.sessionBoard")}
217
+ </div>
218
+ <div class="mt-0.5 text-12-regular text-text-weak">
219
+ {language.t("home.sessionBoard.description")}
220
+ </div>
221
+ </div>
222
+ </div>
223
+ </div>
224
+
225
+ <div class="flex items-center gap-2 self-start md:justify-end">
226
+ <Button
227
+ icon="folder-add-left"
228
+ size="normal"
229
+ class="pl-2 pr-3"
230
+ onClick={chooseProject}
231
+ >
232
+ {language.t("command.project.open")}
233
+ </Button>
234
+ <Button
235
+ size="normal"
236
+ variant="ghost"
237
+ class="pr-3 text-13-regular text-text-weak"
238
+ onClick={() => dialog.show(() => <DialogSelectServer />)}
239
+ >
240
+ <div
241
+ classList={{
242
+ "size-2 rounded-full": true,
243
+ [serverDotClass()]: true,
244
+ }}
245
+ />
246
+ {server.name}
247
+ </Button>
248
+ </div>
249
+ </div>
250
+
88
251
  <Switch>
89
- <Match when={sync.data.project.length > 0}>
90
- <div class="mt-20 w-full flex flex-col gap-4">
91
- <div class="flex gap-2 items-center justify-between pl-3">
92
- <div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
93
- <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
252
+ <Match when={projects().length > 0}>
253
+ <div class="mt-6 flex w-full flex-col gap-6">
254
+ <section class="flex flex-col gap-4">
255
+ <div class="grid gap-3 lg:grid-cols-2">
256
+ <BoardColumnView
257
+ title={language.t("home.sessionBoard.progress")}
258
+ icon="brain"
259
+ tone="progress"
260
+ cards={boardColumns().progress}
261
+ onOpen={openSession}
262
+ onArchive={archiveSession}
263
+ empty={language.t("home.sessionBoard.emptyProgress")}
264
+ />
265
+ <BoardColumnView
266
+ title={language.t("home.sessionBoard.idle")}
267
+ icon="dash"
268
+ tone="idle"
269
+ cards={boardColumns().idle}
270
+ onOpen={openSession}
271
+ onArchive={archiveSession}
272
+ empty={language.t("home.sessionBoard.emptyIdle")}
273
+ />
274
+ </div>
275
+ </section>
276
+ </div>
277
+ </Match>
278
+ <Match when={true}>
279
+ <div
280
+ class="mt-6 rounded-2xl border border-dashed px-6 py-10 shadow-xs-border-base"
281
+ style={homeStyles.emptyPanel}
282
+ >
283
+ <div class="mx-auto flex max-w-md flex-col items-center gap-3 text-center">
284
+ <div
285
+ class="flex size-12 items-center justify-center rounded-2xl border shadow-xs-border-base"
286
+ style={homeStyles.emptyIcon}
287
+ >
288
+ <Icon name="folder-add-left" size="large" />
289
+ </div>
290
+ <div class="flex flex-col gap-1 items-center justify-center">
291
+ <div class="text-14-medium text-text-strong">
292
+ {language.t("home.empty.title")}
293
+ </div>
294
+ <div class="text-12-regular text-text-weak">
295
+ {language.t("home.empty.description")}
296
+ </div>
297
+ </div>
298
+ <Button class="mt-1 px-3" onClick={chooseProject}>
94
299
  {language.t("command.project.open")}
95
300
  </Button>
96
301
  </div>
97
- <ul class="flex flex-col gap-2">
98
- <For each={recent()}>
99
- {(project) => (
302
+ </div>
303
+ </Match>
304
+ </Switch>
305
+ </div>
306
+ );
307
+ }
308
+
309
+ function BoardColumnView(props: {
310
+ title: string;
311
+ icon: "brain" | "dash";
312
+ tone: BoardColumn;
313
+ cards: BoardCard[];
314
+ empty: string;
315
+ onOpen: (directory: string, sessionID: string) => void;
316
+ onArchive: (directory: string, sessionID: string) => void | Promise<void>;
317
+ }) {
318
+ const toneClass = () => {
319
+ if (props.tone === "progress")
320
+ return "bg-surface-base-hover text-icon-success-base border-border-weak-base";
321
+ return "bg-surface-inset-base text-icon-base border-border-weak-base";
322
+ };
323
+
324
+ const sectionStyle = () =>
325
+ props.tone === "progress"
326
+ ? {
327
+ ...homeStyles.border,
328
+ background:
329
+ "linear-gradient(180deg, color-mix(in srgb, var(--text-interactive-base) 8%, var(--background-stronger)), var(--background-stronger))",
330
+ }
331
+ : {
332
+ ...homeStyles.border,
333
+ background:
334
+ "linear-gradient(180deg, color-mix(in srgb, var(--surface-inset-base) 55%, var(--background-stronger)), var(--background-stronger))",
335
+ };
336
+
337
+ return (
338
+ <section class="rounded-2xl p-3 md:p-4 min-h-72 shadow-xs-border-base" style={sectionStyle()}>
339
+ <div class="flex items-center justify-between gap-3">
340
+ <div class="flex items-center gap-2 min-w-0">
341
+ <div
342
+ class={`size-7 rounded-full border flex items-center justify-center ${toneClass()}`}
343
+ >
344
+ <Icon name={props.icon} size="small" />
345
+ </div>
346
+ <div class="text-14-medium text-text-strong">{props.title}</div>
347
+ </div>
348
+ <div class="text-12-mono text-text-weak">{props.cards.length}</div>
349
+ </div>
350
+
351
+ <div class="mt-3 flex flex-col gap-2">
352
+ <Switch>
353
+ <Match when={props.cards.length > 0}>
354
+ <For each={props.cards}>
355
+ {(card) => (
356
+ <div
357
+ class="overflow-hidden rounded-xl transition-colors"
358
+ style={homeStyles.card}
359
+ >
360
+ <div class="flex items-start justify-between gap-3 px-3 pt-3">
361
+ <div class="min-w-0 flex-1 pt-1">
362
+ <div class="truncate text-14-medium leading-tight text-text-strong">
363
+ {card.session.title}
364
+ </div>
365
+ </div>
366
+ <IconButton
367
+ icon="archive"
368
+ variant="ghost"
369
+ class="size-7 rounded-md"
370
+ aria-label="Archive session"
371
+ onClick={(event) => {
372
+ event.preventDefault();
373
+ event.stopPropagation();
374
+ void props.onArchive(card.projectDirectory, card.session.id);
375
+ }}
376
+ />
377
+ </div>
100
378
  <Button
101
- size="large"
102
379
  variant="ghost"
103
- class="text-14-mono text-left justify-between px-3"
104
- onClick={() => openProject(project.worktree)}
380
+ class="flex h-auto w-full min-w-0 items-center justify-between gap-3 rounded-none border-0 bg-transparent px-3 pb-3 pt-2 text-left"
381
+ onClick={() =>
382
+ props.onOpen(card.projectDirectory, card.session.id)
383
+ }
105
384
  >
106
- {project.worktree.replace(homedir(), "~")}
107
- <div class="text-14-regular text-text-weak">
108
- {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
385
+ <span class="min-w-0 flex-1 truncate text-12-regular text-text-weak">
386
+ {card.projectDirectory.replace(
387
+ /^.*?([^/\\]+(?:[/\\][^/\\]+)?)$/,
388
+ "$1",
389
+ )}
390
+ </span>
391
+ <div class="flex shrink-0 items-center gap-2 text-12-regular text-text-weak">
392
+ <span>{DateTime.fromMillis(card.updatedAt).toRelative()}</span>
393
+ <Icon
394
+ name="arrow-right"
395
+ size="small"
396
+ class="text-icon-weak shrink-0"
397
+ />
109
398
  </div>
110
399
  </Button>
111
- )}
112
- </For>
113
- </ul>
114
- </div>
115
- </Match>
116
- <Match when={true}>
117
- <div class="mt-30 mx-auto flex flex-col items-center gap-3">
118
- <Icon name="folder-add-left" size="large" />
119
- <div class="flex flex-col gap-1 items-center justify-center">
120
- <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
121
- <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
400
+ </div>
401
+ )}
402
+ </For>
403
+ </Match>
404
+ <Match when={true}>
405
+ <div
406
+ class="rounded-xl border border-dashed px-3 py-8 text-center text-12-regular text-text-weak"
407
+ style={homeStyles.emptyState}
408
+ >
409
+ {props.empty}
122
410
  </div>
123
- <Button class="px-3 mt-1" onClick={chooseProject}>
124
- {language.t("command.project.open")}
125
- </Button>
126
- </div>
127
- </Match>
128
- </Switch>
129
- </div>
130
- )
411
+ </Match>
412
+ </Switch>
413
+ </div>
414
+ </section>
415
+ );
131
416
  }
@@ -101,7 +101,7 @@ const SessionRow = (props: {
101
101
  cancelHoverPrefetch: () => void
102
102
  }): JSX.Element => (
103
103
  <A
104
- href={`/${props.slug}/session/${props.session.id}`}
104
+ href={`/${props.slug}/${props.session.id}`}
105
105
  class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
106
106
  onPointerEnter={props.scheduleHoverPrefetch}
107
107
  onPointerLeave={props.cancelHoverPrefetch}
@@ -310,7 +310,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
310
310
  onMessageSelect={(message) => {
311
311
  if (!isActive()) {
312
312
  layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
313
- navigate(`${props.slug}/session/${props.session.id}`)
313
+ navigate(`/${props.slug}/${props.session.id}`)
314
314
  return
315
315
  }
316
316
  window.history.replaceState(null, "", `#message-${message.id}`)
@@ -360,7 +360,7 @@ export const NewSessionItem = (props: {
360
360
  const tooltip = () => props.mobile || !props.sidebarExpanded()
361
361
  const item = (
362
362
  <A
363
- href={`/${props.slug}/session`}
363
+ href={`/${props.slug}`}
364
364
  end
365
365
  class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
366
366
  onClick={() => {
@@ -437,7 +437,7 @@ export const SortableWorkspace = (props: {
437
437
  root={props.project.worktree}
438
438
  setHoverSession={props.ctx.setHoverSession}
439
439
  clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
440
- navigateToNewSession={() => navigate(`/${slug()}/session`)}
440
+ navigateToNewSession={() => navigate(`/${slug()}`)}
441
441
  />
442
442
  </div>
443
443
  </div>