@opencode/ui 2.0.29 → 2.0.31

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.
@@ -20,6 +20,7 @@ export declare function useDialogLayer(): {
20
20
  } | undefined;
21
21
  export declare function DialogProvider(props: ParentProps): JSX.Element;
22
22
  export declare function useDialog(): {
23
+ /** The top dialog that is open. One that has started closing no longer counts, though it waits to be disposed. */
23
24
  readonly active: Active | undefined;
24
25
  /**
25
26
  * id lets the caller close this dialog later rather than whichever is on top. Opening is deferred; once
package/dist/i18n/en.d.ts CHANGED
@@ -218,10 +218,12 @@ declare const source: {
218
218
  "ui.message.collapse": string;
219
219
  "ui.message.copy": string;
220
220
  "ui.message.copyMessage": string;
221
+ "ui.message.copyFullMessage": string;
221
222
  "ui.message.forkMessage": string;
222
223
  "ui.message.revertMessage": string;
223
224
  "ui.message.moveToQueue": string;
224
225
  "ui.message.pending": string;
226
+ "ui.message.voice": string;
225
227
  "ui.message.deletePending": string;
226
228
  "ui.message.copyResponse": string;
227
229
  "ui.message.copied": string;
@@ -264,6 +264,10 @@ declare const icons: {
264
264
  viewBox: string;
265
265
  body: string;
266
266
  };
267
+ waveform: {
268
+ viewBox: string;
269
+ body: string;
270
+ };
267
271
  archive: {
268
272
  viewBox: string;
269
273
  body: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode/ui",
3
- "version": "2.0.29",
3
+ "version": "2.0.31",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,17 +7,23 @@
7
7
 
8
8
  [data-slot="animated-number-value"] {
9
9
  display: inline-flex;
10
- flex-direction: row-reverse;
10
+ /* Digits keep numeric order in RTL documents. */
11
+ direction: ltr;
11
12
  align-items: baseline;
12
13
  justify-content: flex-end;
13
14
  line-height: inherit;
14
15
  width: var(--animated-number-width, 1ch);
15
16
  overflow: hidden;
17
+ }
18
+
19
+ [data-slot="animated-number-value"][data-resizing="true"] {
16
20
  transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
17
21
  }
18
22
 
19
23
  [data-slot="animated-number-digit"] {
20
24
  display: inline-grid;
25
+ /* Digits outside the width are clipped at the start edge, not squeezed. */
26
+ flex-shrink: 0;
21
27
  width: 1ch;
22
28
  height: 1em;
23
29
  line-height: 1em;
@@ -56,6 +62,11 @@
56
62
  line-height: 1em;
57
63
  }
58
64
 
65
+ [data-slot="animated-number-static"]::before,
66
+ [data-slot="animated-number-cell"]::before {
67
+ content: attr(data-digit);
68
+ }
69
+
59
70
  [data-slot="animated-number-digit"][data-animating="true"] [data-slot="animated-number-static"] {
60
71
  visibility: hidden;
61
72
  }
@@ -63,8 +74,10 @@
63
74
  [data-slot="animated-number-strip"] {
64
75
  display: inline-flex;
65
76
  flex-direction: column;
66
- margin-top: calc(var(--animated-number-offset, 10) * -1em);
67
- transition-property: margin-top;
77
+ /* Not transform: a composited roll lands on whole pixels, then the digit shifts as it settles. */
78
+ position: relative;
79
+ top: calc(var(--animated-number-offset, 10) * -1em);
80
+ transition-property: top;
68
81
  transition-duration: var(--animated-number-duration, 560ms);
69
82
  transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
70
83
  }
@@ -76,7 +89,7 @@
76
89
  }
77
90
 
78
91
  @media (prefers-reduced-motion: reduce) {
79
- [data-component="animated-number"] [data-slot="animated-number-value"] {
92
+ [data-component="animated-number"] [data-slot="animated-number-value"][data-resizing] {
80
93
  transition-duration: 0ms;
81
94
  }
82
95
 
@@ -1,4 +1,4 @@
1
- import { For, Index, createEffect, createMemo, on } from "solid-js"
1
+ import { For, createEffect, createMemo, on } from "solid-js"
2
2
  import { createStore } from "solid-js/store"
3
3
 
4
4
  const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
@@ -26,6 +26,7 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
26
26
  const step = () => state.step
27
27
  const animating = () => state.animating
28
28
  let last = props.value
29
+ let strip: HTMLSpanElement | undefined
29
30
 
30
31
  createEffect(
31
32
  on(
@@ -34,15 +35,19 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
34
35
  const delta = spin(last, next, props.direction)
35
36
  last = next
36
37
 
37
- if (!delta) {
38
- setState("animating", false)
39
- setState("step", next + 10)
38
+ // An unchanged digit keeps any roll it is in the middle of.
39
+ if (!delta) return
40
+
41
+ const target = step() + delta
42
+
43
+ // A detached digit never finishes a roll, and rapid changes can run off the track, so both settle at once.
44
+ if (!strip?.isConnected || target < 0 || target >= TRACK.length) {
45
+ setState({ animating: false, step: next + 10 })
40
46
 
41
47
  return
42
48
  }
43
49
 
44
- setState("animating", true)
45
- setState("step", (value) => value + delta)
50
+ setState({ animating: true, step: target })
46
51
  },
47
52
  { defer: true },
48
53
  ),
@@ -50,8 +55,9 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
50
55
 
51
56
  return (
52
57
  <span data-slot="animated-number-digit" data-animating={animating() ? "true" : "false"}>
53
- <span data-slot="animated-number-static">{props.value}</span>
58
+ <span data-slot="animated-number-static" data-digit={props.value} />
54
59
  <span
60
+ ref={strip}
55
61
  data-slot="animated-number-strip"
56
62
  onTransitionEnd={() => {
57
63
  setState("animating", false)
@@ -62,7 +68,7 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
62
68
  "--animated-number-duration": `var(--tool-motion-odometer-ms, ${DURATION}ms)`,
63
69
  }}
64
70
  >
65
- <For each={TRACK}>{(value) => <span data-slot="animated-number-cell">{value}</span>}</For>
71
+ <For each={TRACK}>{(value) => <span data-slot="animated-number-cell" data-digit={value} />}</For>
66
72
  </span>
67
73
  </span>
68
74
  )
@@ -75,13 +81,18 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
75
81
  return Math.max(0, Math.round(props.value))
76
82
  })
77
83
 
78
- const [state, setState] = createStore({
84
+ // A spare leading zero waits outside the clipped width to roll in. A digit that leaves stays until the width has
85
+ // shrunk past it, so it rolls to zero instead of vanishing.
86
+ const [state, setState] = createStore<{ value: number; direction: 1 | -1; places: number; resizing: boolean }>({
79
87
  value: target(),
80
- direction: 1 as 1 | -1,
88
+ direction: 1,
89
+ places: String(target()).length + 1,
90
+ resizing: false,
81
91
  })
82
92
 
83
93
  const value = () => state.value
84
94
  const direction = () => state.direction
95
+ let box: HTMLSpanElement | undefined
85
96
 
86
97
  createEffect(
87
98
  on(
@@ -91,8 +102,22 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
91
102
 
92
103
  if (next === current) return
93
104
 
94
- setState("direction", next > current ? 1 : -1)
95
- setState("value", next)
105
+ const digits = String(next).length
106
+
107
+ // A detached number cannot ease its width and would never hear it end, so it settles at once.
108
+ if (!box?.isConnected) {
109
+ setState({ direction: next > current ? 1 : -1, value: next, places: digits + 1, resizing: false })
110
+
111
+ return
112
+ }
113
+
114
+ // Only a change to the digit count eases the width, so a late font swap or zoom never animates it.
115
+ setState({
116
+ direction: next > current ? 1 : -1,
117
+ value: next,
118
+ places: Math.max(state.places, digits + 1),
119
+ resizing: state.resizing || digits !== String(current).length,
120
+ })
96
121
  },
97
122
  { defer: true },
98
123
  ),
@@ -100,22 +125,26 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
100
125
 
101
126
  const label = createMemo(() => value().toString())
102
127
 
103
- const digits = createMemo(() =>
104
- Array.from(label(), (char) => {
105
- const code = char.charCodeAt(0) - 48
106
-
107
- if (code < 0 || code > 9) return 0
108
-
109
- return code
110
- }).reverse(),
111
- )
112
-
113
- const width = createMemo(() => `${digits().length}ch`)
128
+ // Place values, most significant first. Keying by place keeps each digit rolling as digits appear and leave.
129
+ const places = createMemo(() => Array.from({ length: state.places }, (_, index) => state.places - 1 - index))
114
130
 
115
131
  return (
116
- <span data-component="animated-number" class={props.class} aria-label={label()}>
117
- <span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
118
- <Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
132
+ <span data-component="animated-number" class={props.class}>
133
+ {/* The value is the only text: screen readers and copy read it once, in order. CSS draws the digits. */}
134
+ <span class="sr-only">{label()}</span>
135
+ <span
136
+ ref={box}
137
+ data-slot="animated-number-value"
138
+ data-resizing={state.resizing ? "true" : "false"}
139
+ aria-hidden="true"
140
+ style={{ "--animated-number-width": `${label().length}ch` }}
141
+ onTransitionEnd={(event) => {
142
+ if (event.target === event.currentTarget) setState({ places: label().length + 1, resizing: false })
143
+ }}
144
+ >
145
+ <For each={places()}>
146
+ {(place) => <Digit value={Math.floor(value() / 10 ** place) % 10} direction={direction()} />}
147
+ </For>
119
148
  </span>
120
149
  </span>
121
150
  )
@@ -47,6 +47,9 @@ function init() {
47
47
  const closing = new Map<string, ReturnType<typeof setTimeout> | undefined>()
48
48
  // The same ids, reactive, so the top dialog that stays open owns the focus trap during an exit animation.
49
49
  const [exiting, setExiting] = createSignal<ReadonlySet<string>>(new Set())
50
+ // The top dialog that stays open. A dialog without an exit animation leaves the DOM as soon as it starts closing, so
51
+ // it stops being the active dialog then, not when it is disposed.
52
+ const top = () => stack().findLast((item) => !exiting().has(item.id))
50
53
  const lock = { value: false }
51
54
  const state = { disposed: false }
52
55
 
@@ -161,7 +164,7 @@ function init() {
161
164
 
162
165
  return (
163
166
  <Kobalte
164
- modal={stack().findLast((item) => !exiting().has(item.id))?.id === id}
167
+ modal={top()?.id === id}
165
168
  open={!closing()}
166
169
  onOpenChange={(open: boolean) => {
167
170
  if (!open) dismiss(id)
@@ -231,6 +234,7 @@ function init() {
231
234
 
232
235
  return {
233
236
  stack,
237
+ top,
234
238
  close,
235
239
  show,
236
240
  push,
@@ -302,8 +306,9 @@ export function useDialog() {
302
306
  }
303
307
 
304
308
  return {
309
+ /** The top dialog that is open. One that has started closing no longer counts, though it waits to be disposed. */
305
310
  get active() {
306
- return ctx.stack().at(-1)
311
+ return ctx.top()
307
312
  },
308
313
  /**
309
314
  * id lets the caller close this dialog later rather than whichever is on top. Opening is deferred; once
package/src/i18n/en.ts CHANGED
@@ -241,10 +241,12 @@ const source = {
241
241
  "ui.message.collapse": "Collapse message",
242
242
  "ui.message.copy": "Copy",
243
243
  "ui.message.copyMessage": "Copy message",
244
+ "ui.message.copyFullMessage": "Copy full message",
244
245
  "ui.message.forkMessage": "Fork to new session",
245
246
  "ui.message.revertMessage": "Revert message",
246
247
  "ui.message.moveToQueue": "Move to queue",
247
248
  "ui.message.pending": "Pending",
249
+ "ui.message.voice": "Voice",
248
250
  "ui.message.deletePending": "Delete",
249
251
  "ui.message.copyResponse": "Copy response",
250
252
  "ui.message.copied": "Copied",
@@ -266,6 +266,10 @@ const icons = {
266
266
  viewBox: "0 0 16 16",
267
267
  body: `<path d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z" fill="currentColor"/>`,
268
268
  },
269
+ waveform: {
270
+ viewBox: "0 0 16 16",
271
+ body: `<path d="M2 7V9M4.5 5V11M7 2.5V13.5M9.5 4.5V11.5M12 6V10M14.5 7.25V8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>`,
272
+ },
269
273
  archive: {
270
274
  viewBox: "0 0 16 16",
271
275
  body: `<path d="M13.1112 13.5555V14.0555H13.6112V13.5555H13.1112ZM2.889 13.5555H2.389L2.389 14.0555H2.889V13.5555ZM3.38901 5.55546L3.38901 5.05546L2.38901 5.05546L2.38901 5.55546L2.88901 5.55546L3.38901 5.55546ZM14.4446 2.44434H14.9446V1.94434L14.4446 1.94434L14.4446 2.44434ZM14.4446 5.55545L14.4446 6.05545L14.9446 6.05545V5.55545H14.4446ZM1.55566 5.55546L1.05566 5.55545L1.05566 6.05546L1.55566 6.05546L1.55566 5.55546ZM1.5557 2.44436L1.5557 1.94436L1.05571 1.94436L1.0557 2.44435L1.5557 2.44436ZM13.1112 5.55546H12.6112V13.5555H13.1112H13.6112V5.55546H13.1112ZM2.889 13.5555H3.389L3.38901 5.55546L2.88901 5.55546L2.38901 5.55546L2.389 13.5555H2.889ZM14.4446 2.44434H13.9446V5.55545H14.4446H14.9446V2.44434H14.4446ZM1.55566 5.55546L2.05566 5.55547L2.0557 2.44436L1.5557 2.44436L1.0557 2.44435L1.05566 5.55545L1.55566 5.55546ZM6.22234 8.22213V8.72213H9.7779V8.22213V7.72213H6.22234V8.22213ZM13.1112 13.5555V13.0555H2.889V13.5555V14.0555H13.1112V13.5555ZM1.5557 2.44436L1.5557 2.94436L14.4446 2.94434L14.4446 2.44434L14.4446 1.94434L1.5557 1.94436L1.5557 2.44436ZM14.4446 5.55545L14.4446 5.05545L1.55566 5.05546L1.55566 5.55546L1.55566 6.05546L14.4446 6.05545L14.4446 5.55545Z" fill="currentColor"/>`,
@@ -271,7 +271,6 @@
271
271
 
272
272
  #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] {
273
273
  [data-slot="tabs-list"] {
274
- height: 52px;
275
274
  padding-inline-end: 12px;
276
275
  gap: 8px;
277
276
  }
@@ -134,59 +134,93 @@
134
134
  inset: 0;
135
135
  }
136
136
 
137
- [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"] {
138
- box-sizing: border-box;
137
+ /* Every row ends in the change letter or a blank end that sticks inside the pane while long rows
138
+ scroll. It fades the label out over 16px like tab titles and redraws the row's rounded end, so a
139
+ row that runs past the pane edge looks like a row that fits. */
140
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"]) {
141
+ --file-tree-v2-end-inset: 8px;
142
+
139
143
  flex: none;
144
+ align-self: stretch;
140
145
  position: sticky;
141
- inset-inline-end: 8px;
146
+ inset-inline-end: var(--file-tree-v2-end-inset);
142
147
  z-index: 1;
143
- display: flex;
144
- flex-direction: row;
145
- align-items: center;
146
- justify-content: center;
147
- width: 16px;
148
- height: 16px;
149
- font-size: 11px;
150
- font-weight: 530;
151
- line-height: 1;
152
- letter-spacing: -0.04px;
153
- text-align: center;
154
- text-transform: uppercase;
155
- font-variant-numeric: tabular-nums lining-nums;
156
- font-feature-settings:
157
- "tnum" on,
158
- "lnum" on;
159
- background:
160
- linear-gradient(var(--file-tree-v2-row-overlay), var(--file-tree-v2-row-overlay)), var(--v2-background-bg-base);
161
148
  }
162
149
 
163
- [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"]::before {
150
+ [data-component="file-tree-v2"] [data-slot="file-tree-v2-end"] {
151
+ --file-tree-v2-end-inset: 10px;
152
+ }
153
+
154
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::before,
155
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::after {
164
156
  content: "";
165
157
  position: absolute;
166
158
  inset-block: 0;
159
+ pointer-events: none;
160
+ }
161
+
162
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::before {
167
163
  inset-inline-end: 100%;
168
164
  width: 16px;
169
- pointer-events: none;
170
165
  background:
171
166
  linear-gradient(to right, transparent, var(--file-tree-v2-row-overlay)),
172
167
  linear-gradient(to right, transparent, var(--v2-background-bg-base));
173
168
  }
174
169
 
175
- [data-component="file-tree-v2"]:dir(rtl) [data-slot="file-tree-v2-change"]::before {
170
+ [data-component="file-tree-v2"]:dir(rtl)
171
+ :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::before {
176
172
  background:
177
173
  linear-gradient(to left, transparent, var(--file-tree-v2-row-overlay)),
178
174
  linear-gradient(to left, transparent, var(--v2-background-bg-base));
179
175
  }
180
176
 
181
- [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"]::after {
182
- content: "";
183
- position: absolute;
184
- inset-block: 0;
185
- inset-inline-start: 100%;
186
- width: 16px;
187
- pointer-events: none;
177
+ /* The row through its rounded end. The shadow paints the pane past it, outside its corners too. */
178
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::after {
179
+ inset-inline: 0 calc(-1 * var(--file-tree-v2-end-inset));
180
+ z-index: -1;
181
+ border-start-end-radius: 6px;
182
+ border-end-end-radius: 6px;
188
183
  background:
189
184
  linear-gradient(var(--file-tree-v2-row-overlay), var(--file-tree-v2-row-overlay)), var(--v2-background-bg-base);
185
+ box-shadow: 8px 0 0 6px var(--v2-background-bg-base);
186
+ }
187
+
188
+ [data-component="file-tree-v2"]:dir(rtl) :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::after {
189
+ box-shadow: -8px 0 0 6px var(--v2-background-bg-base);
190
+ }
191
+
192
+ @keyframes file-tree-v2-end-fade {
193
+ to {
194
+ opacity: 0;
195
+ }
196
+ }
197
+
198
+ /* Scrolled to the end, every label is whole, so the fade clears. */
199
+ @supports (animation-timeline: scroll()) {
200
+ [data-component="file-tree-v2"] :is([data-slot="file-tree-v2-change"], [data-slot="file-tree-v2-end"])::before {
201
+ animation: file-tree-v2-end-fade linear both;
202
+ animation-timeline: scroll(nearest inline);
203
+ animation-range: calc(100% - 16px) 100%;
204
+ }
205
+ }
206
+
207
+ [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"] {
208
+ box-sizing: border-box;
209
+ display: flex;
210
+ flex-direction: row;
211
+ align-items: center;
212
+ justify-content: center;
213
+ width: 16px;
214
+ font-size: 11px;
215
+ font-weight: 530;
216
+ line-height: 1;
217
+ letter-spacing: -0.04px;
218
+ text-align: center;
219
+ text-transform: uppercase;
220
+ font-variant-numeric: tabular-nums lining-nums;
221
+ font-feature-settings:
222
+ "tnum" on,
223
+ "lnum" on;
190
224
  }
191
225
 
192
226
  [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="modified"] {
@@ -8,7 +8,6 @@
8
8
  @source not "../../../../app/src/**/*.test.{ts,tsx}";
9
9
  @source "../../../../desktop/src/renderer";
10
10
  @source not "../../../../desktop/src/renderer/**/*.test.{ts,tsx}";
11
- @source "../../../../enterprise/src";
12
11
  @source "../../../../session-ui/src";
13
12
  @source "../../../../gui-extensions/src";
14
13
  @source not "../../../../session-ui/src/**/*.test.{ts,tsx}";