@opencode/ui 2.0.20 → 2.0.22

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.
@@ -14,8 +14,12 @@ export declare function useDialogLayer(): {
14
14
  export declare function DialogProvider(props: ParentProps): JSX.Element;
15
15
  export declare function useDialog(): {
16
16
  readonly active: Active | undefined;
17
- show(element: DialogElement, onClose?: () => void): Promise<void>;
18
- push(element: DialogElement, onClose?: () => void): Promise<void>;
19
- close(): void;
17
+ /**
18
+ * id lets the caller close this dialog later rather than whichever is on top. Opening is deferred; once
19
+ * signal aborts, the open neither mounts nor replaces anything and onClose runs as for a replaced dialog.
20
+ */
21
+ show(element: DialogElement, onClose?: () => void, id?: string, signal?: AbortSignal): Promise<void>;
22
+ push(element: DialogElement, onClose?: () => void, id?: string, signal?: AbortSignal): Promise<void>;
23
+ close(id?: string): void;
20
24
  };
21
25
  export {};
@@ -3,6 +3,16 @@ import "./project-avatar.css";
3
3
  export declare const PROJECT_AVATAR_VARIANTS: readonly ["orange", "yellow", "cyan", "green", "red", "pink", "blue", "purple", "gray"];
4
4
  export type ProjectAvatarVariant = (typeof PROJECT_AVATAR_VARIANTS)[number];
5
5
  export type ProjectAvatarStyle = ProjectAvatarVariant | "outline";
6
+ export declare function getProjectAvatarSource(id?: string, icon?: {
7
+ color?: string;
8
+ url?: string;
9
+ override?: string;
10
+ }): string | undefined;
11
+ export declare function getProjectAvatarVariant(key?: string): ProjectAvatarVariant;
12
+ export declare const displayName: (project: {
13
+ name?: string;
14
+ worktree: string;
15
+ }) => string;
6
16
  export interface ProjectAvatarProps extends ComponentProps<"div"> {
7
17
  fallback: string;
8
18
  src?: string;
@@ -10,6 +10,10 @@ declare const icons: {
10
10
  viewBox: string;
11
11
  body: string;
12
12
  };
13
+ "select-element": {
14
+ viewBox: string;
15
+ body: string;
16
+ };
13
17
  flask: {
14
18
  viewBox: string;
15
19
  body: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode/ui",
3
- "version": "2.0.20",
3
+ "version": "2.0.22",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
  }
@@ -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
@@ -12,6 +12,10 @@ const icons = {
12
12
  viewBox: "0 0 16 16",
13
13
  body: `<circle cx="8" cy="8" r="6" stroke="currentColor"/><ellipse cx="8" cy="8" rx="2.5" ry="6" stroke="currentColor"/><path d="M2 8h12" stroke="currentColor"/>`,
14
14
  },
15
+ "select-element": {
16
+ viewBox: "0 0 16 16",
17
+ body: `<path d="M12.5 6.5V2.5C12.5 1.94772 12.0523 1.5 11.5 1.5H2.5C1.94772 1.5 1.5 1.94772 1.5 2.5V11.5C1.5 12.0523 1.94772 12.5 2.5 12.5H6.5" stroke="currentColor" stroke-linecap="round"/><path d="M7.5 7.5L14.5 10.1L11.1 11.1L10.1 14.5L7.5 7.5Z" stroke="currentColor" stroke-linejoin="round"/>`,
18
+ },
15
19
  flask: {
16
20
  viewBox: "0 0 16 16",
17
21
  body: `<path d="M5.5 2H10.5M6 2V6L2.5 12C2 13 2.5 14 3.5 14H12.5C13.5 14 14 13 13.5 12L10 6V2M4.25 9H11.75" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,
@@ -211,11 +211,6 @@
211
211
  transform 120ms ease;
212
212
  }
213
213
 
214
- &[data-value="review"] {
215
- padding-left: 8px;
216
- padding-right: 8px;
217
- }
218
-
219
214
  [data-slot="tabs-trigger"] {
220
215
  height: 100%;
221
216
  padding: 0 !important;
@@ -552,18 +547,6 @@
552
547
  }
553
548
  }
554
549
 
555
- &[data-value="open-file"],
556
- &[data-value^="file://"],
557
- &[data-value="browser"],
558
- &[data-value^="browser:"] {
559
- gap: 8px;
560
- padding-inline-end: 4px;
561
-
562
- [data-slot="tabs-trigger-close-button"] {
563
- margin-inline-start: 0;
564
- }
565
- }
566
-
567
550
  &:has([data-selected]) {
568
551
  background-color: var(--v2-background-bg-layer-02);
569
552
  box-shadow: none;
@@ -581,7 +564,7 @@
581
564
  font-variation-settings: "slnt" 0;
582
565
  font-variant-numeric: tabular-nums;
583
566
 
584
- &[data-selected]:not([data-value="open-file"]) {
567
+ &[data-selected] {
585
568
  color: var(--v2-text-text-base);
586
569
  }
587
570
 
@@ -10,6 +10,7 @@
10
10
  @source not "../../../../desktop/src/renderer/**/*.test.{ts,tsx}";
11
11
  @source "../../../../enterprise/src";
12
12
  @source "../../../../session-ui/src";
13
+ @source "../../../../gui-extensions/src";
13
14
  @source not "../../../../session-ui/src/**/*.test.{ts,tsx}";
14
15
  @source "../../../../storybook";
15
16
  @source "../../../src";
@@ -11,6 +11,7 @@
11
11
  --font-size-x-large: 20px;
12
12
  --font-weight-regular: 400;
13
13
  --font-weight-medium: 500;
14
+ --font-weight-bold: 700;
14
15
  --line-height-normal: 130%;
15
16
  --line-height-large: 150%;
16
17
  --line-height-x-large: 180%;