@opencode/ui 2.0.21 → 2.0.23

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.
@@ -37,35 +37,66 @@ export function useDialogLayer() {
37
37
 
38
38
  function init() {
39
39
  const [stack, setStack] = createSignal<Active[]>([])
40
- const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
40
+ // A dialog is closing from the moment its close starts until its exit animation ends and it is disposed.
41
+ const closing = new Map<string, ReturnType<typeof setTimeout> | undefined>()
42
+ // The same ids, reactive, so the top dialog that stays open owns the focus trap during an exit animation.
43
+ const [exiting, setExiting] = createSignal<ReadonlySet<string>>(new Set())
41
44
  const lock = { value: false }
45
+ const state = { disposed: false }
46
+
47
+ // Detach the stack before disposing, so cleanups that close dialogs find nothing to admit. Every dialog is
48
+ // drained even when one throws. A replaced dialog that was not already closing still hears onClose.
49
+ const disposeAll = (notify: boolean) => {
50
+ const items = stack()
51
+ const exited = new Set(closing.keys())
52
+ setStack([])
53
+ closing.forEach((timer) => clearTimeout(timer))
54
+ closing.clear()
55
+ setExiting(new Set<string>())
56
+ items.forEach((item) => {
57
+ if (notify && !exited.has(item.id)) isolate(() => item.onClose?.())
58
+ isolate(item.dispose)
59
+ })
60
+ }
42
61
 
43
62
  onCleanup(() => {
44
- if (timer.current === undefined) return
45
- clearTimeout(timer.current)
46
- timer.current = undefined
63
+ state.disposed = true
64
+ disposeAll(false)
47
65
  })
48
66
 
49
- const close = (id?: string) => {
50
- const items = stack()
51
- const current = id ? items.find((item) => item.id === id) : items.at(-1)
52
- if (!current || lock.value) return
53
- lock.value = true
54
- current.onClose?.()
67
+ const finish = (current: Active) => {
68
+ // Scheduled first, so the dialog still goes away when a callback throws.
69
+ closing.set(
70
+ current.id,
71
+ setTimeout(() => {
72
+ closing.delete(current.id)
73
+ if (closing.size === 0) lock.value = false
74
+ setExiting((ids) => new Set([...ids].filter((id) => id !== current.id)))
75
+ setStack((items) => items.filter((item) => item.id !== current.id))
76
+ current.dispose()
77
+ }, 100),
78
+ )
79
+ setExiting((ids) => new Set([...ids, current.id]))
80
+ isolate(() => current.onClose?.())
55
81
  current.setClosing(true)
82
+ }
56
83
 
57
- const closed = current.id
58
- if (timer.current !== undefined) {
59
- clearTimeout(timer.current)
60
- timer.current = undefined
61
- }
84
+ /** Programmatic close. Without an id it closes the top dialog, one at a time; with an id it never waits. */
85
+ const close = (id?: string) => {
86
+ const current = id ? stack().find((item) => item.id === id) : stack().at(-1)
87
+ if (!current || closing.has(current.id) || (!id && lock.value)) return
88
+ closing.set(current.id, undefined)
89
+ lock.value = true
90
+ finish(current)
91
+ }
62
92
 
63
- timer.current = setTimeout(() => {
64
- timer.current = undefined
65
- current.dispose()
66
- setStack((items) => items.filter((item) => item.id !== closed))
67
- lock.value = false
68
- }, 100)
93
+ /** Escape, a backdrop click, or Kobalte dismissing: only the top dialog, and one per exit animation. */
94
+ const dismiss = (id?: string) => {
95
+ const current = stack().at(-1)
96
+ if (!current || (id && current.id !== id) || closing.has(current.id) || lock.value) return
97
+ closing.set(current.id, undefined)
98
+ lock.value = true
99
+ finish(current)
69
100
  }
70
101
 
71
102
  createEffect(() => {
@@ -73,7 +104,7 @@ function init() {
73
104
 
74
105
  const onKeyDown = (event: KeyboardEvent) => {
75
106
  if (event.key !== "Escape") return
76
- close()
107
+ dismiss()
77
108
  event.preventDefault()
78
109
  event.stopPropagation()
79
110
  }
@@ -81,9 +112,17 @@ function init() {
81
112
  makeEventListener(window, "keydown", onKeyDown, { capture: true })
82
113
  })
83
114
 
84
- const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, layer: number) => {
85
- const id = Math.random().toString(36).slice(2)
86
- const zIndex = 50 + layer * 10
115
+ const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, key?: string) => {
116
+ // A deferred open (e.g. from a focus callback) must not mount after the provider is gone.
117
+ if (state.disposed) return
118
+ const id = key ?? Math.random().toString(36).slice(2)
119
+ // The layer follows the dialog's current place in the stack, so a new top dialog always renders above.
120
+ const layer = () =>
121
+ Math.max(
122
+ 0,
123
+ stack().findIndex((item) => item.id === id),
124
+ )
125
+ const zIndex = () => String(50 + layer() * 10)
87
126
  let dispose: (() => void) | undefined
88
127
  let setClosing: ((closing: boolean) => void) | undefined
89
128
 
@@ -96,27 +135,26 @@ function init() {
96
135
  setClosing = setClosingSignal
97
136
  return (
98
137
  <Kobalte
99
- modal={stack().at(-1)?.id === id}
138
+ modal={stack().findLast((item) => !exiting().has(item.id))?.id === id}
100
139
  open={!closing()}
101
140
  onOpenChange={(open: boolean) => {
102
- if (open || stack().at(-1)?.id !== id) return
103
- close(id)
141
+ if (!open) dismiss(id)
104
142
  }}
105
143
  >
106
144
  <Kobalte.Portal>
107
145
  <Kobalte.Overlay
108
146
  data-component="dialog-overlay"
109
- style={{ "z-index": String(zIndex) }}
147
+ style={{ "z-index": zIndex() }}
110
148
  onClick={() => {
111
- if (backdropDismiss()) close(id)
149
+ if (backdropDismiss()) dismiss(id)
112
150
  }}
113
151
  />
114
152
  <div
115
- data-dialog-layer={layer}
153
+ data-dialog-layer={layer()}
116
154
  style={{
117
155
  position: "fixed",
118
156
  inset: "0",
119
- "z-index": String(zIndex),
157
+ "z-index": zIndex(),
120
158
  display: "flex",
121
159
  "align-items": "center",
122
160
  "justify-content": "center",
@@ -137,24 +175,15 @@ function init() {
137
175
  setStack((items) => [...items, active])
138
176
  }
139
177
 
140
- const push = (element: DialogElement, owner: Owner, onClose?: () => void) => {
141
- if (timer.current !== undefined) {
142
- clearTimeout(timer.current)
143
- timer.current = undefined
144
- }
178
+ const push = (element: DialogElement, owner: Owner, onClose?: () => void, id?: string) => {
145
179
  lock.value = false
146
- mount(element, owner, onClose, stack().length)
180
+ mount(element, owner, onClose, id)
147
181
  }
148
182
 
149
- const show = (element: DialogElement, owner: Owner, onClose?: () => void) => {
150
- for (const item of stack()) item.dispose()
151
- setStack([])
152
- if (timer.current !== undefined) {
153
- clearTimeout(timer.current)
154
- timer.current = undefined
155
- }
183
+ const show = (element: DialogElement, owner: Owner, onClose?: () => void, id?: string) => {
184
+ disposeAll(true)
156
185
  lock.value = false
157
- mount(element, owner, onClose, 0)
186
+ mount(element, owner, onClose, id)
158
187
  }
159
188
 
160
189
  return {
@@ -164,6 +193,14 @@ function init() {
164
193
  push,
165
194
  }
166
195
  }
196
+ /** Runs a dialog callback so its throw is reported without stopping the caller's cleanup. */
197
+ function isolate(fn: () => void) {
198
+ try {
199
+ fn()
200
+ } catch (error) {
201
+ console.error("[dialog]", error)
202
+ }
203
+ }
167
204
 
168
205
  export function DialogProvider(props: ParentProps) {
169
206
  const ctx = init()
@@ -192,16 +229,26 @@ export function useDialog() {
192
229
  get active() {
193
230
  return ctx.stack().at(-1)
194
231
  },
195
- show(element: DialogElement, onClose?: () => void) {
232
+ /**
233
+ * id lets the caller close this dialog later rather than whichever is on top. Opening is deferred; once
234
+ * signal aborts, the open neither mounts nor replaces anything and onClose runs as for a replaced dialog.
235
+ */
236
+ show(element: DialogElement, onClose?: () => void, id?: string, signal?: AbortSignal) {
196
237
  const base = ctx.stack().at(-1)?.owner ?? owner
197
- return startTransition(() => ctx.show(element, base, onClose))
238
+ return startTransition(() => {
239
+ if (signal?.aborted) return isolate(() => onClose?.())
240
+ ctx.show(element, base, onClose, id)
241
+ })
198
242
  },
199
- push(element: DialogElement, onClose?: () => void) {
243
+ push(element: DialogElement, onClose?: () => void, id?: string, signal?: AbortSignal) {
200
244
  const base = ctx.stack().at(-1)?.owner ?? owner
201
- return startTransition(() => ctx.push(element, base, onClose))
245
+ return startTransition(() => {
246
+ if (signal?.aborted) return isolate(() => onClose?.())
247
+ ctx.push(element, base, onClose, id)
248
+ })
202
249
  },
203
- close() {
204
- ctx.close()
250
+ close(id?: string) {
251
+ ctx.close(id)
205
252
  },
206
253
  }
207
254
  }
@@ -2,17 +2,27 @@ import { Content, Header, Item, Root, Trigger } from "@kobalte/core/accordion"
2
2
  import { splitProps } from "solid-js"
3
3
  import type { ComponentProps, ParentProps } from "solid-js"
4
4
 
5
- export interface AccordionProps extends ComponentProps<typeof Root> {}
5
+ export interface AccordionProps extends Omit<ComponentProps<typeof Root>, "value"> {
6
+ /** Controlled expanded items. Kobalte reads this iterable into its own selection; it never mutates this array. */
7
+ value?: readonly string[]
8
+ }
9
+
6
10
  export interface AccordionItemProps extends ComponentProps<typeof Item> {}
11
+
7
12
  export interface AccordionHeaderProps extends ComponentProps<typeof Header> {}
13
+
8
14
  export interface AccordionTriggerProps extends ComponentProps<typeof Trigger> {}
15
+
9
16
  export interface AccordionContentProps extends ComponentProps<typeof Content> {}
10
17
 
11
18
  function AccordionRoot(props: AccordionProps) {
12
- const [split, rest] = splitProps(props, ["class", "classList"])
19
+ const [split, rest] = splitProps(props, ["class", "classList", "value"])
20
+
21
+ // SAFETY: Kobalte's createMultipleSelectionState copies selectedKeys into a Selection before changing it.
13
22
  return (
14
23
  <Root
15
24
  {...rest}
25
+ value={split.value as string[] | undefined}
16
26
  data-component="accordion"
17
27
  classList={{
18
28
  ...split.classList,
@@ -24,6 +34,7 @@ function AccordionRoot(props: AccordionProps) {
24
34
 
25
35
  function AccordionItem(props: AccordionItemProps) {
26
36
  const [split, rest] = splitProps(props, ["class", "classList"])
37
+
27
38
  return (
28
39
  <Item
29
40
  {...rest}
@@ -38,6 +49,7 @@ function AccordionItem(props: AccordionItemProps) {
38
49
 
39
50
  function AccordionHeader(props: ParentProps<AccordionHeaderProps>) {
40
51
  const [split, rest] = splitProps(props, ["class", "classList", "children"])
52
+
41
53
  return (
42
54
  <Header
43
55
  {...rest}
@@ -54,6 +66,7 @@ function AccordionHeader(props: ParentProps<AccordionHeaderProps>) {
54
66
 
55
67
  function AccordionTrigger(props: ParentProps<AccordionTriggerProps>) {
56
68
  const [split, rest] = splitProps(props, ["class", "classList", "children"])
69
+
57
70
  return (
58
71
  <Trigger
59
72
  {...rest}
@@ -70,6 +83,7 @@ function AccordionTrigger(props: ParentProps<AccordionTriggerProps>) {
70
83
 
71
84
  function AccordionContent(props: ParentProps<AccordionContentProps>) {
72
85
  const [split, rest] = splitProps(props, ["class", "classList", "children"])
86
+
73
87
  return (
74
88
  <Content
75
89
  {...rest}
@@ -29,6 +29,39 @@ export type ProjectAvatarVariant = (typeof PROJECT_AVATAR_VARIANTS)[number]
29
29
  // "outline" is a neutral, muted style (e.g. recently closed projects) and is not part of the color rotation.
30
30
  export type ProjectAvatarStyle = ProjectAvatarVariant | "outline"
31
31
 
32
+ const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
33
+
34
+ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
35
+ if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
36
+ if (icon?.override) return icon.override
37
+ if (icon?.color) return undefined
38
+ return icon?.url
39
+ }
40
+
41
+ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
42
+ if (key === "mint") return "cyan"
43
+ if (key === "lime") return "green"
44
+ if (
45
+ key === "orange" ||
46
+ key === "yellow" ||
47
+ key === "cyan" ||
48
+ key === "green" ||
49
+ key === "red" ||
50
+ key === "pink" ||
51
+ key === "blue" ||
52
+ key === "purple" ||
53
+ key === "gray"
54
+ )
55
+ return key
56
+ return "gray"
57
+ }
58
+
59
+ export const displayName = (project: { name?: string; worktree: string }) => {
60
+ const trimmed = project.worktree.replace(/[/\\]+$/, "")
61
+ const filename = trimmed.slice(Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")) + 1)
62
+ return project.name || filename || project.worktree
63
+ }
64
+
32
65
  export interface ProjectAvatarProps extends ComponentProps<"div"> {
33
66
  fallback: string
34
67
  src?: string
@@ -21,9 +21,12 @@ function ToastRegion(props: ToastRegionProps) {
21
21
  element.tabIndex = hidden ? -1 : 0
22
22
  })
23
23
  }
24
+
24
25
  let connected = false
26
+
25
27
  const connect = () => {
26
28
  const regions = document.querySelectorAll(".toast-v2-region")
29
+
27
30
  if (!regions.length) return
28
31
  observer.disconnect()
29
32
  regions.forEach((region) => {
@@ -37,14 +40,17 @@ function ToastRegion(props: ToastRegionProps) {
37
40
  connected = true
38
41
  sync()
39
42
  }
43
+
40
44
  const observer = new MutationObserver(() => {
41
45
  if (!connected) connect()
42
46
  else sync()
43
47
  })
48
+
44
49
  observer.observe(document.body, { subtree: true, childList: true })
45
50
  queueMicrotask(connect)
46
51
  onCleanup(() => observer.disconnect())
47
52
  })
53
+
48
54
  return (
49
55
  <Portal>
50
56
  <Toaster
@@ -55,7 +61,7 @@ function ToastRegion(props: ToastRegionProps) {
55
61
  duration={5000}
56
62
  swipeDirections={["bottom"]}
57
63
  className={["toast-v2-region", local.className, local.class].filter(Boolean).join(" ")}
58
- style={{ "--width": "320px", ...local.style } as JSX.CSSProperties}
64
+ style={{ "--width": "320px", ...local.style }}
59
65
  toastOptions={{
60
66
  ...local.toastOptions,
61
67
  unstyled: true,
@@ -103,6 +109,7 @@ function ToastCloseButton(props: ComponentProps<"button">) {
103
109
  const i18n = useI18n()
104
110
  const toastId = useContext(ToastContext)
105
111
  const [local, rest] = splitProps(props, ["children", "onClick"])
112
+
106
113
  return (
107
114
  <button
108
115
  type="button"
@@ -110,7 +117,8 @@ function ToastCloseButton(props: ComponentProps<"button">) {
110
117
  aria-label={i18n.t("ui.common.dismiss")}
111
118
  {...rest}
112
119
  onClick={(event) => {
113
- if (typeof local.onClick === "function") local.onClick(event)
120
+ if (local.onClick instanceof Function) local.onClick(event)
121
+
114
122
  if (!event.defaultPrevented && toastId !== undefined) toaster.dismiss(toastId)
115
123
  }}
116
124
  >
@@ -147,7 +155,7 @@ export const toaster = {
147
155
  duration?: number
148
156
  persistent?: boolean
149
157
  /** Reactive value the toast's height depends on; sonner re-measures when it changes. */
150
- resize?: () => unknown
158
+ resize?: () => void
151
159
  },
152
160
  ) {
153
161
  const toastId = --toastV2Id
@@ -162,10 +170,12 @@ export const toaster = {
162
170
  description: options?.resize
163
171
  ? (): JSX.Element => {
164
172
  options.resize?.()
173
+
165
174
  return undefined
166
175
  }
167
176
  : undefined,
168
177
  })
178
+
169
179
  return toastId
170
180
  },
171
181
  dismiss(toastId?: number) {
@@ -175,6 +185,7 @@ export const toaster = {
175
185
  } else {
176
186
  releaseToast(activeToastById.get(toastId))
177
187
  }
188
+
178
189
  return toast.dismiss(toastId)
179
190
  },
180
191
  }
@@ -188,7 +199,8 @@ export interface ToastAction {
188
199
  export interface ToastOptions {
189
200
  title?: string
190
201
  description?: string
191
- icon?: JSX.Element
202
+ /** An element, or a render function the toast calls under its own owner, for icons created from imperative code. */
203
+ icon?: JSX.Element | (() => JSX.Element)
192
204
  variant?: "default" | "success" | "error" | "loading"
193
205
  duration?: number
194
206
  persistent?: boolean
@@ -203,10 +215,14 @@ interface ActiveToast {
203
215
  }
204
216
 
205
217
  const activeToastByKey = new Map<string, ActiveToast>()
218
+
206
219
  const activeToastById = new Map<number, ActiveToast>()
207
220
 
208
221
  export function showToast(options: ToastOptions | string) {
222
+ // SAFETY: `options` is declared `ToastOptions | string`; `typeof` separates the string shorthand from the object.
223
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- see SAFETY above
209
224
  const opts = boundToastDescription(typeof options === "string" ? { description: options } : options)
225
+
210
226
  const key = JSON.stringify({
211
227
  title: opts.title,
212
228
  description: opts.description,
@@ -215,6 +231,7 @@ export function showToast(options: ToastOptions | string) {
215
231
  persistent: opts.persistent,
216
232
  actions: opts.actions?.map((action) => [action.label, action.variant]),
217
233
  })
234
+
218
235
  const active = activeToastByKey.get(key)
219
236
  const toasts = toast.getToasts()
220
237
 
@@ -222,6 +239,7 @@ export function showToast(options: ToastOptions | string) {
222
239
  active.options = opts
223
240
  publishToast(active)
224
241
  pulseToast(active.id)
242
+
225
243
  return active.id
226
244
  }
227
245
 
@@ -233,6 +251,7 @@ export function showToast(options: ToastOptions | string) {
233
251
  activeToastByKey.set(key, entry)
234
252
  activeToastById.set(entry.id, entry)
235
253
  publishToast(entry)
254
+
236
255
  return entry.id
237
256
  }
238
257
 
@@ -241,7 +260,9 @@ function publishToast(entry: ActiveToast) {
241
260
  toast(entry.options.title ?? "", {
242
261
  id: entry.id,
243
262
  description: entry.options.description,
244
- icon: entry.options.icon,
263
+ // SAFETY: solid-sonner inserts `icon` as a JSX child, and Solid calls a function child under the toast's render
264
+ // owner; solid-sonner's type omits function children.
265
+ icon: entry.options.icon as JSX.Element,
245
266
  action: entry.actions,
246
267
  closeButton: true,
247
268
  duration: entry.options.persistent ? Number.POSITIVE_INFINITY : entry.options.duration,
@@ -255,6 +276,7 @@ function publishToast(entry: ActiveToast) {
255
276
 
256
277
  function createToastActions(entry: ActiveToast) {
257
278
  if (!entry.options.actions?.length) return undefined
279
+
258
280
  return (
259
281
  <Toast.Actions>
260
282
  {/* Static map, not <For>: this JSX is created imperatively outside any Solid
@@ -268,7 +290,8 @@ function createToastActions(entry: ActiveToast) {
268
290
  data-action-variant={action.variant ?? "primary"}
269
291
  onClick={() => {
270
292
  const onClick = entry.options.actions?.[index]?.onClick
271
- if (typeof onClick === "function") onClick()
293
+
294
+ if (onClick && onClick !== "dismiss") onClick()
272
295
  toaster.dismiss(entry.id)
273
296
  }}
274
297
  >
@@ -283,6 +306,7 @@ function pulseToast(toastId: number) {
283
306
  if (typeof document === "undefined" || typeof requestAnimationFrame === "undefined") return
284
307
  requestAnimationFrame(() => {
285
308
  const element = document.querySelector<HTMLElement>(`[data-testid="toast-v2-${toastId}"]`)
309
+
286
310
  if (!element || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
287
311
  element.animate([{ scale: 1 }, { scale: 1.025 }, { scale: 1 }], { duration: 160, easing: "ease-out" })
288
312
  })
@@ -290,7 +314,9 @@ function pulseToast(toastId: number) {
290
314
 
291
315
  function releaseToast(entry: ActiveToast | undefined) {
292
316
  if (!entry) return
317
+
293
318
  if (activeToastByKey.get(entry.key) === entry) activeToastByKey.delete(entry.key)
319
+
294
320
  if (activeToastById.get(entry.id) === entry) activeToastById.delete(entry.id)
295
321
  }
296
322
 
package/src/i18n/en.ts CHANGED
@@ -108,6 +108,7 @@ const source = {
108
108
  "ui.messagePart.questions.dismissed": "Questions dismissed",
109
109
  "ui.messagePart.compaction": "Session compacted",
110
110
  "ui.messagePart.compaction.started": "Session compaction started",
111
+ "ui.messagePart.compaction.queued": "Session compaction queued",
111
112
  "ui.messagePart.compaction.running": "Compacting",
112
113
  "ui.messagePart.compaction.failed": "Session compaction failed",
113
114
  "ui.messagePart.compaction.cancelled": "Session compaction cancelled",
@@ -242,12 +243,15 @@ const source = {
242
243
  "ui.message.copyMessage": "Copy message",
243
244
  "ui.message.forkMessage": "Fork to new session",
244
245
  "ui.message.revertMessage": "Revert message",
246
+ "ui.message.moveToQueue": "Move to queue",
247
+ "ui.message.deletePending": "Delete",
245
248
  "ui.message.copyResponse": "Copy response",
246
249
  "ui.message.copied": "Copied",
247
250
  "ui.message.thought": "Thought",
248
251
  "ui.message.duration.seconds": "{{count}}s",
249
252
  "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
250
253
  "ui.message.interrupted": "Interrupted",
254
+ "ui.message.modelVariant": "{{model}} ({{variant}})",
251
255
  "ui.sessionTimeline.notice.model": "Model",
252
256
  "ui.sessionTimeline.notice.modelSwitched": "Switched to {{model}}",
253
257
  "ui.sessionTimeline.notice.agentChanged": "Agent changed",
@@ -275,10 +279,15 @@ const source = {
275
279
  } satisfies Record<string, string>
276
280
 
277
281
  export type Key = keyof typeof source
282
+
278
283
  export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other"
284
+
279
285
  export type PluralKey = {
280
286
  [Entry in Key]: Entry extends `${infer Base}.other` ? (`${Base}.one` extends Key ? Base : never) : never
281
287
  }[Key]
288
+
282
289
  export type PluralLookupKey = `${PluralKey}.${PluralCategory}`
290
+
283
291
  export type LocaleKey = Key | PluralLookupKey
292
+
284
293
  export const dict: typeof source & Record<string, string> = source