ikanban-web 0.2.6 → 0.2.8

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 (43) hide show
  1. package/README.md +25 -20
  2. package/dist/assets/{ghostty-web-BsS8qmby.js → ghostty-web--axxBQj6.js} +1 -1
  3. package/dist/assets/{home-BVcfU4oW.js → home-BAEIhduF.js} +1 -1
  4. package/dist/assets/{index-B1DFsnCu.css → index-B0TEjAod.css} +1 -1
  5. package/dist/assets/{index-BbYqEnGb.js → index-BR3vRMWX.js} +124 -116
  6. package/dist/assets/session-Bp9iU6SU.js +24 -0
  7. package/dist/index.html +19 -6
  8. package/package.json +1 -1
  9. package/src/components/dialog-session-timeline.tsx +85 -0
  10. package/src/components/prompt-input.tsx +3 -3
  11. package/src/components/session/session-header.tsx +2 -211
  12. package/src/components/settings-general.tsx +18 -93
  13. package/src/context/global-sync/child-store.ts +1 -0
  14. package/src/context/global-sync/event-reducer.test.ts +5 -0
  15. package/src/context/global-sync/event-reducer.ts +263 -183
  16. package/src/context/global-sync/types.ts +3 -0
  17. package/src/context/highlights.tsx +4 -176
  18. package/src/context/server.tsx +24 -4
  19. package/src/context/sync.tsx +237 -2
  20. package/src/i18n/en.ts +12 -1
  21. package/src/i18n/parity.test.ts +4 -22
  22. package/src/i18n/zh.ts +23 -1
  23. package/src/pages/layout.tsx +13 -56
  24. package/src/pages/session/review-tab.tsx +3 -1
  25. package/src/pages/session/session-side-panel.tsx +17 -51
  26. package/src/pages/session/use-session-commands.tsx +91 -154
  27. package/src/pages/session.tsx +87 -16
  28. package/dist/assets/session-CZ-wC_3g.js +0 -24
  29. package/src/i18n/ar.ts +0 -751
  30. package/src/i18n/br.ts +0 -759
  31. package/src/i18n/bs.ts +0 -836
  32. package/src/i18n/da.ts +0 -830
  33. package/src/i18n/de.ts +0 -768
  34. package/src/i18n/es.ts +0 -842
  35. package/src/i18n/fr.ts +0 -766
  36. package/src/i18n/ja.ts +0 -755
  37. package/src/i18n/ko.ts +0 -755
  38. package/src/i18n/no.ts +0 -838
  39. package/src/i18n/pl.ts +0 -757
  40. package/src/i18n/ru.ts +0 -838
  41. package/src/i18n/th.ts +0 -828
  42. package/src/i18n/tr.ts +0 -849
  43. package/src/i18n/zht.ts +0 -821
package/src/i18n/zh.ts CHANGED
@@ -28,6 +28,7 @@ export const dict = {
28
28
  "command.sidebar.toggle": "切换侧边栏",
29
29
 
30
30
  "command.project.open": "打开项目",
31
+ "command.project.close": "关闭项目",
31
32
 
32
33
  "command.provider.connect": "连接提供商",
33
34
 
@@ -104,6 +105,8 @@ export const dict = {
104
105
 
105
106
  "command.session.undo": "撤销",
106
107
  "command.session.undo.description": "撤销上一条消息",
108
+ "command.session.timeline": "时间线",
109
+ "command.session.timeline.description": "跳转到此会话时间线中的任意消息",
107
110
  "command.session.redo": "重做",
108
111
  "command.session.redo.description": "重做上一条撤销的消息",
109
112
  "command.session.compact": "精简会话",
@@ -139,7 +142,9 @@ export const dict = {
139
142
  "dialog.model.search.placeholder": "搜索模型",
140
143
  "dialog.model.empty": "未找到模型",
141
144
  "dialog.model.manage": "管理模型",
145
+ "dialog.model.recent": "最近",
142
146
  "dialog.model.manage.description": "自定义模型选择器中显示的模型。",
147
+ "dialog.model.manage.provider.toggle": "切换 {{provider}} 的所有模型",
143
148
  "dialog.model.unpaid.freeModels.title": "OpenCode 提供的免费模型",
144
149
  "dialog.model.unpaid.addMore.title": "从热门提供商添加更多模型",
145
150
 
@@ -320,6 +325,8 @@ export const dict = {
320
325
  "mcp.status.disabled": "已禁用",
321
326
 
322
327
  "dialog.fork.empty": "没有可用于分叉的消息",
328
+ "dialog.timeline.empty": "此时间线中没有消息",
329
+ "dialog.timeline.latest": "最新消息",
323
330
 
324
331
  "dialog.directory.search.placeholder": "搜索文件夹",
325
332
  "dialog.directory.empty": "未找到文件夹",
@@ -334,6 +341,11 @@ export const dict = {
334
341
  "dialog.server.add.error": "无法连接到服务器",
335
342
  "dialog.server.add.checking": "检查中...",
336
343
  "dialog.server.add.button": "添加服务器",
344
+ "dialog.server.add.name": "服务器名称(可选)",
345
+ "dialog.server.add.namePlaceholder": "Localhost",
346
+ "dialog.server.add.username": "用户名(可选)",
347
+ "dialog.server.add.password": "密码(可选)",
348
+ "dialog.server.edit.title": "编辑服务器",
337
349
  "dialog.server.default.title": "默认服务器",
338
350
  "dialog.server.default.description": "应用启动时连接此服务器,而不是启动本地服务器。需要重启。",
339
351
  "dialog.server.default.none": "未选择服务器",
@@ -406,6 +418,7 @@ export const dict = {
406
418
  "language.br": "Português (Brasil)",
407
419
  "language.bs": "Bosanski",
408
420
  "language.th": "ไทย",
421
+ "language.tr": "Türkçe",
409
422
 
410
423
  "toast.language.title": "语言",
411
424
  "toast.language.description": "已切换到{{language}}",
@@ -497,6 +510,9 @@ export const dict = {
497
510
  "session.review.change.other": "更改",
498
511
  "session.review.loadingChanges": "正在加载更改...",
499
512
  "session.review.empty": "此会话暂无更改",
513
+ "session.review.noVcs": "未检测到 Git 版本控制系统,因此无法检测会话更改",
514
+ "session.review.emptyProject": "当前没有项目更改",
515
+ "session.review.noVcsProject": "未检测到 Git 版本控制系统,因此无法检测项目更改",
500
516
  "session.review.noChanges": "无更改",
501
517
  "session.files.selectToOpen": "选择要打开的文件",
502
518
  "session.files.all": "所有文件",
@@ -581,7 +597,7 @@ export const dict = {
581
597
  "sidebar.project.viewAllSessions": "查看全部会话",
582
598
  "sidebar.project.clearNotifications": "清除通知",
583
599
 
584
- "app.name.desktop": "OpenCode Desktop",
600
+ "app.name.desktop": "iKanban",
585
601
 
586
602
  "settings.section.desktop": "桌面",
587
603
  "settings.section.server": "服务器",
@@ -597,6 +613,7 @@ export const dict = {
597
613
  "settings.general.section.notifications": "系统通知",
598
614
  "settings.general.section.updates": "更新",
599
615
  "settings.general.section.sounds": "音效",
616
+ "settings.general.section.releaseNotes": "发行说明",
600
617
  "settings.general.section.feed": "动态",
601
618
  "settings.general.section.display": "显示",
602
619
  "settings.general.row.language.title": "语言",
@@ -607,6 +624,8 @@ export const dict = {
607
624
  "settings.general.row.theme.description": "自定义 OpenCode 的主题。",
608
625
  "settings.general.row.font.title": "字体",
609
626
  "settings.general.row.font.description": "自定义代码块使用的等宽字体",
627
+ "settings.general.row.reasoningSummaries.title": "显示推理摘要",
628
+ "settings.general.row.reasoningSummaries.description": "在时间线中显示模型推理摘要",
610
629
  "settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分",
611
630
  "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
612
631
  "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
@@ -616,6 +635,9 @@ export const dict = {
616
635
  "settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",
617
636
  "settings.general.row.releaseNotes.title": "发行说明",
618
637
  "settings.general.row.releaseNotes.description": "更新后显示“新功能”弹窗",
638
+ "settings.general.row.releaseNotesCurrent.title": "当前版本变更",
639
+ "settings.general.row.releaseNotesCurrent.description": "在 GitHub 打开当前版本的更新记录",
640
+ "settings.general.action.releaseNotesCurrent": "查看变更",
619
641
 
620
642
  "settings.updates.row.startup.title": "启动时检查更新",
621
643
  "settings.updates.row.startup.description": "在 OpenCode 启动时自动检查更新",
@@ -279,59 +279,6 @@ export default function Layout(props: ParentProps) {
279
279
  setLocale(next)
280
280
  }
281
281
 
282
- const useUpdatePolling = () =>
283
- onMount(() => {
284
- if (!platform.checkUpdate || !platform.update || !platform.restart) return
285
-
286
- let toastId: number | undefined
287
- let interval: ReturnType<typeof setInterval> | undefined
288
-
289
- const pollUpdate = () =>
290
- platform.checkUpdate!().then(({ updateAvailable, version }) => {
291
- if (!updateAvailable) return
292
- if (toastId !== undefined) return
293
- toastId = showToast({
294
- persistent: true,
295
- icon: "download",
296
- title: language.t("toast.update.title"),
297
- description: language.t("toast.update.description", { version: version ?? "" }),
298
- actions: [
299
- {
300
- label: language.t("toast.update.action.installRestart"),
301
- onClick: async () => {
302
- await platform.update!()
303
- await platform.restart!()
304
- },
305
- },
306
- {
307
- label: language.t("toast.update.action.notYet"),
308
- onClick: "dismiss",
309
- },
310
- ],
311
- })
312
- })
313
-
314
- createEffect(() => {
315
- if (!settings.ready()) return
316
-
317
- if (!settings.updates.startup()) {
318
- if (interval === undefined) return
319
- clearInterval(interval)
320
- interval = undefined
321
- return
322
- }
323
-
324
- if (interval !== undefined) return
325
- void pollUpdate()
326
- interval = setInterval(pollUpdate, 10 * 60 * 1000)
327
- })
328
-
329
- onCleanup(() => {
330
- if (interval === undefined) return
331
- clearInterval(interval)
332
- })
333
- })
334
-
335
282
  const useSDKNotificationToasts = () =>
336
283
  onMount(() => {
337
284
  const toastBySession = new Map<string, number>()
@@ -440,7 +387,6 @@ export default function Layout(props: ParentProps) {
440
387
  })
441
388
  })
442
389
 
443
- useUpdatePolling()
444
390
  useSDKNotificationToasts()
445
391
 
446
392
  function scrollToSession(sessionId: string, sessionKey: string) {
@@ -871,6 +817,17 @@ export default function Layout(props: ParentProps) {
871
817
  keybind: "mod+o",
872
818
  onSelect: () => chooseProject(),
873
819
  },
820
+ {
821
+ id: "project.close",
822
+ title: language.t("command.project.close"),
823
+ category: language.t("command.category.project"),
824
+ disabled: !currentProject(),
825
+ onSelect: () => {
826
+ const project = currentProject()
827
+ if (!project) return
828
+ closeProject(project.worktree)
829
+ },
830
+ },
874
831
  {
875
832
  id: "provider.connect",
876
833
  title: language.t("command.provider.connect"),
@@ -2080,7 +2037,7 @@ export default function Layout(props: ParentProps) {
2080
2037
  settingsKeybind={() => command.keybind("settings.open")}
2081
2038
  onOpenSettings={openSettings}
2082
2039
  helpLabel={() => language.t("sidebar.help")}
2083
- onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
2040
+ onOpenHelp={() => platform.openLink("https://github.com/isomoes/ikanban/issues")}
2084
2041
  renderPanel={() => (
2085
2042
  <Show when={currentProject()} keyed>
2086
2043
  {(project) => <SidebarPanel project={project} />}
@@ -2149,7 +2106,7 @@ export default function Layout(props: ParentProps) {
2149
2106
  settingsKeybind={() => command.keybind("settings.open")}
2150
2107
  onOpenSettings={openSettings}
2151
2108
  helpLabel={() => language.t("sidebar.help")}
2152
- onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
2109
+ onOpenHelp={() => platform.openLink("https://github.com/isomoes/ikanban/issues")}
2153
2110
  renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
2154
2111
  />
2155
2112
  </nav>
@@ -21,6 +21,7 @@ export interface SessionReviewTabProps {
21
21
  diffStyle: DiffStyle
22
22
  onDiffStyleChange?: (style: DiffStyle) => void
23
23
  onViewFile?: (file: string) => void
24
+ onRevealFile?: (file: string) => void
24
25
  onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
25
26
  onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
26
27
  onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
@@ -56,7 +57,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
56
57
 
57
58
  const readFile = async (path: string) => {
58
59
  return sdk.client.file
59
- .read({ path })
60
+ .read({ path, directory: sdk.directory })
60
61
  .then((x) => x.data)
61
62
  .catch((error) => {
62
63
  console.debug("[session-review] failed to read file", { path, error })
@@ -164,6 +165,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
164
165
  diffStyle={props.diffStyle}
165
166
  onDiffStyleChange={props.onDiffStyleChange}
166
167
  onViewFile={props.onViewFile}
168
+ onRevealFile={props.onRevealFile}
167
169
  focusedFile={props.focusedFile}
168
170
  readFile={readFile}
169
171
  onLineComment={props.onLineComment}
@@ -20,7 +20,6 @@ import { useCommand } from "@/context/command"
20
20
  import { useFile, type SelectedLineRange } from "@/context/file"
21
21
  import { useLanguage } from "@/context/language"
22
22
  import { useLayout } from "@/context/layout"
23
- import { useSync } from "@/context/sync"
24
23
  import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
25
24
  import { FileTabContent } from "@/pages/session/file-tabs"
26
25
  import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers"
@@ -31,10 +30,15 @@ export function SessionSidePanel(props: {
31
30
  reviewPanel: () => JSX.Element
32
31
  activeDiff?: string
33
32
  focusReviewDiff: (path: string) => void
33
+ showReview: () => boolean
34
+ reviewCount: () => number
35
+ hasReview: () => boolean
36
+ diffsReady: () => boolean
37
+ diffFiles: () => string[]
38
+ kinds: () => Map<string, "add" | "del" | "mix">
34
39
  }) {
35
40
  const params = useParams()
36
41
  const layout = useLayout()
37
- const sync = useSync()
38
42
  const file = useFile()
39
43
  const language = useLanguage()
40
44
  const command = useCommand()
@@ -49,44 +53,6 @@ export function SessionSidePanel(props: {
49
53
  const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
50
54
  const reviewTab = createMemo(() => isDesktop())
51
55
 
52
- const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
53
- const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
54
- const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
55
- const hasReview = createMemo(() => reviewCount() > 0)
56
- const diffsReady = createMemo(() => {
57
- const id = params.id
58
- if (!id) return true
59
- if (!hasReview()) return true
60
- return sync.data.session_diff[id] !== undefined
61
- })
62
-
63
- const diffFiles = createMemo(() => diffs().map((d) => d.file))
64
- const kinds = createMemo(() => {
65
- const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
66
- if (!a) return b
67
- if (a === b) return a
68
- return "mix" as const
69
- }
70
-
71
- const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
72
-
73
- const out = new Map<string, "add" | "del" | "mix">()
74
- for (const diff of diffs()) {
75
- const file = normalize(diff.file)
76
- const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
77
-
78
- out.set(file, kind)
79
-
80
- const parts = file.split("/")
81
- for (const [idx] of parts.slice(0, -1).entries()) {
82
- const dir = parts.slice(0, idx + 1).join("/")
83
- if (!dir) continue
84
- out.set(dir, merge(out.get(dir), kind))
85
- }
86
- }
87
- return out
88
- })
89
-
90
56
  const normalizeTab = (tab: string) => {
91
57
  if (!tab.startsWith("file://")) return tab
92
58
  return file.tab(tab)
@@ -121,7 +87,7 @@ export function SessionSidePanel(props: {
121
87
  const first = openedTabs()[0]
122
88
  if (first) return first
123
89
  if (contextOpen()) return "context"
124
- if (reviewTab() && hasReview()) return "review"
90
+ if (reviewTab() && props.showReview()) return "review"
125
91
  return "empty"
126
92
  })
127
93
 
@@ -236,8 +202,8 @@ export function SessionSidePanel(props: {
236
202
  <Tabs.Trigger value="review">
237
203
  <div class="flex items-center gap-1.5">
238
204
  <div>{language.t("session.tab.review")}</div>
239
- <Show when={hasReview()}>
240
- <div>{reviewCount()}</div>
205
+ <Show when={props.hasReview()}>
206
+ <div>{props.reviewCount()}</div>
241
207
  </Show>
242
208
  </div>
243
209
  </Tabs.Trigger>
@@ -356,8 +322,8 @@ export function SessionSidePanel(props: {
356
322
  >
357
323
  <Tabs.List data-scrolled={store.fileTreeScrolled ? "" : undefined}>
358
324
  <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
359
- {reviewCount()}{" "}
360
- {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
325
+ {props.reviewCount()}{" "}
326
+ {language.t(props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
361
327
  </Tabs.Trigger>
362
328
  <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
363
329
  {language.t("session.files.all")}
@@ -370,9 +336,9 @@ export function SessionSidePanel(props: {
370
336
  class="bg-background-stronger px-3 py-0"
371
337
  >
372
338
  <Switch>
373
- <Match when={hasReview()}>
339
+ <Match when={props.hasReview()}>
374
340
  <Show
375
- when={diffsReady()}
341
+ when={props.diffsReady()}
376
342
  fallback={
377
343
  <div class="px-2 py-2 text-12-regular text-text-weak">
378
344
  {language.t("common.loading")}
@@ -382,8 +348,8 @@ export function SessionSidePanel(props: {
382
348
  >
383
349
  <FileTree
384
350
  path=""
385
- allowed={diffFiles()}
386
- kinds={kinds()}
351
+ allowed={props.diffFiles()}
352
+ kinds={props.kinds()}
387
353
  draggable={false}
388
354
  active={props.activeDiff}
389
355
  onFileClick={(node) => props.focusReviewDiff(node.path)}
@@ -405,8 +371,8 @@ export function SessionSidePanel(props: {
405
371
  >
406
372
  <FileTree
407
373
  path=""
408
- modified={diffFiles()}
409
- kinds={kinds()}
374
+ modified={props.diffFiles()}
375
+ kinds={props.kinds()}
410
376
  onFileClick={(node) => openTab(file.tab(node.path))}
411
377
  />
412
378
  </Tabs.Content>
@@ -1,4 +1,5 @@
1
1
  import { createMemo } from "solid-js";
2
+ import { produce } from "solid-js/store";
2
3
  import { useNavigate, useParams } from "@solidjs/router";
3
4
  import { useCommand, type CommandOption } from "@/context/command";
4
5
  import { useDialog } from "ikanban-ui/context/dialog";
@@ -20,6 +21,7 @@ import { DialogSelectFile } from "@/components/dialog-select-file";
20
21
  import { DialogSelectModel } from "@/components/dialog-select-model";
21
22
  import { DialogSelectMcp } from "@/components/dialog-select-mcp";
22
23
  import { DialogFork } from "@/components/dialog-fork";
24
+ import { DialogSessionTimeline } from "@/components/dialog-session-timeline";
23
25
  import { showToast } from "ikanban-ui/toast";
24
26
  import { findLast } from "ikanban-utils/array";
25
27
  import { extractPromptFromParts } from "@/utils/prompt";
@@ -79,6 +81,77 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
79
81
  return userMessages().filter((m) => m.id < revert);
80
82
  });
81
83
 
84
+ const refreshReviewDiffs = async (sessionID: string) => {
85
+ const directory = sdk.directory;
86
+ sync.set(
87
+ produce((draft) => {
88
+ delete draft.session_diff[sessionID];
89
+ delete draft.project_diff[directory];
90
+ }),
91
+ );
92
+ await Promise.allSettled([
93
+ sync.session.diff(sessionID),
94
+ sync.projectDiff.diff(),
95
+ ]);
96
+ };
97
+
98
+ const revertToMessage = async (message: UserMessage) => {
99
+ const sessionID = params.id;
100
+ if (!sessionID) return;
101
+ if (status()?.type !== "idle") {
102
+ await sdk.client.session.abort({ sessionID }).catch(() => { });
103
+ }
104
+ await sdk.client.session.revert({ sessionID, messageID: message.id });
105
+ const parts = sync.data.part[message.id];
106
+ if (parts) {
107
+ const restored = extractPromptFromParts(parts, {
108
+ directory: sdk.directory,
109
+ });
110
+ prompt.set(restored);
111
+ }
112
+ await refreshReviewDiffs(sessionID);
113
+ const priorMessage = findLast(userMessages(), (x) => x.id < message.id);
114
+ setActiveMessage(priorMessage);
115
+ };
116
+
117
+ const redoToMessage = async (message: UserMessage | undefined) => {
118
+ const sessionID = params.id;
119
+ if (!sessionID) return;
120
+ const revertMessageID = info()?.revert?.messageID;
121
+ if (!revertMessageID) return;
122
+ if (!message) {
123
+ await sdk.client.session.unrevert({ sessionID });
124
+ prompt.reset();
125
+ await refreshReviewDiffs(sessionID);
126
+ const lastMsg = findLast(
127
+ userMessages(),
128
+ (x) => x.id >= revertMessageID,
129
+ );
130
+ setActiveMessage(lastMsg);
131
+ return;
132
+ }
133
+ await sdk.client.session.revert({
134
+ sessionID,
135
+ messageID: message.id,
136
+ });
137
+ await refreshReviewDiffs(sessionID);
138
+ const priorMsg = findLast(userMessages(), (x) => x.id < message.id);
139
+ setActiveMessage(priorMsg);
140
+ };
141
+
142
+ const jumpToMessage = async (message: UserMessage | undefined) => {
143
+ const revertMessageID = info()?.revert?.messageID;
144
+ if (!message) {
145
+ await redoToMessage(undefined);
146
+ return;
147
+ }
148
+ if (!revertMessageID || message.id < revertMessageID) {
149
+ await revertToMessage(message);
150
+ return;
151
+ }
152
+ await redoToMessage(message);
153
+ };
154
+
82
155
  const showAllFiles = () => {
83
156
  if (layout.fileTree.tab() !== "changes") return;
84
157
  layout.fileTree.setTab("all");
@@ -317,29 +390,31 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
317
390
  slash: "undo",
318
391
  disabled: !params.id || visibleUserMessages().length === 0,
319
392
  onSelect: async () => {
320
- const sessionID = params.id;
321
- if (!sessionID) return;
322
- if (status()?.type !== "idle") {
323
- await sdk.client.session.abort({ sessionID }).catch(() => { });
324
- }
325
393
  const revert = info()?.revert?.messageID;
326
394
  const message = findLast(
327
395
  userMessages(),
328
396
  (x) => !revert || x.id < revert,
329
397
  );
330
398
  if (!message) return;
331
- await sdk.client.session.revert({ sessionID, messageID: message.id });
332
- const parts = sync.data.part[message.id];
333
- if (parts) {
334
- const restored = extractPromptFromParts(parts, {
335
- directory: sdk.directory,
336
- });
337
- prompt.set(restored);
338
- }
339
- const priorMessage = findLast(userMessages(), (x) => x.id < message.id);
340
- setActiveMessage(priorMessage);
399
+ await revertToMessage(message);
341
400
  },
342
401
  }),
402
+ sessionCommand({
403
+ id: "session.timeline",
404
+ title: language.t("command.session.timeline"),
405
+ description: language.t("command.session.timeline.description"),
406
+ slash: "timeline",
407
+ disabled: !params.id || userMessages().length === 0,
408
+ onSelect: () =>
409
+ dialog.show(() => (
410
+ <DialogSessionTimeline
411
+ onSelect={(message) => {
412
+ dialog.close();
413
+ void jumpToMessage(message);
414
+ }}
415
+ />
416
+ )),
417
+ }),
343
418
  sessionCommand({
344
419
  id: "session.redo",
345
420
  title: language.t("command.session.redo"),
@@ -347,27 +422,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
347
422
  slash: "redo",
348
423
  disabled: !params.id || !info()?.revert?.messageID,
349
424
  onSelect: async () => {
350
- const sessionID = params.id;
351
- if (!sessionID) return;
352
425
  const revertMessageID = info()?.revert?.messageID;
353
426
  if (!revertMessageID) return;
354
427
  const nextMessage = userMessages().find((x) => x.id > revertMessageID);
355
- if (!nextMessage) {
356
- await sdk.client.session.unrevert({ sessionID });
357
- prompt.reset();
358
- const lastMsg = findLast(
359
- userMessages(),
360
- (x) => x.id >= revertMessageID,
361
- );
362
- setActiveMessage(lastMsg);
363
- return;
364
- }
365
- await sdk.client.session.revert({
366
- sessionID,
367
- messageID: nextMessage.id,
368
- });
369
- const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id);
370
- setActiveMessage(priorMsg);
428
+ await redoToMessage(nextMessage);
371
429
  },
372
430
  }),
373
431
  sessionCommand({
@@ -404,126 +462,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
404
462
  }),
405
463
  ]);
406
464
 
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
465
  command.register("session", () =>
528
466
  [
529
467
  sessionCommands(),
@@ -534,7 +472,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
534
472
  agentCommands(),
535
473
  permissionCommands(),
536
474
  sessionActionCommands(),
537
- shareCommands(),
538
475
  ].flatMap((x) => x),
539
476
  );
540
477
  };