@jskit-ai/assistant-core 0.2.24 → 0.2.25

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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- - The avatar's Show and Minimise buttons set no fixed height: they are 40dp icon buttons with material-web's 48dp touch target, so `jskit check m3` passes on `AssistantConversationElement`.
5
+ - The conversation's remaining boxes are Material 3 tonal containers instead of outlines, visual only: queued and sent attachments and the questions are `surface-container-high`, code blocks and inline code `surface-container-highest`, tables and details `surface-container-low` (a table keeps `outline-variant` row dividers), question numbers `secondary-container` badges, and the model menu a `surface-container` surface at elevation level 2 with `outline-variant` dividers. The composer stays Material 3's outlined text field, the only outline left.
6
+ - `AssistantSettingsFormCard` takes a `formId`: its form gets that id and the card draws no Save button of its own, so the page's app bar can carry Save as a submit button for the form (`type="submit" form="<formId>"`), as a Material 3 settings pane does. Without one, the card keeps its Save; a person who may not save still sees the read-only note.
7
+ - Material 3 containers in the conversation, visual only: the transcript area is no longer outlined (its bubbles are the only containers, and the welcome bubble is a tonal fill without an outline), the composer is Material 3's outlined text field (4dp corners, the `outline` role, a 2dp primary outline on focus, 16dp side padding) with Send and the other actions 12dp from it (8dp when compact), and `AssistantSettingsFormCard` adds no heading or divider inside the settings pane that titles it: its subtitle is the card's first line and its title names the section for assistive technology.
8
+ - The avatar's Show and Minimise buttons set no fixed size: they are 40dp icon buttons with material-web's 48dp touch target, so `jskit check m3` passes on `AssistantConversationElement`. The collapsed Show button is 40dp wide like Minimise, no longer 44dp.
6
9
  - The goal menu closes when Cancel succeeds, as it does after Start; Pause and Resume keep it open.
7
10
  - `AssistantConversationElement` has an `avatar-thumbnail` slot, with the `{ size, height }` scope of the other avatar slots: while the avatar is hidden, the **Show avatar** button draws its content instead of the anonymous account icon, such as a still miniature of the avatar, clipped to the button's round 32-pixel disc. The button keeps its label, title and 44-pixel target, and shows the icon as before without the slot.
8
11
  - A delivered Codex turn is held as not yet started only until Codex reports it: a `turn/started` that arrives before the `turn/start` answer counts, and a thread read listing exactly that turn as completed or failed settles a turn whose notifications were missed, so its answer is no longer kept waiting for a notification that never comes.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.24",
12
- "@jskit-ai/http-runtime": "0.2.10",
13
- "@jskit-ai/kernel": "0.2.10",
14
- "@jskit-ai/material-web": "0.1.2",
11
+ "@jskit-ai/assistant-core": "0.2.25",
12
+ "@jskit-ai/http-runtime": "0.2.11",
13
+ "@jskit-ai/kernel": "0.2.11",
14
+ "@jskit-ai/material-web": "0.1.3",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.11",
18
- "@jskit-ai/connectors-catalog": "0.2.11"
17
+ "@jskit-ai/connectors-core": "0.2.12",
18
+ "@jskit-ai/connectors-catalog": "0.2.12"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
4
4
  "type": "module",
5
5
  "scripts": {
6
- "test": "node --test --test-concurrency=1"
6
+ "test": "jskit test"
7
7
  },
8
8
  "exports": {
9
9
  "./client": "./src/client/index.js",
@@ -51,9 +51,9 @@
51
51
  "@ai-sdk/perplexity": "^4.0.43",
52
52
  "@ai-sdk/togetherai": "^3.0.49",
53
53
  "@ai-sdk/xai": "^4.0.58",
54
- "@jskit-ai/agent": "0.1.1",
55
- "@jskit-ai/resource-core": "0.1.201",
56
- "@jskit-ai/resource-crud-core": "0.1.201",
54
+ "@jskit-ai/agent": "0.1.2",
55
+ "@jskit-ai/resource-core": "0.1.202",
56
+ "@jskit-ai/resource-crud-core": "0.1.202",
57
57
  "@openrouter/ai-sdk-provider": "^3.0.0",
58
58
  "ai": "^7.0.152",
59
59
  "json-rest-schema": "^1.0.17",
@@ -61,8 +61,8 @@
61
61
  "ws": "^8.20.1"
62
62
  },
63
63
  "peerDependencies": {
64
- "@jskit-ai/http-runtime": "0.2.10",
65
- "@jskit-ai/kernel": "0.2.10",
64
+ "@jskit-ai/http-runtime": "0.2.11",
65
+ "@jskit-ai/kernel": "0.2.11",
66
66
  "vue": "^3.5.13",
67
67
  "vuetify": "^4.3.0"
68
68
  },
@@ -220,6 +220,6 @@
220
220
  }
221
221
  },
222
222
  "devDependencies": {
223
- "@jskit-ai/material-web": "0.1.2"
223
+ "@jskit-ai/material-web": "0.1.3"
224
224
  }
225
225
  }
@@ -1,12 +1,10 @@
1
1
  <template>
2
- <section :class="props.rootClass">
2
+ <!-- The settings pane around it carries the page title, so the card adds no heading of its own: the title names the
3
+ section for assistive technology and the subtitle says what the settings apply to. -->
4
+ <section :class="props.rootClass" :aria-label="props.title">
3
5
  <v-card>
4
- <v-card-item>
5
- <v-card-title class="text-title-large">{{ props.title }}</v-card-title>
6
- <v-card-subtitle>{{ props.subtitle }}</v-card-subtitle>
7
- </v-card-item>
8
- <v-divider />
9
- <v-card-text class="pt-4">
6
+ <v-card-text>
7
+ <p class="text-body-medium on-surface-variant mb-4">{{ props.subtitle }}</p>
10
8
  <template v-if="props.showFormSkeleton">
11
9
  <v-skeleton-loader type="text@2, list-item-two-line@4, button" />
12
10
  </template>
@@ -20,10 +18,12 @@
20
18
  </p>
21
19
 
22
20
  <template v-else>
23
- <v-form @submit.prevent="props.addEdit.submit" novalidate>
21
+ <!-- With a formId, the page's app bar carries Save (a submit button for this form), as a settings pane does;
22
+ the card then shows only the read-only note. -->
23
+ <v-form :id="props.formId || undefined" @submit.prevent="props.addEdit.submit" novalidate>
24
24
  <v-progress-linear v-if="props.addEdit.isRefetching" indeterminate class="mb-4" />
25
25
  <slot />
26
- <div class="d-flex align-center justify-end ga-3 mt-2">
26
+ <div v-if="!props.formId || !props.addEdit.canSave" class="d-flex align-center justify-end ga-3 mt-2">
27
27
  <v-btn
28
28
  v-if="props.addEdit.canSave"
29
29
  type="submit"
@@ -74,6 +74,11 @@ const props = defineProps({
74
74
  showFormSkeleton: {
75
75
  type: Boolean,
76
76
  default: false
77
+ },
78
+ // The form's id, when the page's app bar carries its Save button (type="submit" form="<formId>").
79
+ formId: {
80
+ type: String,
81
+ default: ""
77
82
  }
78
83
  });
79
84
  </script>
@@ -307,10 +307,9 @@ onBeforeUnmount(() => {
307
307
  </script>
308
308
 
309
309
  <style scoped>
310
+ /* A Material 3 tonal container above the composer: a surface-container-high fill, no outline. */
310
311
  .assistant-attachment-queue {
311
- background: rgba(var(--v-theme-on-surface), 0.035);
312
- border: 1px solid rgba(var(--v-theme-outline), 0.2);
313
- border-bottom: 0;
312
+ background: rgb(var(--v-theme-surface-container-high));
314
313
  border-radius: 14px 14px 0 0;
315
314
  color: rgb(var(--v-theme-on-surface));
316
315
  display: grid;
@@ -332,7 +331,6 @@ onBeforeUnmount(() => {
332
331
  }
333
332
 
334
333
  .assistant-attachment-queue--standalone {
335
- border-bottom: 1px solid rgba(var(--v-theme-outline), 0.2);
336
334
  border-radius: 10px;
337
335
  }
338
336
 
@@ -8,7 +8,7 @@
8
8
  <slot name="avatar-control" :size="avatarSize" :height="avatarHeight" />
9
9
  <v-btn
10
10
  icon="$accountCircle" size="small"
11
- variant="text" :width="44" :min-width="44"
11
+ variant="text"
12
12
  class="assistant-conversation__avatar-size"
13
13
  :aria-label="t('jskit.assistant.chat.showAvatar')" :title="t('jskit.assistant.chat.showAvatar')"
14
14
  :aria-expanded="avatarSize !== 'hidden'" @click="toggleAvatar"
@@ -26,7 +26,7 @@
26
26
  <slot name="avatar-tools" :size="avatarSize" :height="avatarHeight" />
27
27
  <v-btn
28
28
  icon="$minus" size="small" variant="text" color="primary"
29
- :width="40" :min-width="40" class="assistant-conversation__avatar-size"
29
+ class="assistant-conversation__avatar-size"
30
30
  :aria-label="t('jskit.assistant.chat.minimiseAvatar')" :title="t('jskit.assistant.chat.minimiseAvatar')" :aria-expanded="true"
31
31
  @click="toggleAvatar"
32
32
  >
@@ -70,10 +70,10 @@ function attachmentKey(attachment, index) {
70
70
  width: min(19rem, 100%);
71
71
  }
72
72
 
73
+ /* Each attachment is a Material 3 tonal container: a surface-container-high fill, no outline. */
73
74
  .assistant-conversation-attachments__item {
74
75
  align-items: center;
75
- background: rgba(var(--v-theme-surface), 0.82);
76
- border: 1px solid rgba(var(--v-theme-outline), 0.2);
76
+ background: rgb(var(--v-theme-surface-container-high));
77
77
  border-radius: 10px;
78
78
  color: rgb(var(--v-theme-on-surface));
79
79
  min-height: 2.8rem;
@@ -20,11 +20,12 @@
20
20
  />
21
21
  </template>
22
22
 
23
+ <!-- A Material 3 menu surface: surface-container at elevation level 2, no outline. -->
23
24
  <v-sheet
24
25
  :aria-label="t('jskit.assistant.model.selector')"
25
- border
26
26
  class="assistant-model-control"
27
- :elevation="3"
27
+ color="surface-container"
28
+ :elevation="2"
28
29
  rounded="xl"
29
30
  >
30
31
  <header class="assistant-model-control__header">
@@ -235,7 +236,7 @@ function selectVariant(value) { if (!props.changesDisabled && !props.saving) emi
235
236
 
236
237
  .assistant-model-control__header {
237
238
  align-items: center;
238
- border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
239
+ border-bottom: 1px solid rgb(var(--v-theme-outline-variant));
239
240
  display: flex;
240
241
  gap: 0.65rem;
241
242
  padding: 0.1rem 0.1rem 0.7rem;
@@ -311,7 +312,7 @@ function selectVariant(value) { if (!props.changesDisabled && !props.saving) emi
311
312
 
312
313
  .assistant-model-control__actions {
313
314
  align-items: center;
314
- border-top: 1px solid rgba(var(--v-theme-outline), 0.12);
315
+ border-top: 1px solid rgb(var(--v-theme-outline-variant));
315
316
  display: grid;
316
317
  gap: 0.4rem;
317
318
  grid-template-columns: auto 1fr auto;
@@ -261,12 +261,13 @@ defineExpose({ inputElement: textareaRef, focus: focusTextarea, preserveHeightFo
261
261
  width: 100%;
262
262
  }
263
263
 
264
+ /* Material 3's outlined text field: 4dp corners, a 1dp outline that becomes a 2dp primary one on focus (drawn inset, so
265
+ nothing moves), 16dp side padding, and the label in the outline. */
264
266
  .assistant-prompt-input__field {
265
267
  background: rgb(var(--v-theme-surface));
266
- border: 1px solid rgba(var(--v-theme-on-surface), 0.34);
267
- border-radius: 18px;
268
+ border: 1px solid rgb(var(--v-theme-outline));
269
+ border-radius: 4px;
268
270
  box-sizing: border-box;
269
- box-shadow: inset 0 0 0 1px rgba(var(--v-theme-on-surface), 0.08);
270
271
  display: grid;
271
272
  max-width: 100%;
272
273
  min-width: 0;
@@ -274,29 +275,33 @@ defineExpose({ inputElement: textareaRef, focus: focusTextarea, preserveHeightFo
274
275
  transition: border-color 120ms ease, box-shadow 120ms ease;
275
276
  }
276
277
 
278
+ .assistant-prompt-input__field:not(.assistant-prompt-input__field--disabled):hover {
279
+ border-color: rgb(var(--v-theme-on-surface));
280
+ }
281
+
277
282
  .assistant-prompt-input__field:not(.assistant-prompt-input__field--disabled):focus-within {
278
283
  border-color: rgb(var(--v-theme-primary));
279
- box-shadow:
280
- 0 0 0 2px rgba(var(--v-theme-primary), 0.28),
281
- inset 0 0 0 1px rgba(var(--v-theme-primary), 0.2);
284
+ box-shadow: inset 0 0 0 1px rgb(var(--v-theme-primary));
282
285
  }
283
286
 
284
287
  .assistant-prompt-input__field--disabled {
285
- background: rgba(var(--v-theme-on-surface), 0.04);
286
- border-color: rgba(var(--v-theme-on-surface), 0.16);
287
- box-shadow: none;
288
+ border-color: rgba(var(--v-theme-on-surface), 0.12);
289
+ }
290
+
291
+ .assistant-prompt-input__field:not(.assistant-prompt-input__field--disabled):focus-within .assistant-prompt-input__label {
292
+ color: rgb(var(--v-theme-primary));
288
293
  }
289
294
 
290
295
  .assistant-prompt-input__label {
291
296
  align-self: start;
292
297
  background: rgb(var(--v-theme-surface));
293
- color: rgba(var(--v-theme-on-surface), 0.82);
294
- font-size: 0.78rem;
295
- line-height: 1.1;
296
- margin: -0.5rem 0 0 0.9rem;
297
- max-width: calc(100% - 1.8rem);
298
+ color: rgb(var(--v-theme-on-surface-variant));
299
+ font-size: 0.75rem;
300
+ line-height: 1rem;
301
+ margin: -0.5rem 0 0 12px;
302
+ max-width: calc(100% - 24px);
298
303
  overflow: hidden;
299
- padding-inline: 0.24rem;
304
+ padding-inline: 4px;
300
305
  text-overflow: ellipsis;
301
306
  white-space: nowrap;
302
307
  width: fit-content;
@@ -349,19 +354,20 @@ defineExpose({ inputElement: textareaRef, focus: focusTextarea, preserveHeightFo
349
354
  opacity: 1;
350
355
  }
351
356
 
357
+ /* Send and the other actions keep 12dp from the outline (8dp in the compact composer). */
352
358
  .assistant-prompt-input__footer {
353
359
  min-width: 0;
354
- padding: 0 0.55rem 0.55rem;
360
+ padding: 0 12px 12px;
355
361
  }
356
362
 
357
363
  .assistant-prompt-input--compact .assistant-prompt-input__input {
358
364
  max-height: min(8rem, 20dvh);
359
365
  min-height: 2.5rem;
360
- padding: 0.4rem 0.65rem 0.15rem;
366
+ padding: 0.4rem 12px 0.15rem;
361
367
  }
362
368
 
363
369
  .assistant-prompt-input--compact .assistant-prompt-input__footer {
364
- padding: 0 0.3rem 0.25rem;
370
+ padding: 0 8px 8px;
365
371
  }
366
372
 
367
373
  .assistant-prompt-input__details {
@@ -1,7 +1,6 @@
1
1
  .assistant-transcript__plain-user { white-space: pre-wrap; margin: 0; }
2
+ /* The transcript is the page's own area, not a container: its bubbles are the only containers in it. */
2
3
  .assistant-transcript {
3
- border: 1px solid rgba(var(--v-theme-outline), 0.24);
4
- border-radius: 8px;
5
4
  display: grid;
6
5
  gap: 0.35rem;
7
6
  grid-template-rows: minmax(0, 1fr);
@@ -87,7 +86,6 @@
87
86
 
88
87
  .assistant-transcript__welcome {
89
88
  background: rgba(var(--v-theme-primary), 0.08);
90
- border: 1px solid rgba(var(--v-theme-primary), 0.18);
91
89
  border-radius: 16px;
92
90
  color: rgb(var(--v-theme-on-surface));
93
91
  display: grid;
@@ -239,10 +237,6 @@
239
237
  padding: 0;
240
238
  }
241
239
 
242
- .assistant-transcript--task {
243
- border-color: rgba(126, 87, 194, 0.32);
244
- }
245
-
246
240
  .assistant-transcript--task .assistant-transcript__avatar--assistant {
247
241
  background: #7e57c2;
248
242
  }
@@ -416,10 +410,9 @@
416
410
  .assistant-transcript__message--user :deep(.studio-long-text-review__table th > code),
417
411
  .assistant-transcript__message--user :deep(.studio-long-text-review__table td > code),
418
412
  .assistant-transcript__message--user :deep(.studio-long-text-review__details-summary code) {
419
- background: rgba(var(--v-theme-primary), 0.07);
420
- border: 1px solid rgba(var(--v-theme-primary), 0.14);
413
+ background: rgb(var(--v-theme-surface-container-highest));
421
414
  border-radius: 3px;
422
- color: rgba(var(--v-theme-on-surface), 0.9);
415
+ color: rgb(var(--v-theme-on-surface));
423
416
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
424
417
  font-size: 0.9em;
425
418
  padding: 0.02rem 0.2rem;
@@ -436,10 +429,10 @@
436
429
  padding: 0;
437
430
  }
438
431
 
432
+ /* Questions are tonal containers (surface-container-high), their numbers secondary-container badges: no outlines. */
439
433
  .assistant-transcript__question {
440
434
  align-items: start;
441
- background: rgba(var(--v-theme-surface), 0.62);
442
- border: 1px solid rgba(var(--v-theme-outline), 0.2);
435
+ background: rgb(var(--v-theme-surface-container-high));
443
436
  border-radius: 8px;
444
437
  box-sizing: border-box;
445
438
  display: grid;
@@ -452,10 +445,9 @@
452
445
 
453
446
  .assistant-transcript__question-number {
454
447
  align-items: center;
455
- background: rgba(var(--v-theme-primary), 0.1);
456
- border: 1px solid rgba(var(--v-theme-primary), 0.2);
448
+ background: rgb(var(--v-theme-secondary-container));
457
449
  border-radius: 999px;
458
- color: rgb(var(--v-theme-primary));
450
+ color: rgb(var(--v-theme-on-secondary-container));
459
451
  display: inline-flex;
460
452
  font-size: 0.72rem;
461
453
  font-weight: 760;
@@ -44,10 +44,9 @@ function handleLinkClick(event, part = {}) {
44
44
 
45
45
  <style scoped>
46
46
  code {
47
- background: rgba(var(--v-theme-primary), 0.07);
48
- border: 1px solid rgba(var(--v-theme-primary), 0.14);
47
+ background: rgb(var(--v-theme-surface-container-highest));
49
48
  border-radius: 3px;
50
- color: rgba(var(--v-theme-on-surface), 0.9);
49
+ color: rgb(var(--v-theme-on-surface));
51
50
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
52
51
  font-size: 0.9em;
53
52
  overflow-wrap: anywhere;
@@ -240,9 +240,10 @@ h5.studio-long-text-review__heading {
240
240
  font-size: 0.8rem;
241
241
  }
242
242
 
243
+ /* Code, tables and details are Material 3 tonal containers (surface-container tones), not outlined boxes; a
244
+ table keeps outline-variant dividers between its rows. */
243
245
  .studio-long-text-review__code {
244
- background: rgba(var(--v-theme-surface-variant), 0.58);
245
- border: 1px solid rgba(var(--v-border-color), 0.26);
246
+ background: rgb(var(--v-theme-surface-container-highest));
246
247
  border-radius: 8px;
247
248
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
248
249
  font-size: 0.82rem;
@@ -256,7 +257,7 @@ h5.studio-long-text-review__heading {
256
257
  }
257
258
 
258
259
  .studio-long-text-review__table-wrap {
259
- border: 1px solid rgba(var(--v-border-color), 0.28);
260
+ background: rgb(var(--v-theme-surface-container-low));
260
261
  border-radius: 8px;
261
262
  inline-size: fit-content;
262
263
  margin-block: 0.35rem;
@@ -282,7 +283,7 @@ h5.studio-long-text-review__heading {
282
283
 
283
284
  .studio-long-text-review__table th,
284
285
  .studio-long-text-review__table td {
285
- border-bottom: 1px solid rgba(var(--v-border-color), 0.22);
286
+ border-bottom: 1px solid rgb(var(--v-theme-outline-variant));
286
287
  max-inline-size: 28rem;
287
288
  padding: 0.55rem 0.75rem;
288
289
  text-align: left;
@@ -290,7 +291,7 @@ h5.studio-long-text-review__heading {
290
291
  }
291
292
 
292
293
  .studio-long-text-review__table th {
293
- background: rgba(var(--v-theme-surface-variant), 0.48);
294
+ background: rgb(var(--v-theme-surface-container-high));
294
295
  color: rgb(var(--v-theme-on-surface));
295
296
  font-weight: 760;
296
297
  position: sticky;
@@ -320,7 +321,7 @@ h5.studio-long-text-review__heading {
320
321
  }
321
322
 
322
323
  .studio-long-text-review__details {
323
- border: 1px solid rgba(var(--v-border-color), 0.34);
324
+ background: rgb(var(--v-theme-surface-container-low));
324
325
  border-radius: 8px;
325
326
  display: block;
326
327
  min-width: 0;
@@ -550,7 +550,12 @@ export function createCodexAppServerProviderOwner({
550
550
  assertOpen();
551
551
  if (!record.observationStop || record.observationStop.error !== error) {
552
552
  reportRecoveryEvent(record, "lost", error);
553
- const stop = { error, promise: invalidation.suspendUnobservedProvider(record, error) };
553
+ // A shared stop of this native runtime gives each of its peers the failure that started it, and records their
554
+ // barriers and completion itself. A peer that then loses its own connection to the stopped process joins that
555
+ // stop instead of stopping the runtime again, which would reopen its stopped barrier while the first finishes.
556
+ const runtimeKey = codexAppServerOwnedRuntimeKey(record.providerKey, record.providerOptions);
557
+ const shared = runtimePeers(runtimeKey).find(peer => peer !== record && peer.observationStop?.error === error);
558
+ const stop = { error, promise: shared?.observationStop.promise || invalidation.suspendUnobservedProvider(record, error) };
554
559
  record.observationStop = stop;
555
560
  const tracked = stop.promise.catch(failure => {
556
561
  reportRecoveryEvent(record, "stopFailed", failure);
@@ -3,16 +3,14 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
  import test from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
- import { chromium, expect } from "@playwright/test";
7
- import {
8
- createChromiumLaunchOptions,
9
- startViteFixture,
10
- stopProcess
11
- } from "../../../tooling/testUtils/browserFixture.mjs";
6
+ import { expect } from "../../../tooling/testUtils/browserExpect.mjs";
7
+ import { sharedBrowserFixture } from "../../../tooling/testUtils/browserFixture.mjs";
12
8
 
13
9
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
14
10
  const FIXTURE_ROOT = path.resolve(TEST_DIRECTORY, "../fixtures/responsive-assistant");
15
11
  const RUN_BROWSER_TEST = process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION === "1";
12
+ // Every test reads the same fixture app, so the file shares one server and one Chromium; each test gets its own pages.
13
+ const openFixture = sharedBrowserFixture({ fixtureRoot: FIXTURE_ROOT });
16
14
  // Turns use content-visibility estimates. Moving the reader materializes rows
17
15
  // near the new position, and Chromium's scroll anchoring then shifts scrollTop
18
16
  // to keep the same content in view. Hold a reading position only once that
@@ -109,9 +107,8 @@ const VIEWPORTS = Object.freeze([
109
107
 
110
108
  test("progress previews omit expandable details without hiding the current update", {
111
109
  skip: !RUN_BROWSER_TEST, timeout: 60_000
112
- }, async () => {
113
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
114
- const browser = await chromium.launch(createChromiumLaunchOptions());
110
+ }, async (t) => {
111
+ const { vite, browser } = await openFixture(t);
115
112
  try {
116
113
  for (const width of [390, 800, 1365]) {
117
114
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -137,15 +134,13 @@ test("progress previews omit expandable details without hiding the current updat
137
134
  }
138
135
  } finally {
139
136
  await browser.close();
140
- await stopProcess(vite);
141
137
  }
142
138
  });
143
139
 
144
140
  test("saved reply attribution remains when the current assistant changes", {
145
141
  skip: !RUN_BROWSER_TEST, timeout: 60_000
146
- }, async () => {
147
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
148
- const browser = await chromium.launch(createChromiumLaunchOptions());
142
+ }, async (t) => {
143
+ const { vite, browser } = await openFixture(t);
149
144
  try {
150
145
  const page = await browser.newPage();
151
146
  await page.goto(`${vite.baseURL}/?conversation=1&attribution=1`);
@@ -157,15 +152,13 @@ test("saved reply attribution remains when the current assistant changes", {
157
152
  await expect(names).toHaveText(["First assistant", "Message assistant", "Next assistant"]);
158
153
  } finally {
159
154
  await browser.close();
160
- await stopProcess(vite);
161
155
  }
162
156
  });
163
157
 
164
158
  test("streaming answers grow in one bubble, preserve drafts and settle once at every width", {
165
159
  skip: !RUN_BROWSER_TEST, timeout: 90_000
166
- }, async () => {
167
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
168
- const browser = await chromium.launch(createChromiumLaunchOptions());
160
+ }, async (t) => {
161
+ const { vite, browser } = await openFixture(t);
169
162
  try {
170
163
  for (const width of [390, 800, 1365]) {
171
164
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -189,16 +182,14 @@ test("streaming answers grow in one bubble, preserve drafts and settle once at e
189
182
  }
190
183
  } finally {
191
184
  await browser.close();
192
- await stopProcess(vite);
193
185
  }
194
186
  });
195
187
 
196
188
  test("adjacent reasoning stays grouped across storage rows, history pages and execution changes", {
197
189
  skip: !RUN_BROWSER_TEST,
198
190
  timeout: 90_000
199
- }, async () => {
200
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
201
- const browser = await chromium.launch(createChromiumLaunchOptions());
191
+ }, async (t) => {
192
+ const { vite, browser } = await openFixture(t);
202
193
  try {
203
194
  for (const width of [390, 800, 1365]) {
204
195
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -262,7 +253,6 @@ test("adjacent reasoning stays grouped across storage rows, history pages and ex
262
253
  }
263
254
  } finally {
264
255
  await browser.close();
265
- await stopProcess(vite);
266
256
  }
267
257
  });
268
258
 
@@ -286,13 +276,11 @@ test("AssistantConversationElement keeps long conversations scrollable across re
286
276
  skip: RUN_BROWSER_TEST
287
277
  ? false
288
278
  : "set JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION=1 to run assistant-core browser integration",
289
- timeout: 180_000
290
- }, async () => {
291
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
292
- let browser = null;
279
+ timeout: 90_000
280
+ }, async (t) => {
281
+ const { vite, browser } = await openFixture(t);
293
282
 
294
283
  try {
295
- browser = await chromium.launch(createChromiumLaunchOptions());
296
284
  const context = await browser.newContext({
297
285
  baseURL: vite.baseURL,
298
286
  locale: "en-US"
@@ -315,16 +303,14 @@ test("AssistantConversationElement keeps long conversations scrollable across re
315
303
  );
316
304
  } finally {
317
305
  await browser?.close();
318
- await stopProcess(vite);
319
306
  }
320
307
  });
321
308
 
322
309
  test("conversation scrolling preserves history anchors and resets expansion on selection", {
323
310
  skip: !RUN_BROWSER_TEST,
324
311
  timeout: 60_000
325
- }, async () => {
326
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
327
- const browser = await chromium.launch(createChromiumLaunchOptions());
312
+ }, async (t) => {
313
+ const { vite, browser } = await openFixture(t);
328
314
  try {
329
315
  const page = await browser.newPage();
330
316
  await page.goto(`${vite.baseURL}/?controls=1`);
@@ -375,16 +361,14 @@ test("conversation scrolling preserves history anchors and resets expansion on s
375
361
  assert.equal(await page.evaluate(() => window.revealPlaceholders), 0, "reopening loaded text does not flash a loading placeholder");
376
362
  } finally {
377
363
  await browser.close();
378
- await stopProcess(vite);
379
364
  }
380
365
  });
381
366
 
382
367
  test("a delayed final history page preserves the visible message at every width", {
383
368
  skip: !RUN_BROWSER_TEST,
384
369
  timeout: 60_000
385
- }, async () => {
386
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
387
- const browser = await chromium.launch(createChromiumLaunchOptions());
370
+ }, async (t) => {
371
+ const { vite, browser } = await openFixture(t);
388
372
  try {
389
373
  for (const width of [390, 800, 1365]) {
390
374
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -410,16 +394,14 @@ test("a delayed final history page preserves the visible message at every width"
410
394
  }
411
395
  } finally {
412
396
  await browser.close();
413
- await stopProcess(vite);
414
397
  }
415
398
  });
416
399
 
417
400
  test("upward scrolling loads older history once and preserves retry and selection behavior", {
418
401
  skip: !RUN_BROWSER_TEST,
419
402
  timeout: 90_000
420
- }, async () => {
421
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
422
- const browser = await chromium.launch(createChromiumLaunchOptions());
403
+ }, async (t) => {
404
+ const { vite, browser } = await openFixture(t);
423
405
  try {
424
406
  for (const width of [390, 800, 1365]) {
425
407
  const touch = width < 1000;
@@ -504,16 +486,14 @@ test("upward scrolling loads older history once and preserves retry and selectio
504
486
  }
505
487
  } finally {
506
488
  await browser.close();
507
- await stopProcess(vite);
508
489
  }
509
490
  });
510
491
 
511
492
  test("a reader's own upward scrolling leaves the latest message from its first frame", {
512
493
  skip: !RUN_BROWSER_TEST,
513
494
  timeout: 60_000
514
- }, async () => {
515
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
516
- const browser = await chromium.launch(createChromiumLaunchOptions());
495
+ }, async (t) => {
496
+ const { vite, browser } = await openFixture(t);
517
497
  try {
518
498
  for (const width of [390, 1365]) {
519
499
  const touch = width < 1000;
@@ -580,16 +560,14 @@ test("a reader's own upward scrolling leaves the latest message from its first f
580
560
  }
581
561
  } finally {
582
562
  await browser.close();
583
- await stopProcess(vite);
584
563
  }
585
564
  });
586
565
 
587
566
  test("a reader moved away from the end without a scroll direction stays there", {
588
567
  skip: !RUN_BROWSER_TEST,
589
568
  timeout: 60_000
590
- }, async () => {
591
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
592
- const browser = await chromium.launch(createChromiumLaunchOptions());
569
+ }, async (t) => {
570
+ const { vite, browser } = await openFixture(t);
593
571
  try {
594
572
  const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
595
573
  const errors = [];
@@ -618,16 +596,14 @@ test("a reader moved away from the end without a scroll direction stays there",
618
596
  assert.deepEqual(errors, []);
619
597
  } finally {
620
598
  await browser.close();
621
- await stopProcess(vite);
622
599
  }
623
600
  });
624
601
 
625
602
  test("retained hidden transcripts preserve readers and follow only at the latest message", {
626
603
  skip: !RUN_BROWSER_TEST,
627
604
  timeout: 90_000
628
- }, async () => {
629
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
630
- const browser = await chromium.launch(createChromiumLaunchOptions());
605
+ }, async (t) => {
606
+ const { vite, browser } = await openFixture(t);
631
607
  try {
632
608
  for (const width of [390, 800, 1365]) {
633
609
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -683,16 +659,14 @@ test("retained hidden transcripts preserve readers and follow only at the latest
683
659
  }
684
660
  } finally {
685
661
  await browser.close();
686
- await stopProcess(vite);
687
662
  }
688
663
  });
689
664
 
690
665
  test("transient update errors keep the loaded transcript, reader, draft and focus in a compact banner", {
691
666
  skip: !RUN_BROWSER_TEST,
692
667
  timeout: 90_000
693
- }, async () => {
694
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
695
- const browser = await chromium.launch(createChromiumLaunchOptions());
668
+ }, async (t) => {
669
+ const { vite, browser } = await openFixture(t);
696
670
  try {
697
671
  for (const width of [390, 800, 1365]) {
698
672
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -750,16 +724,14 @@ test("transient update errors keep the loaded transcript, reader, draft and focu
750
724
  }
751
725
  } finally {
752
726
  await browser.close();
753
- await stopProcess(vite);
754
727
  }
755
728
  });
756
729
 
757
730
  test("retained hidden history loads restore their original anchor and invalidate retired requests", {
758
731
  skip: !RUN_BROWSER_TEST,
759
732
  timeout: 90_000
760
- }, async () => {
761
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
762
- const browser = await chromium.launch(createChromiumLaunchOptions());
733
+ }, async (t) => {
734
+ const { vite, browser } = await openFixture(t);
763
735
  try {
764
736
  for (const width of [390, 800, 1365]) {
765
737
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -818,16 +790,14 @@ test("retained hidden history loads restore their original anchor and invalidate
818
790
  }
819
791
  } finally {
820
792
  await browser.close();
821
- await stopProcess(vite);
822
793
  }
823
794
  });
824
795
 
825
796
  test("conversation composer preserves typing and reacts to external state and pane resizing", {
826
797
  skip: !RUN_BROWSER_TEST,
827
798
  timeout: 60_000
828
- }, async () => {
829
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
830
- const browser = await chromium.launch(createChromiumLaunchOptions());
799
+ }, async (t) => {
800
+ const { vite, browser } = await openFixture(t);
831
801
  try {
832
802
  const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
833
803
  await page.goto(`${vite.baseURL}/?conversation=1`);
@@ -872,16 +842,14 @@ test("conversation composer preserves typing and reacts to external state and pa
872
842
  }
873
843
  } finally {
874
844
  await browser.close();
875
- await stopProcess(vite);
876
845
  }
877
846
  });
878
847
 
879
848
  test("working state appears before output and suggestion agents and activity rendering are replaceable", {
880
849
  skip: !RUN_BROWSER_TEST,
881
850
  timeout: 60_000
882
- }, async () => {
883
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
884
- const browser = await chromium.launch(createChromiumLaunchOptions());
851
+ }, async (t) => {
852
+ const { vite, browser } = await openFixture(t);
885
853
  try {
886
854
  const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
887
855
  await page.goto(`${vite.baseURL}/?conversation=1&support=1`);
@@ -910,7 +878,6 @@ test("working state appears before output and suggestion agents and activity ren
910
878
  await expect(input).toHaveValue("Keep typing while waiting");
911
879
  } finally {
912
880
  await browser.close();
913
- await stopProcess(vite);
914
881
  }
915
882
  });
916
883
 
@@ -918,9 +885,8 @@ test("working state appears before output and suggestion agents and activity ren
918
885
  test("long goal objectives keep Start reachable on short phone, tablet and desktop screens", {
919
886
  skip: !RUN_BROWSER_TEST,
920
887
  timeout: 60_000
921
- }, async () => {
922
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
923
- const browser = await chromium.launch(createChromiumLaunchOptions());
888
+ }, async (t) => {
889
+ const { vite, browser } = await openFixture(t);
924
890
  try {
925
891
  const objective = "Complete the requested work and verify the result. ".repeat(80).slice(0, 3990);
926
892
  for (const width of [390, 800, 1280]) {
@@ -947,16 +913,14 @@ test("long goal objectives keep Start reachable on short phone, tablet and deskt
947
913
  }
948
914
  } finally {
949
915
  await browser.close();
950
- await stopProcess(vite);
951
916
  }
952
917
  });
953
918
 
954
919
  test("optional goals show running and paused lights, count only active time, and adapt to pane width", {
955
920
  skip: !RUN_BROWSER_TEST,
956
921
  timeout: 60_000
957
- }, async () => {
958
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
959
- const browser = await chromium.launch(createChromiumLaunchOptions());
922
+ }, async (t) => {
923
+ const { vite, browser } = await openFixture(t);
960
924
  try {
961
925
  const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
962
926
  await page.clock.install();
@@ -1035,7 +999,6 @@ test("optional goals show running and paused lights, count only active time, and
1035
999
  await expect(input).toHaveValue("Preserve this draft and caret");
1036
1000
  } finally {
1037
1001
  await browser.close();
1038
- await stopProcess(vite);
1039
1002
  }
1040
1003
  });
1041
1004
 
@@ -1043,9 +1006,8 @@ test("optional goals show running and paused lights, count only active time, and
1043
1006
  test("shared model choices and file delivery preserve a responsive composer", {
1044
1007
  skip: !RUN_BROWSER_TEST,
1045
1008
  timeout: 60_000
1046
- }, async () => {
1047
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1048
- const browser = await chromium.launch(createChromiumLaunchOptions());
1009
+ }, async (t) => {
1010
+ const { vite, browser } = await openFixture(t);
1049
1011
  try {
1050
1012
  const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
1051
1013
  await page.goto(`${vite.baseURL}/?conversation=1&capabilities=1`);
@@ -1084,15 +1046,13 @@ test("shared model choices and file delivery preserve a responsive composer", {
1084
1046
  assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
1085
1047
  } finally {
1086
1048
  await browser.close();
1087
- await stopProcess(vite);
1088
1049
  }
1089
1050
  });
1090
1051
 
1091
1052
  test("an optional avatar clamps in a short conversation without consuming its preference or draft", {
1092
1053
  skip: !RUN_BROWSER_TEST, timeout: 90_000
1093
- }, async () => {
1094
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1095
- const browser = await chromium.launch(createChromiumLaunchOptions());
1054
+ }, async (t) => {
1055
+ const { vite, browser } = await openFixture(t);
1096
1056
  try {
1097
1057
  for (const width of [390, 800, 1365]) {
1098
1058
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -1122,8 +1082,7 @@ test("an optional avatar clamps in a short conversation without consuming its pr
1122
1082
  assert.ok(send.y + send.height <= bounds.y + bounds.height + 1, "Send stays inside the supplied container");
1123
1083
  assert.ok(feedback.y + feedback.height <= bounds.y + bounds.height + 1, "Feedback stays inside the supplied container");
1124
1084
  assert.ok((await input.boundingBox()).height >= 40);
1125
- const avatarExpanded = await sizeControl.getAttribute("aria-expanded") === "true";
1126
- assert.equal(avatarButton.width, avatarExpanded ? 40 : 44, "Expanded minimise is compact; collapsed Show retains its wider target");
1085
+ assert.equal(Math.round(avatarButton.width), 40, "Avatar visibility is a 40dp icon button, Show and Minimise alike");
1127
1086
  assert.equal(Math.round(avatarButton.height), 40, "Avatar visibility is a 40dp icon button, with no fixed height");
1128
1087
  const avatarTarget = await sizeControl.evaluate((element) => Number.parseFloat(window.getComputedStyle(element, "::before").height));
1129
1088
  assert.ok(avatarTarget >= 48, "Avatar visibility keeps Material's 48dp touch target");
@@ -1175,7 +1134,7 @@ test("an optional avatar clamps in a short conversation without consuming its pr
1175
1134
  await expect(artwork).toHaveCount(0);
1176
1135
  await expect(sizeControl).toBeVisible();
1177
1136
  const showBounds = await sizeControl.boundingBox();
1178
- assert.equal(showBounds.width, 44, "Collapsed Show retains its original wider target");
1137
+ assert.equal(Math.round(showBounds.width), 40, "Collapsed Show is the small icon button, as Minimise is");
1179
1138
  assert.equal(Math.round(showBounds.height), 40, "Collapsed Show keeps the small icon button's height");
1180
1139
  assert.ok(await sizeControl.evaluate(element => Number.parseFloat(getComputedStyle(element, "::before").height)) >= 48,
1181
1140
  "Collapsed Show keeps a 48dp touch target");
@@ -1249,16 +1208,14 @@ test("an optional avatar clamps in a short conversation without consuming its pr
1249
1208
  }
1250
1209
  } finally {
1251
1210
  await browser.close();
1252
- await stopProcess(vite);
1253
1211
  }
1254
1212
  });
1255
1213
 
1256
1214
 
1257
1215
  test("unsent preview edits stay in their bubble and disappear for the canonical message", {
1258
1216
  skip: !RUN_BROWSER_TEST, timeout: 60_000
1259
- }, async () => {
1260
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1261
- const browser = await chromium.launch(createChromiumLaunchOptions());
1217
+ }, async (t) => {
1218
+ const { vite, browser } = await openFixture(t);
1262
1219
  try {
1263
1220
  const page = await browser.newPage({ viewport: { width: 320, height: 844 } });
1264
1221
  await page.goto(`${vite.baseURL}/?conversation=1&preview=1`);
@@ -1363,7 +1320,6 @@ test("unsent preview edits stay in their bubble and disappear for the canonical
1363
1320
  }
1364
1321
  } finally {
1365
1322
  await browser.close();
1366
- await stopProcess(vite);
1367
1323
  }
1368
1324
  });
1369
1325
 
@@ -1371,9 +1327,8 @@ test("unsent preview edits stay in their bubble and disappear for the canonical
1371
1327
  test("pending history keeps following the reader when a new reply changes transcript height", {
1372
1328
  skip: !RUN_BROWSER_TEST,
1373
1329
  timeout: 90_000
1374
- }, async () => {
1375
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1376
- const browser = await chromium.launch(createChromiumLaunchOptions());
1330
+ }, async (t) => {
1331
+ const { vite, browser } = await openFixture(t);
1377
1332
  try {
1378
1333
  for (const width of [390, 1365]) {
1379
1334
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -1414,16 +1369,14 @@ test("pending history keeps following the reader when a new reply changes transc
1414
1369
  }
1415
1370
  } finally {
1416
1371
  await browser.close();
1417
- await stopProcess(vite);
1418
1372
  }
1419
1373
  });
1420
1374
 
1421
1375
 
1422
1376
  test("connection warnings keep avatar controls and Reload reachable without replacing history", {
1423
1377
  skip: !RUN_BROWSER_TEST, timeout: 90_000
1424
- }, async () => {
1425
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1426
- const browser = await chromium.launch(createChromiumLaunchOptions());
1378
+ }, async (t) => {
1379
+ const { vite, browser } = await openFixture(t);
1427
1380
  try {
1428
1381
  for (const width of [320, 800]) {
1429
1382
  const page = await browser.newPage({ viewport: { width, height: 1000 } });
@@ -1498,16 +1451,14 @@ test("connection warnings keep avatar controls and Reload reachable without repl
1498
1451
  }
1499
1452
  } finally {
1500
1453
  await browser.close();
1501
- await stopProcess(vite);
1502
1454
  }
1503
1455
  });
1504
1456
 
1505
1457
 
1506
1458
  test("short connection warnings preserve full text and recovery targets at phone widths", {
1507
1459
  skip: !RUN_BROWSER_TEST, timeout: 90_000
1508
- }, async () => {
1509
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1510
- const browser = await chromium.launch(createChromiumLaunchOptions());
1460
+ }, async (t) => {
1461
+ const { vite, browser } = await openFixture(t);
1511
1462
  try {
1512
1463
  for (const width of [320, 390]) {
1513
1464
  const page = await browser.newPage({ viewport: { width, height: 768 } });
@@ -1580,6 +1531,5 @@ test("short connection warnings preserve full text and recovery targets at phone
1580
1531
  }
1581
1532
  } finally {
1582
1533
  await browser.close();
1583
- await stopProcess(vite);
1584
1534
  }
1585
1535
  });
@@ -759,7 +759,9 @@ test("Claude cancellation waits for an application effect after closing the nati
759
759
  });
760
760
 
761
761
  test("the common request deadline stops an unfinished native turn and retains its admitted history", async t => {
762
- const f = await fixture(t, { limits: { timeoutMs: 2000 } });
762
+ // The deadline also covers starting the native process and admitting the turn, which took up to 8.4 s (median 1.3 to
763
+ // 2.5 s) with 4 to 8 copies of this test running on an 8-thread host, so it allows 10 s.
764
+ const f = await fixture(t, { limits: { timeoutMs: 10_000 } });
763
765
  await f.conversation.send({ messageId: "waiting", text: "wait" });
764
766
  const snapshot = await f.conversation.wait();
765
767
  assert.equal(snapshot.conversationLog[0].metadata.runtime.status, "failed");
@@ -11,3 +11,47 @@ test("client entry points expose one conversation implementation and retain sett
11
11
  const conversation = await readFile(new URL("../src/client/conversation/index.js", import.meta.url), "utf8");
12
12
  assert.match(conversation, /AssistantConversationElement/);
13
13
  });
14
+
15
+ // Material 3: one container at most, the composer an outlined text field, and no heading inside a titled pane.
16
+ test("the transcript, composer and settings card each draw one Material 3 container", async () => {
17
+ const transcript = await readFile(new URL("../src/client/conversation/AssistantTranscript.css", import.meta.url), "utf8");
18
+ const block = (css, selector) => new RegExp(`(?:^|\\n)${selector.replaceAll(".", "\\.")} \\{([^}]*)\\}`, "u").exec(css)?.[1] ?? "";
19
+ assert.doesNotMatch(block(transcript, ".assistant-transcript"), /border/u, "the transcript area is not outlined");
20
+ assert.doesNotMatch(block(transcript, ".assistant-transcript__welcome"), /\bborder:/u, "the welcome bubble is a tonal fill only");
21
+ const prompt = await readFile(new URL("../src/client/conversation/AssistantPromptInput.vue", import.meta.url), "utf8");
22
+ const field = block(prompt, ".assistant-prompt-input__field");
23
+ assert.match(field, /border: 1px solid rgb\(var\(--v-theme-outline\)\);/u);
24
+ assert.match(field, /border-radius: 4px;/u);
25
+ assert.match(block(prompt, ".assistant-prompt-input__footer"), /padding: 0 12px 12px;/u);
26
+ const card = await readFile(new URL("../src/client/components/AssistantSettingsFormCard.vue", import.meta.url), "utf8");
27
+ assert.doesNotMatch(card, /<v-card-title|<v-card-subtitle|<v-divider/u);
28
+ assert.match(card, /:aria-label="props\.title"/u);
29
+ // Given a formId, the page's app bar carries Save: the form takes the id and the card draws no Save of its own.
30
+ assert.match(card, /<v-form :id="props\.formId \|\| undefined"/u);
31
+ assert.match(card, /<div v-if="!props\.formId \|\| !props\.addEdit\.canSave"/u);
32
+ });
33
+
34
+ // Material 3 tonal containers: inside the conversation only the composer, an outlined text field, draws an outline;
35
+ // attachments, questions, code, tables, details and the model menu are surface-container tones.
36
+ test("the conversation's containers are surface-container tones, not outlined boxes", async () => {
37
+ const folder = new URL("../src/client/conversation/", import.meta.url);
38
+ const files = ["AssistantTranscript.css", "AssistantAttachmentQueue.vue", "AssistantMessageAttachments.vue",
39
+ "AssistantModelControl.vue", "LongTextPreviewBlocks.vue", "LongTextInlineParts.vue", "AssistantPromptInput.vue"];
40
+ const outlines = [];
41
+ for (const file of files) {
42
+ const source = await readFile(new URL(file, folder), "utf8");
43
+ for (const [declaration] of source.matchAll(/\bborder(?:-(?:top|bottom|left|right|inline|block)(?:-(?:start|end))?)?: [^;]*\d[^;]*;/gu)) {
44
+ if (!/: 0;/u.test(declaration)) outlines.push(`${file}: ${declaration}`);
45
+ }
46
+ if (file === "AssistantModelControl.vue") {
47
+ assert.doesNotMatch(source, /<v-sheet[^>]*\n\s*border\n/u, "the model menu has no outline");
48
+ assert.match(source, /color="surface-container"\s+:elevation="2"/u);
49
+ }
50
+ }
51
+ assert.deepEqual(outlines, [
52
+ "AssistantModelControl.vue: border-bottom: 1px solid rgb(var(--v-theme-outline-variant));",
53
+ "AssistantModelControl.vue: border-top: 1px solid rgb(var(--v-theme-outline-variant));",
54
+ "LongTextPreviewBlocks.vue: border-bottom: 1px solid rgb(var(--v-theme-outline-variant));",
55
+ "AssistantPromptInput.vue: border: 1px solid rgb(var(--v-theme-outline));"
56
+ ], "only outline-variant dividers and the composer's outlined field draw lines");
57
+ });
@@ -40,7 +40,6 @@ async function renderShowAvatarButton(element, slots) {
40
40
  assert.equal(buttons.length, 1, "One Show avatar button while the avatar is hidden");
41
41
  const [, attributes, content] = buttons[0];
42
42
  assert.match(attributes, /title="jskit.assistant.chat.showAvatar"/u);
43
- assert.match(attributes, /width="44"/u);
44
43
  return content;
45
44
  }
46
45
 
@@ -61,10 +60,10 @@ test("the hidden avatar's Show button shows the avatar-thumbnail slot, or the ac
61
60
  assert.doesNotMatch(fallback, /fixture-thumbnail/u);
62
61
  });
63
62
 
64
- test("the avatar's Show and Minimise buttons set no fixed height, so material-web gives them the 48dp touch target", () => {
63
+ test("the avatar's Show and Minimise buttons set no fixed size, so material-web gives them the 48dp touch target", () => {
65
64
  const source = readFileSync(new URL(elementUrl), "utf8");
66
65
  const buttons = [...source.matchAll(/<v-btn\b[^>]*>/gu)].map(([tag]) => tag).filter((tag) => tag.includes("assistant-conversation__avatar-size"));
67
66
  assert.equal(buttons.length, 2);
68
- for (const tag of buttons) assert.doesNotMatch(tag, /(?:^|\s):?(?:min-)?height=/u);
69
- assert.doesNotMatch(source, /\.assistant-conversation__avatar-size\s*\{[^}]*height/u);
67
+ for (const tag of buttons) assert.doesNotMatch(tag, /(?:^|\s):?(?:min-)?(?:height|width)=/u);
68
+ assert.doesNotMatch(source, /\.assistant-conversation__avatar-size\s*\{[^}]*(?:height|width)/u);
70
69
  });
@@ -1,14 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { fileURLToPath } from "node:url";
4
- import { chromium, expect } from "@playwright/test";
5
- import { createChromiumLaunchOptions, startViteFixture, stopProcess } from "../../../tooling/testUtils/browserFixture.mjs";
4
+ import { expect } from "../../../tooling/testUtils/browserExpect.mjs";
5
+ import { sharedBrowserFixture } from "../../../tooling/testUtils/browserFixture.mjs";
6
+
7
+ const openFixture = sharedBrowserFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
6
8
 
7
9
  test("the shared element renders delayed sends and supplies retry, edit and cancel without application handlers", {
8
10
  skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
9
- }, async () => {
10
- const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
11
- const browser = await chromium.launch(createChromiumLaunchOptions());
11
+ }, async (t) => {
12
+ const { vite, browser } = await openFixture(t);
12
13
  try {
13
14
  for (const width of [390, 800, 1365]) {
14
15
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -102,16 +103,14 @@ test("the shared element renders delayed sends and supplies retry, edit and canc
102
103
  }
103
104
  } finally {
104
105
  await browser.close();
105
- await stopProcess(vite);
106
106
  }
107
107
  });
108
108
 
109
109
 
110
110
  test("chronological messages and Cancel during a delivery check work on phone and desktop", {
111
111
  skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
112
- }, async () => {
113
- const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
114
- const browser = await chromium.launch(createChromiumLaunchOptions());
112
+ }, async (t) => {
113
+ const { vite, browser } = await openFixture(t);
115
114
  try {
116
115
  for (const width of [390, 1365]) {
117
116
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -148,14 +147,13 @@ test("chronological messages and Cancel during a delivery check work on phone an
148
147
  assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
149
148
  await page.close();
150
149
  }
151
- } finally { await browser.close(); await stopProcess(vite); }
150
+ } finally { await browser.close(); }
152
151
  });
153
152
 
154
153
  test("accepted local messages precede later replies before their history page arrives", {
155
154
  skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
156
- }, async () => {
157
- const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
158
- const browser = await chromium.launch(createChromiumLaunchOptions());
155
+ }, async (t) => {
156
+ const { vite, browser } = await openFixture(t);
159
157
  try {
160
158
  for (const width of [390, 1365]) {
161
159
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -178,5 +176,5 @@ test("accepted local messages precede later replies before their history page ar
178
176
  assert.deepEqual(errors, []);
179
177
  await page.close();
180
178
  }
181
- } finally { await browser.close(); await stopProcess(vite); }
179
+ } finally { await browser.close(); }
182
180
  });
@@ -77,6 +77,7 @@ async function fixture(t, options = {}) {
77
77
  const directory = await mkdtemp(path.join(tmpdir(), "jskit-opencode-conversation-"));
78
78
  const command = path.join(directory, "opencode.mjs");
79
79
  const trace = path.join(directory, "trace.jsonl");
80
+ const partialSeen = path.join(directory, "partial-seen");
80
81
  await writeFile(command, `#!${process.execPath}
81
82
  import { createServer } from "node:http";
82
83
  import { appendFileSync, readFileSync, writeFileSync, existsSync } from "node:fs";
@@ -90,6 +91,7 @@ async function fixture(t, options = {}) {
90
91
  const streams = new Set();
91
92
  const assistantResponses = ${JSON.stringify(options.assistantResponses || [])};
92
93
  const busy = new Set();
94
+ const heldAnswers = new Map();
93
95
  let sequence = [...sessions.values()].reduce((sum, session) => sum + session.messages.length, 0);
94
96
  const row = (role, id, parts, extra = {}) => ({ info: { role, id, time: { created: ++sequence }, ...extra }, parts });
95
97
  const server = createServer(async (req, res) => {
@@ -132,6 +134,12 @@ async function fixture(t, options = {}) {
132
134
  }
133
135
  if (req.url.includes("/message")) {
134
136
  if (${Boolean(options.historyUnavailable)}) return reply({ message: "Native history unavailable" }, 503);
137
+ // A held answer completes at the first read after the test saw its partial streamed, or after 10 s, so a
138
+ // partial that never streams fails the test instead of hanging it.
139
+ const held = heldAnswers.get(session.id);
140
+ if (held && (existsSync(${JSON.stringify(partialSeen)}) || Date.now() - held.since > 10_000)) {
141
+ heldAnswers.delete(session.id); held.complete();
142
+ }
135
143
  return reply(session.messages);
136
144
  }
137
145
  if (req.url.endsWith("/prompt_async")) {
@@ -217,11 +225,13 @@ async function fixture(t, options = {}) {
217
225
  log({ system: output.system, sessionId: session.id });
218
226
  const answer = row("assistant", randomUUID(), [{ type: "reasoning", text: "Reasoning summary" }, { type: "text", text: "Answer: " }]);
219
227
  session.messages.push(answer); save();
220
- setTimeout(() => {
228
+ const complete = () => {
221
229
  answer.parts[1].text = answerText;
222
230
  answer.info.finish = "stop"; answer.info.time.completed = Date.now(); save();
223
231
  busy.delete(session.id);
224
- }, 100);
232
+ };
233
+ if (${Boolean(options.holdPartialAnswer)}) heldAnswers.set(session.id, { complete, since: Date.now() });
234
+ else setTimeout(complete, 100);
225
235
  return;
226
236
  }
227
237
  reply({ message: "Unexpected route" }, 404);
@@ -259,6 +269,7 @@ async function fixture(t, options = {}) {
259
269
  limits: { admissionTimeoutMs: 250, ...options.limits }
260
270
  },
261
271
  binding: () => storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding),
272
+ releasePartialAnswer: () => writeFile(partialSeen, ""),
262
273
  async trace() { return (await readFile(trace, "utf8")).trim().split("\n").map(line => JSON.parse(line)); } };
263
274
  }
264
275
 
@@ -321,9 +332,14 @@ test("OpenCode structured output counts its schema framing before native dispatc
321
332
  });
322
333
 
323
334
  test("OpenCode admits once, streams and saves native reasoning through the common runtime", async t => {
324
- const f = await fixture(t);
335
+ // OpenCode text streams only through the runtime's history reads, so the native answer stays partial until this
336
+ // subscriber has seen it streamed, instead of for a fixed 100 ms that a read under load could miss.
337
+ const f = await fixture(t, { holdPartialAnswer: true });
325
338
  const events = [];
326
- await f.conversation.subscribe(event => events.push(event));
339
+ await f.conversation.subscribe(event => {
340
+ events.push(event);
341
+ if (event.type === "message" && event.text === "Answer: ") void f.releasePartialAnswer();
342
+ });
327
343
  assert.equal((await f.conversation.send(input)).status, "accepted");
328
344
  const state = await f.conversation.wait();
329
345
  assert.equal(state.conversationLog[0].metadata.runtime.status, "complete");
@@ -1,8 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { fileURLToPath } from "node:url";
4
- import { chromium, expect } from "@playwright/test";
5
- import { createChromiumLaunchOptions, startViteFixture, stopProcess } from "../../../tooling/testUtils/browserFixture.mjs";
4
+ import { expect } from "@playwright/test";
5
+ import { sharedBrowserFixture } from "../../../tooling/testUtils/browserFixture.mjs";
6
6
 
7
7
  // An element's text colour, the theme values it should come from, and its WCAG contrast over what is painted under
8
8
  // it: each translucent background is laid over the next one out, down to the first opaque one (white when none is).
@@ -43,11 +43,12 @@ function readText(element) {
43
43
  };
44
44
  }
45
45
 
46
+ const openFixture = sharedBrowserFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
47
+
46
48
  test("the task transcript's user bubble, header time and footer follow the light and dark themes", {
47
49
  skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
48
- }, async () => {
49
- const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
50
- const browser = await chromium.launch(createChromiumLaunchOptions());
50
+ }, async (t) => {
51
+ const { vite, browser } = await openFixture(t);
51
52
  try {
52
53
  for (const theme of ["light", "dark"]) {
53
54
  const page = await browser.newPage({ viewport: { width: 800, height: 900 } });
@@ -84,6 +85,5 @@ test("the task transcript's user bubble, header time and footer follow the light
84
85
  }
85
86
  } finally {
86
87
  await browser.close();
87
- await stopProcess(vite);
88
88
  }
89
89
  });