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
@@ -1,208 +1,36 @@
1
- import { createEffect, createSignal, onCleanup } from "solid-js"
1
+ import { createEffect, createSignal } from "solid-js"
2
2
  import { createStore } from "solid-js/store"
3
3
  import { createSimpleContext } from "ikanban-ui/context"
4
- import { useDialog } from "ikanban-ui/context/dialog"
5
4
  import { usePlatform } from "@/context/platform"
6
- import { useSettings } from "@/context/settings"
7
5
  import { persisted } from "@/utils/persist"
8
- import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes"
9
-
10
- const CHANGELOG_URL = "https://opencode.ai/changelog.json"
11
6
 
12
7
  type Store = {
13
8
  version?: string
14
9
  }
15
10
 
16
- type ParsedRelease = {
17
- tag?: string
18
- highlights: Highlight[]
19
- }
20
-
21
- function isRecord(value: unknown): value is Record<string, unknown> {
22
- return typeof value === "object" && value !== null && !Array.isArray(value)
23
- }
24
-
25
- function getText(value: unknown): string | undefined {
26
- if (typeof value === "string") {
27
- const text = value.trim()
28
- return text.length > 0 ? text : undefined
29
- }
30
-
31
- if (typeof value === "number") return String(value)
32
- return
33
- }
34
-
35
- function normalizeVersion(value: string | undefined) {
36
- const text = value?.trim()
37
- if (!text) return
38
- return text.startsWith("v") || text.startsWith("V") ? text.slice(1) : text
39
- }
40
-
41
- function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined {
42
- if (!isRecord(value)) return
43
- const type = getText(value.type)?.toLowerCase()
44
- const src = getText(value.src) ?? getText(value.url)
45
- if (!src) return
46
- if (type !== "image" && type !== "video") return
47
-
48
- return { type, src, alt }
49
- }
50
-
51
- function parseHighlight(value: unknown): Highlight | undefined {
52
- if (!isRecord(value)) return
53
-
54
- const title = getText(value.title)
55
- if (!title) return
56
-
57
- const description = getText(value.description) ?? getText(value.shortDescription)
58
- if (!description) return
59
-
60
- const media = parseMedia(value.media, title)
61
- return { title, description, media }
62
- }
63
-
64
- function parseRelease(value: unknown): ParsedRelease | undefined {
65
- if (!isRecord(value)) return
66
- const tag = getText(value.tag) ?? getText(value.tag_name) ?? getText(value.name)
67
-
68
- if (!Array.isArray(value.highlights)) {
69
- return { tag, highlights: [] }
70
- }
71
-
72
- const highlights = value.highlights.flatMap((group) => {
73
- if (!isRecord(group)) return []
74
-
75
- const source = getText(group.source)
76
- if (!source) return []
77
- if (!source.toLowerCase().includes("desktop")) return []
78
-
79
- if (Array.isArray(group.items)) {
80
- return group.items.map((item) => parseHighlight(item)).filter((item): item is Highlight => item !== undefined)
81
- }
82
-
83
- const item = parseHighlight(group)
84
- if (!item) return []
85
- return [item]
86
- })
87
-
88
- return { tag, highlights }
89
- }
90
-
91
- function parseChangelog(value: unknown): ParsedRelease[] | undefined {
92
- if (Array.isArray(value)) {
93
- return value.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined)
94
- }
95
-
96
- if (!isRecord(value)) return
97
- if (!Array.isArray(value.releases)) return
98
-
99
- return value.releases.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined)
100
- }
101
-
102
- function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; previous?: string }) {
103
- const current = normalizeVersion(input.current)
104
- const previous = normalizeVersion(input.previous)
105
- const releases = input.releases
106
-
107
- const start = (() => {
108
- if (!current) return 0
109
- const index = releases.findIndex((release) => normalizeVersion(release.tag) === current)
110
- return index === -1 ? 0 : index
111
- })()
112
-
113
- const end = (() => {
114
- if (!previous) return releases.length
115
- const index = releases.findIndex((release, i) => i >= start && normalizeVersion(release.tag) === previous)
116
- return index === -1 ? releases.length : index
117
- })()
118
-
119
- const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
120
- const seen = new Set<string>()
121
- const unique = highlights.filter((highlight) => {
122
- const key = dedupeKey(highlight)
123
- if (seen.has(key)) return false
124
- seen.add(key)
125
- return true
126
- })
127
- return unique.slice(0, 5)
128
- }
129
-
130
- function dedupeKey(highlight: Highlight) {
131
- return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n")
132
- }
133
-
134
- function loadReleaseHighlights(value: unknown, current?: string, previous?: string) {
135
- const releases = parseChangelog(value)
136
- if (!releases?.length) return []
137
- return sliceHighlights({ releases, current, previous })
138
- }
139
-
140
11
  export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
141
12
  name: "Highlights",
142
13
  gate: false,
143
14
  init: () => {
144
15
  const platform = usePlatform()
145
- const dialog = useDialog()
146
- const settings = useSettings()
147
16
  const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
148
17
 
149
18
  const [from, setFrom] = createSignal<string | undefined>(undefined)
150
19
  const [to, setTo] = createSignal<string | undefined>(undefined)
151
20
  const state = { started: false }
152
- let timer: ReturnType<typeof setTimeout> | undefined
153
-
154
- const clearTimer = () => {
155
- if (timer === undefined) return
156
- clearTimeout(timer)
157
- timer = undefined
158
- }
159
-
160
21
  const markSeen = () => {
161
22
  if (!platform.version) return
162
23
  setStore("version", platform.version)
163
24
  }
164
25
 
165
- const start = (previous: string) => {
166
- if (!settings.general.releaseNotes()) {
167
- markSeen()
168
- return
169
- }
170
-
171
- const fetcher = platform.fetch ?? fetch
172
- const controller = new AbortController()
173
- onCleanup(() => {
174
- controller.abort()
175
- clearTimer()
176
- })
177
-
178
- fetcher(CHANGELOG_URL, {
179
- signal: controller.signal,
180
- headers: { Accept: "application/json" },
181
- })
182
- .then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
183
- .then((json) => {
184
- if (!json) return
185
- const highlights = loadReleaseHighlights(json, platform.version, previous)
186
- if (controller.signal.aborted) return
187
-
188
- if (highlights.length === 0) {
189
- markSeen()
190
- return
191
- }
192
-
193
- timer = setTimeout(() => {
194
- timer = undefined
195
- markSeen()
196
- dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
197
- }, 500)
198
- })
199
- .catch(() => undefined)
26
+ const start = (_previous: string) => {
27
+ // Updates and release-note prompts are temporarily disabled.
28
+ markSeen()
200
29
  }
201
30
 
202
31
  createEffect(() => {
203
32
  if (state.started) return
204
33
  if (!ready()) return
205
- if (!settings.ready()) return
206
34
  if (!platform.version) return
207
35
  state.started = true
208
36
 
@@ -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
  }
@@ -1,11 +1,180 @@
1
1
  import { batch, createMemo } from "solid-js"
2
- import { createStore, produce, reconcile } from "solid-js/store"
2
+ import { createStore, produce, reconcile, type SetStoreFunction } from "solid-js/store"
3
3
  import { Binary } from "ikanban-utils/binary"
4
4
  import { retry } from "ikanban-utils/retry"
5
5
  import { createSimpleContext } from "ikanban-ui/context"
6
+ import { applyPatch, parsePatch, reversePatch, type StructuredPatch } from "diff"
6
7
  import { useGlobalSync } from "./global-sync"
7
8
  import { useSDK } from "./sdk"
8
- import type { Message, Part } from "@opencode-ai/sdk/v2/client"
9
+ import type { File as SDKFile, FileContent, FileDiff, Message, Part } from "@opencode-ai/sdk/v2/client"
10
+
11
+ type ProjectDiffEntry = FileDiff & {
12
+ lazy?: boolean
13
+ loading?: boolean
14
+ binary?: boolean
15
+ }
16
+
17
+ const PROJECT_DIFF_INITIAL_READ_LIMIT = 20
18
+ const PROJECT_DIFF_READ_CONCURRENCY = 4
19
+ const PROJECT_DIFF_MAX_EAGER_CHANGED_LINES = 400
20
+ const BINARY_FILE_RE = /\.(?:png|jpe?g|gif|webp|bmp|ico|avif|mp3|wav|ogg|flac|mp4|mov|avi|mkv|webm|pdf|zip|gz|tar|7z|woff2?|ttf|eot|otf|exe|bin|so|dylib|dll|class|jar|wasm)$/i
21
+
22
+ function isLikelyBinaryPath(path: string) {
23
+ return BINARY_FILE_RE.test(path)
24
+ }
25
+
26
+ function createProjectPlaceholder(file: SDKFile, input: { lazy?: boolean; loading?: boolean; binary?: boolean } = {}): ProjectDiffEntry {
27
+ return {
28
+ file: file.path,
29
+ before: "",
30
+ after: "",
31
+ additions: file.added,
32
+ deletions: file.removed,
33
+ status: file.status,
34
+ lazy: input.lazy,
35
+ loading: input.loading,
36
+ binary: input.binary,
37
+ }
38
+ }
39
+
40
+ function shouldEagerLoadProjectDiff(file: SDKFile) {
41
+ if (isLikelyBinaryPath(file.path)) return false
42
+ if (file.added + file.removed > PROJECT_DIFF_MAX_EAGER_CHANGED_LINES) return false
43
+ return true
44
+ }
45
+
46
+ function getStructuredPatch(content?: FileContent): StructuredPatch | undefined {
47
+ if (!content) return
48
+ if (content.diff) {
49
+ const parsed = parsePatch(content.diff)[0]
50
+ if (parsed) return parsed
51
+ }
52
+ if (!content.patch) return
53
+ return {
54
+ oldFileName: content.patch.oldFileName,
55
+ newFileName: content.patch.newFileName,
56
+ oldHeader: content.patch.oldHeader,
57
+ newHeader: content.patch.newHeader,
58
+ hunks: content.patch.hunks.map((hunk) => ({
59
+ oldStart: hunk.oldStart,
60
+ oldLines: hunk.oldLines,
61
+ newStart: hunk.newStart,
62
+ newLines: hunk.newLines,
63
+ lines: [...hunk.lines],
64
+ })),
65
+ }
66
+ }
67
+
68
+ function buildWorkspaceDiff(file: SDKFile, content?: FileContent): ProjectDiffEntry | undefined {
69
+ const patch = getStructuredPatch(content)
70
+
71
+ if (content?.type === "binary") {
72
+ return createProjectPlaceholder(file, { binary: true })
73
+ }
74
+
75
+ if (file.status === "added") {
76
+ const after = content?.content ?? (patch ? applyPatch("", patch) || "" : "")
77
+ return {
78
+ file: file.path,
79
+ before: "",
80
+ after,
81
+ additions: file.added,
82
+ deletions: file.removed,
83
+ status: file.status,
84
+ }
85
+ }
86
+
87
+ if (file.status === "deleted") {
88
+ const before = content?.content ?? (patch ? applyPatch("", reversePatch(patch)) || "" : "")
89
+ return {
90
+ file: file.path,
91
+ before,
92
+ after: "",
93
+ additions: file.added,
94
+ deletions: file.removed,
95
+ status: file.status,
96
+ }
97
+ }
98
+
99
+ const after = content?.content ?? ""
100
+ const before = patch ? applyPatch(after, reversePatch(patch)) || after : after
101
+
102
+ if (!patch && file.added + file.removed === 0) return
103
+
104
+ return {
105
+ file: file.path,
106
+ before,
107
+ after,
108
+ additions: file.added,
109
+ deletions: file.removed,
110
+ status: file.status,
111
+ }
112
+ }
113
+
114
+ async function fetchProjectDiffs(client: ReturnType<typeof useSDK>["client"], directory: string): Promise<FileDiff[]> {
115
+ const files = await retry(() => client.file.status({ directory })).then((result) => result.data ?? [])
116
+ return files.map((file) =>
117
+ createProjectPlaceholder(file, {
118
+ lazy: !shouldEagerLoadProjectDiff(file),
119
+ loading: false,
120
+ }),
121
+ )
122
+ }
123
+
124
+ async function hydrateProjectDiffFile(
125
+ client: ReturnType<typeof useSDK>["client"],
126
+ directory: string,
127
+ file: SDKFile,
128
+ ): Promise<ProjectDiffEntry> {
129
+ const content = await retry(() => client.file.read({ path: file.path, directory }))
130
+ .then((result) => result.data)
131
+ .catch(() => undefined)
132
+ return buildWorkspaceDiff(file, content) ?? createProjectPlaceholder(file)
133
+ }
134
+
135
+ function updateProjectDiffEntry(
136
+ setStore: SetStoreFunction<any>,
137
+ directory: string,
138
+ path: string,
139
+ updater: (current: ProjectDiffEntry) => ProjectDiffEntry,
140
+ ) {
141
+ setStore(
142
+ "project_diff",
143
+ directory,
144
+ produce((draft: ProjectDiffEntry[]) => {
145
+ const index = draft.findIndex((item) => item.file === path)
146
+ if (index === -1) return
147
+ draft[index] = updater(draft[index])
148
+ }),
149
+ )
150
+ }
151
+
152
+ async function hydrateProjectDiffBatch(input: {
153
+ client: ReturnType<typeof useSDK>["client"]
154
+ directory: string
155
+ setStore: SetStoreFunction<any>
156
+ entries: ProjectDiffEntry[]
157
+ }) {
158
+ for (let i = 0; i < input.entries.length; i += PROJECT_DIFF_READ_CONCURRENCY) {
159
+ const chunk = input.entries.slice(i, i + PROJECT_DIFF_READ_CONCURRENCY)
160
+ await Promise.all(
161
+ chunk.map(async (entry) => {
162
+ const file: SDKFile = {
163
+ path: entry.file,
164
+ added: entry.additions,
165
+ removed: entry.deletions,
166
+ status: entry.status ?? "modified",
167
+ }
168
+ const hydrated = await hydrateProjectDiffFile(input.client, input.directory, file)
169
+ updateProjectDiffEntry(input.setStore, input.directory, entry.file, () => ({
170
+ ...hydrated,
171
+ lazy: false,
172
+ loading: false,
173
+ }))
174
+ }),
175
+ )
176
+ }
177
+ }
9
178
 
10
179
  function sortParts(parts: Part[]) {
11
180
  return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
@@ -107,6 +276,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
107
276
  const messagePageSize = 200
108
277
  const inflight = new Map<string, Promise<void>>()
109
278
  const inflightDiff = new Map<string, Promise<void>>()
279
+ const inflightProjectFile = new Map<string, Promise<void>>()
110
280
  const inflightTodo = new Map<string, Promise<void>>()
111
281
  const [meta, setMeta] = createStore({
112
282
  limit: {} as Record<string, number>,
@@ -350,6 +520,71 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
350
520
  )
351
521
  },
352
522
  },
523
+ projectDiff: {
524
+ async diff() {
525
+ const directory = sdk.directory
526
+ const client = sdk.client
527
+ const [store, setStore] = globalSync.child(directory)
528
+ if (store.project_diff[directory] !== undefined) return
529
+
530
+ const key = `project\n${directory}`
531
+ return runInflight(inflightDiff, key, async () => {
532
+ const diff = (await fetchProjectDiffs(client, directory)) as ProjectDiffEntry[]
533
+ const eagerFiles = new Set(
534
+ diff
535
+ .filter((entry) => !entry.lazy)
536
+ .slice(0, PROJECT_DIFF_INITIAL_READ_LIMIT)
537
+ .map((entry) => entry.file),
538
+ )
539
+ const seeded = diff.map((entry) => ({
540
+ ...entry,
541
+ lazy: entry.lazy || !eagerFiles.has(entry.file),
542
+ loading: eagerFiles.has(entry.file),
543
+ }))
544
+ setStore("project_diff", directory, reconcile(seeded, { key: "file" }))
545
+
546
+ const eager = seeded.filter((entry) => eagerFiles.has(entry.file))
547
+
548
+ if (!eager.length) return
549
+
550
+ await hydrateProjectDiffBatch({
551
+ client,
552
+ directory,
553
+ setStore,
554
+ entries: eager,
555
+ })
556
+ })
557
+ },
558
+ hydrate(path: string) {
559
+ const directory = sdk.directory
560
+ const client = sdk.client
561
+ const [store, setStore] = globalSync.child(directory)
562
+ const entries = store.project_diff[directory] as ProjectDiffEntry[] | undefined
563
+ const existing = entries?.find((item) => item.file === path)
564
+ if (!existing || (!existing.lazy && !existing.loading)) return
565
+
566
+ const fileKey = `project-file\n${directory}\n${path}`
567
+ return runInflight(inflightProjectFile, fileKey, async () => {
568
+ updateProjectDiffEntry(setStore, directory, path, (current) => ({
569
+ ...current,
570
+ loading: true,
571
+ }))
572
+
573
+ const file: SDKFile = {
574
+ path: existing.file,
575
+ added: existing.additions,
576
+ removed: existing.deletions,
577
+ status: existing.status ?? "modified",
578
+ }
579
+ const hydrated = await hydrateProjectDiffFile(client, directory, file)
580
+ updateProjectDiffEntry(setStore, directory, path, () => ({
581
+ ...hydrated,
582
+ lazy: false,
583
+ loading: false,
584
+ }))
585
+ })
586
+ },
587
+ },
353
588
  absolute,
354
589
  get directory() {
355
590
  return current()[0].path.directory
package/src/i18n/en.ts CHANGED
@@ -23,6 +23,7 @@ export const dict = {
23
23
 
24
24
  "command.sidebar.toggle": "Toggle sidebar",
25
25
  "command.project.open": "Open project",
26
+ "command.project.close": "Close project",
26
27
  "command.provider.connect": "Connect provider",
27
28
  "command.server.switch": "Switch server",
28
29
  "command.settings.open": "Open settings",
@@ -77,6 +78,8 @@ export const dict = {
77
78
  "command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar",
78
79
  "command.session.undo": "Undo",
79
80
  "command.session.undo.description": "Undo the last message",
81
+ "command.session.timeline": "Timeline",
82
+ "command.session.timeline.description": "Jump to any message in this session timeline",
80
83
  "command.session.redo": "Redo",
81
84
  "command.session.redo.description": "Redo the last undone message",
82
85
  "command.session.compact": "Compact session",
@@ -303,6 +306,8 @@ export const dict = {
303
306
  "mcp.status.disabled": "disabled",
304
307
 
305
308
  "dialog.fork.empty": "No messages to fork from",
309
+ "dialog.timeline.empty": "No messages in this timeline",
310
+ "dialog.timeline.latest": "Latest message",
306
311
 
307
312
  "dialog.directory.search.placeholder": "Search folders",
308
313
  "dialog.directory.empty": "No folders found",
@@ -513,6 +518,8 @@ export const dict = {
513
518
  "session.review.loadingChanges": "Loading changes...",
514
519
  "session.review.empty": "No changes in this session yet",
515
520
  "session.review.noVcs": "No git VCS detected, so session changes will not be detected",
521
+ "session.review.emptyProject": "No project changes yet",
522
+ "session.review.noVcsProject": "No git VCS detected, so project changes will not be detected",
516
523
  "session.review.noChanges": "No changes",
517
524
 
518
525
  "session.files.selectToOpen": "Select a file to open",
@@ -610,7 +617,7 @@ export const dict = {
610
617
  "sidebar.project.viewAllSessions": "View all sessions",
611
618
  "sidebar.project.clearNotifications": "Clear notifications",
612
619
 
613
- "app.name.desktop": "OpenCode Desktop",
620
+ "app.name.desktop": "iKanban",
614
621
 
615
622
  "settings.section.desktop": "Desktop",
616
623
  "settings.section.server": "Server",
@@ -624,6 +631,7 @@ export const dict = {
624
631
  "settings.general.section.notifications": "System notifications",
625
632
  "settings.general.section.updates": "Updates",
626
633
  "settings.general.section.sounds": "Sound effects",
634
+ "settings.general.section.releaseNotes": "Release notes",
627
635
  "settings.general.section.feed": "Feed",
628
636
  "settings.general.section.display": "Display",
629
637
 
@@ -651,6 +659,9 @@ export const dict = {
651
659
 
652
660
  "settings.general.row.releaseNotes.title": "Release notes",
653
661
  "settings.general.row.releaseNotes.description": "Show What's New popups after updates",
662
+ "settings.general.row.releaseNotesCurrent.title": "Current version changes",
663
+ "settings.general.row.releaseNotesCurrent.description": "Open this version's changelog entries on GitHub",
664
+ "settings.general.action.releaseNotesCurrent": "View changes",
654
665
 
655
666
  "settings.updates.row.startup.title": "Check for updates on startup",
656
667
  "settings.updates.row.startup.description": "Automatically check for updates when OpenCode launches",
@@ -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
  })