@getdom/studio 0.2.0 → 0.2.2

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
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2 - 2026-09-26
4
+
5
+ More flexible calendar highlights, refined agent chat, and updated website
6
+ licensing flows.
7
+
8
+ ### Added
9
+
10
+ - `DomMonthCalendar` now accepts `todayIndicator="both"` to show the cell outline
11
+ and date circle together. `todayDateClass` customises the circle in either
12
+ `circle` or `both` mode, with matching TypeScript declarations and an interactive
13
+ example.
14
+ - Website support for account-optional licence checkout, downloadable purchase
15
+ confirmations, and email-verified licence claiming. Checkout requires configured
16
+ transactional email delivery.
17
+
18
+ ### Improved
19
+
20
+ - Agent chat uses a shared centred width for messages and the composer, compact
21
+ conversation rows with avatars and unread counts, expandable tool details,
22
+ and clearer loading, empty, streaming, and agent-context displays.
23
+ - Default control backgrounds use the canvas colour. The command palette has
24
+ updated translucency and theme-aware shadows.
25
+ - The homepage includes interactive workspace, agent-chat, analytics, and designer
26
+ demos. Website pricing and checkout use consistent USD licence offers.
27
+
28
+ ### Fixed
29
+
30
+ - Calendar date numbers keep consistent spacing across all today-indicator modes,
31
+ and week numbers align with dates in the desktop grid.
32
+ - Website checkout validates the configured price and licence before redirecting
33
+ to payment. Delayed payment handling and repeat webhook processing preserve
34
+ the recorded licence and its fulfilment state.
35
+
36
+ ### Upgrade notes
37
+
38
+ - Existing calendar indicator values remain supported; `cell` is still the default.
39
+ - `DomAgentConversation.maxMessageWidth` now controls the centred message column
40
+ and composer together. Review custom conversation layouts after upgrading.
41
+ - Applications that supply their own theme can set `--skin-control-bg` to retain
42
+ a different control background.
43
+
44
+ ## 0.2.1 - 2026-09-21
45
+
46
+ Vue Router 5 support and a public home for DOM Studio release notes.
47
+
48
+ ### Added
49
+
50
+ - A release history at `/releases`, with publication dates, version links and
51
+ upgrade notes. Published GitHub Releases supply the notes through the server.
52
+ - A release preparation command and tag-triggered publishing workflow that checks
53
+ the package, verifies npm publication, then creates matching GitHub Release notes.
54
+
55
+ ### Improved
56
+
57
+ - Added Vue Router 5 support while retaining compatibility with Vue Router 4.
58
+ - Updated the development and package-smoke toolchains to validate the current
59
+ Vue, Pinia, Vite, and Vue Router release line.
60
+
61
+ ### Upgrade notes
62
+
63
+ - Vue must be version 3.5.34 or newer within Vue 3. Vue Router 4 remains supported.
64
+ - When using Vue Router 5.3 with Pinia, use Pinia 3.0.4 or newer within Pinia 3.
65
+ Vue Router 5.3 also requires Vite 7.3 or 8 when Vite is installed.
66
+
3
67
  ## 0.2.0 - 2026-09-20
4
68
 
5
69
  This release expands DOM Studio from its initial public package with new
package/LICENSE.md CHANGED
@@ -28,11 +28,14 @@ the order confirmation.
28
28
  An Individual licence permits one named individual to:
29
29
 
30
30
  - use and modify the Software in unlimited personal or commercial products owned by that individual;
31
+ - incorporate the Software into bespoke products created for that individual's clients;
31
32
  - deploy and distribute those products to end users; and
32
33
  - maintain those products for their end users.
33
34
 
34
- The Individual licence does not permit sharing the Software or paid source
35
- access with colleagues, employees, contractors, or clients.
35
+ The Individual licence covers one named developer. It does not permit sharing
36
+ the component library or paid source access with colleagues, employees, or
37
+ contractors. A client may receive the Software as part of a bespoke end product
38
+ under the client-delivery terms below.
36
39
 
37
40
  ### Team
38
41
 
@@ -44,9 +47,14 @@ A Team licence permits the purchasing legal entity to:
44
47
  - incorporate the Software into bespoke products created for its clients.
45
48
 
46
49
  Contractors may use the Software only while working on behalf of the purchasing
47
- entity and must not retain or reuse it for unrelated work. A client may receive
48
- the Software only as part of a bespoke end product and may not extract or reuse
49
- it as a standalone component library unless the client obtains its own licence.
50
+ entity and must not retain or reuse it for unrelated work.
51
+
52
+ ### Client delivery
53
+
54
+ Under either commercial licence, a client may receive the Software only as part
55
+ of a bespoke end product. The client may operate and maintain that end product,
56
+ but may not extract or reuse the Software as a standalone component library or
57
+ in another product unless the client obtains its own licence.
50
58
 
51
59
  ## Permitted modifications and distribution
52
60
 
@@ -69,10 +77,16 @@ You must not:
69
77
 
70
78
  ## Updates
71
79
 
72
- A purchase described as "lifetime v1 access" includes releases published under
73
- major version 1 while DOM Studio makes them available. It does not include a
74
- right to future major versions, hosted services, consulting, or support unless
75
- the order confirmation says otherwise.
80
+ A commercial licence permits perpetual use of the versions obtained under it,
81
+ subject to the terms of this licence. Included updates cover releases published
82
+ under major version 1 while DOM Studio makes them available. Future major
83
+ upgrades are optional and may be sold separately; declining an upgrade does not
84
+ end the right to use the versions already obtained.
85
+
86
+ Hosted services, architecture reviews, consulting, and support are not included
87
+ unless the order confirmation says otherwise. Existing purchases, including
88
+ introductory £1 purchases and purchases described as "lifetime v1 access", retain
89
+ their purchased access and any additional rights or services promised at purchase.
76
90
 
77
91
  ## Ownership
78
92
 
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@getdom/studio",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A Vue 3 and Web Component interface system for application UI.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
7
7
  "author": "Steve O'Brien",
8
8
  "homepage": "https://getdom.studio",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/steve-obrien/dom.studio.git"
12
+ },
9
13
  "keywords": [
10
14
  "vue",
11
15
  "web-components",
@@ -50,8 +54,8 @@
50
54
  "peerDependencies": {
51
55
  "pinia": "^2.3.0 || ^3.0.0",
52
56
  "tailwindcss": "^4.2.0",
53
- "vue": "^3.5.0",
54
- "vue-router": "^4.6.0"
57
+ "vue": "^3.5.34",
58
+ "vue-router": "^4.6.0 || ^5.0.0"
55
59
  },
56
60
  "dependencies": {
57
61
  "@tiptap/extension-code-block": "3.27.3",
@@ -1778,7 +1778,7 @@ export const componentCatalog = [
1778
1778
  },
1779
1779
  "name": "Agent conversation list",
1780
1780
  "tag": "<DomAgentConversationList>",
1781
- "description": "A reusable AI conversation navigation list with search, agent labels, unread counts, status pills, and active-thread selection.",
1781
+ "description": "A reusable AI conversation navigation list with search, agent labels, unread counts, status indicators, and active-thread selection.",
1782
1782
  "icon": "M5 6h14M5 12h14M5 18h8",
1783
1783
  "events": [
1784
1784
  {
@@ -439,12 +439,17 @@ function matchesShortcut(event, shortcut) {
439
439
  .command-panel-blur {
440
440
  --skin-overlay-bg: var(
441
441
  --dom-command-palette-background,
442
- color-mix(in oklch, var(--canvas) 82%, transparent)
442
+ color-mix(in oklch, var(--canvas) 68%, transparent)
443
443
  );
444
444
  --skin-overlay-backdrop: var(
445
445
  --dom-command-palette-backdrop,
446
446
  blur(20px) saturate(1.12)
447
447
  );
448
+ --skin-overlay-shadow: var(
449
+ --dom-command-palette-shadow,
450
+ 0 40px 100px -24px rgb(15 23 42 / 0.3),
451
+ 0 16px 40px -20px rgb(15 23 42 / 0.2)
452
+ );
448
453
  }
449
454
 
450
455
  .command-panel-entering {
@@ -103,6 +103,8 @@ export const agentChatShellProps = {
103
103
  import { computed, useId } from 'vue';
104
104
  import DomIconButton from '../../components/icon-button/DomIconButton.vue';
105
105
  import DomSplitterPanel from '../../components/splitter-panel/DomSplitterPanel.vue';
106
+ import DomAvatar from '../../visual/avatar/DomAvatar.vue';
107
+ import DomBadge from '../../visual/badge/DomBadge.vue';
106
108
  import DomStatusPill from '../../visual/status-pill/DomStatusPill.vue';
107
109
  import DomAgentConversation from './DomAgentConversation.vue';
108
110
  import DomAgentConversationList from './DomAgentConversationList.vue';
@@ -364,7 +366,7 @@ function conversationStatusTone() {
364
366
  <template>
365
367
  <section
366
368
  :id="shellId"
367
- class="h-full min-h-0 w-full min-w-0 overflow-hidden rounded-lg border border-border bg-canvas text-canvas-fg shadow-sm"
369
+ class="h-full min-h-0 w-full min-w-0 overflow-hidden rounded-xl border border-border bg-canvas text-canvas-fg shadow-sm"
368
370
  :aria-label="title"
369
371
  >
370
372
  <div class="hidden h-full min-h-0 md:block">
@@ -376,7 +378,7 @@ function conversationStatusTone() {
376
378
  :min-main="minConversation"
377
379
  :min-end="asideMinimum"
378
380
  :handle-size="8"
379
- handle-class="transition hover:bg-border/70 data-[dragging=true]:bg-ring/30"
381
+ handle-class="relative before:pointer-events-none before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-border/70 before:transition hover:before:bg-ring/60 data-[dragging=true]:before:bg-ring"
380
382
  @update:start-size="updateSidebarSize"
381
383
  @update:end-size="updateAsideSize"
382
384
  >
@@ -440,70 +442,67 @@ function conversationStatusTone() {
440
442
  <template v-if="asideOpen" #end>
441
443
  <slot name="aside" :conversation="conversation" :active-agent="activeAgent" :tool-parts="toolParts">
442
444
  <aside class="flex h-full min-h-0 flex-col bg-canvas">
443
- <header class="shrink-0 border-b border-border bg-secondary/25 p-4">
444
- <div class="flex items-start justify-between gap-3">
445
+ <header class="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border/70 px-4">
446
+ <div class="flex min-w-0 items-center gap-2.5">
447
+ <DomAvatar size="sm" shape="rounded" :src="activeAgent?.avatarUrl || ''" :name="activeAgentLabel()" />
445
448
  <div class="min-w-0">
446
- <p class="text-xs font-semibold uppercase text-muted-fg">Agent context</p>
447
- <h2 class="mt-1 truncate text-sm font-semibold text-canvas-fg">{{ activeAgentLabel() }}</h2>
449
+ <h2 class="truncate text-sm font-semibold tracking-tight text-canvas-fg">{{ activeAgentLabel() }}</h2>
450
+ <p class="truncate font-mono text-[11px] text-muted-fg">{{ activeModelLabel() }}</p>
448
451
  </div>
449
- <DomIconButton label="Close context panel" size="sm" variant="ghost" icon="M6 6l12 12M18 6 6 18" @click="setAsideOpen(false)" />
450
452
  </div>
453
+ <DomIconButton label="Close context panel" size="sm" variant="ghost" icon="M6 6l12 12M18 6 6 18" @click="setAsideOpen(false)" />
451
454
  </header>
452
- <div class="min-h-0 flex-1 overflow-y-auto p-4">
453
- <div class="rounded-lg border border-border bg-secondary/25 p-4">
454
- <p class="text-xs font-semibold text-muted-fg">Runtime</p>
455
- <p class="mt-1 text-sm font-semibold text-canvas-fg">{{ activeModelLabel() }}</p>
456
- <p class="mt-2 text-sm leading-6 text-muted-fg">{{ activeAgent?.role || 'Connect an agent to show instructions, tools, and memory context here.' }}</p>
457
- </div>
458
-
459
- <div class="mt-4 rounded-lg border border-border p-4">
460
- <div class="flex items-center justify-between gap-3">
461
- <p class="text-xs font-semibold uppercase text-muted-fg">Conversation</p>
462
- <DomStatusPill size="sm" :tone="conversationStatusTone()">{{ streaming ? 'streaming' : (conversation?.status || 'idle') }}</DomStatusPill>
463
- </div>
464
- <dl class="mt-3 grid grid-cols-2 gap-3 text-sm">
465
- <div>
466
- <dt class="text-xs text-muted-fg">Messages</dt>
467
- <dd class="font-semibold text-canvas-fg">{{ messages.length }}</dd>
455
+ <div class="min-h-0 flex-1 divide-y divide-border/70 overflow-y-auto">
456
+ <section class="p-4">
457
+ <p class="text-sm leading-6 text-muted-fg">{{ activeAgent?.role || 'Connect an agent to show instructions, tools, and memory context here.' }}</p>
458
+ <dl class="mt-4 grid grid-cols-3 gap-2">
459
+ <div class="rounded-lg bg-secondary/60 px-3 py-2">
460
+ <dt class="text-[11px] text-muted-fg">Status</dt>
461
+ <dd class="mt-1">
462
+ <DomStatusPill class="capitalize" size="sm" :tone="conversationStatusTone()" :pulse="streaming">{{ streaming ? 'streaming' : (conversation?.status || 'idle') }}</DomStatusPill>
463
+ </dd>
468
464
  </div>
469
- <div>
470
- <dt class="text-xs text-muted-fg">Tools</dt>
471
- <dd class="font-semibold text-canvas-fg">{{ toolParts.length }}</dd>
465
+ <div class="rounded-lg bg-secondary/60 px-3 py-2">
466
+ <dt class="text-[11px] text-muted-fg">Messages</dt>
467
+ <dd class="mt-0.5 text-base font-semibold tabular-nums text-canvas-fg">{{ messages.length }}</dd>
468
+ </div>
469
+ <div class="rounded-lg bg-secondary/60 px-3 py-2">
470
+ <dt class="text-[11px] text-muted-fg">Tools</dt>
471
+ <dd class="mt-0.5 text-base font-semibold tabular-nums text-canvas-fg">{{ toolParts.length }}</dd>
472
472
  </div>
473
473
  </dl>
474
- </div>
474
+ </section>
475
475
 
476
- <div class="mt-4 rounded-lg border border-border p-4">
477
- <p class="text-xs font-semibold uppercase text-muted-fg">Tool calls</p>
478
- <div v-if="toolParts.length" class="mt-3 space-y-2">
479
- <div
476
+ <section class="p-4">
477
+ <h3 class="text-xs font-medium uppercase tracking-wide text-muted-fg">Tool calls</h3>
478
+ <ul v-if="toolParts.length" class="mt-2 -mx-2">
479
+ <li
480
480
  v-for="tool in toolParts"
481
481
  :key="`${tool.messageId}-${tool.id || tool.name}`"
482
- class="rounded-md border border-border bg-secondary/25 p-3"
482
+ class="flex items-center gap-3 rounded-lg px-2 py-2 transition hover:bg-secondary/50"
483
483
  >
484
- <div class="flex items-start justify-between gap-2">
485
- <p class="min-w-0 truncate text-sm font-semibold text-canvas-fg">{{ tool.label || tool.name }}</p>
486
- <span class="shrink-0 text-xs text-muted-fg">{{ tool.status || 'ready' }}</span>
487
- </div>
488
- <p class="mt-1 truncate text-xs text-muted-fg">{{ tool.name }}</p>
489
- </div>
490
- </div>
491
- <p v-else class="mt-3 text-sm leading-6 text-muted-fg">Tool activity appears here as the agent works.</p>
492
- </div>
484
+ <span class="grid size-7 shrink-0 place-items-center rounded-lg bg-secondary text-muted-fg">
485
+ <svg viewBox="0 0 24 24" class="size-3.5" fill="none" aria-hidden="true">
486
+ <path d="M14.7 6.3a4 4 0 0 0-5.4 5.2L4 16.8V20h3.2l5.3-5.3a4 4 0 0 0 5.2-5.4l-2.5 2.5-2.3-.7-.7-2.3 2.5-2.5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" />
487
+ </svg>
488
+ </span>
489
+ <span class="min-w-0 flex-1">
490
+ <span class="block truncate text-sm font-medium text-canvas-fg">{{ tool.label || tool.name }}</span>
491
+ <span class="block truncate font-mono text-[11px] text-muted-fg">{{ tool.name }}</span>
492
+ </span>
493
+ <span class="shrink-0 text-xs capitalize text-muted-fg">{{ tool.status || 'ready' }}</span>
494
+ </li>
495
+ </ul>
496
+ <p v-else class="mt-2 text-sm leading-6 text-muted-fg">Tool activity appears here as the agent works.</p>
497
+ </section>
493
498
 
494
- <div class="mt-4 rounded-lg border border-border p-4">
495
- <p class="text-xs font-semibold uppercase text-muted-fg">Available tools</p>
496
- <div v-if="activeAgent?.tools?.length" class="mt-3 flex flex-wrap gap-2">
497
- <span
498
- v-for="tool in activeAgent.tools"
499
- :key="tool"
500
- class="rounded-full border border-border bg-secondary px-2.5 py-1 text-xs font-medium text-muted-fg"
501
- >
502
- {{ tool }}
503
- </span>
499
+ <section class="p-4">
500
+ <h3 class="text-xs font-medium uppercase tracking-wide text-muted-fg">Available tools</h3>
501
+ <div v-if="activeAgent?.tools?.length" class="mt-3 flex flex-wrap gap-1.5">
502
+ <DomBadge v-for="tool in activeAgent.tools" :key="tool" class="font-mono" size="sm">{{ tool }}</DomBadge>
504
503
  </div>
505
- <p v-else class="mt-3 text-sm leading-6 text-muted-fg">No tool manifest supplied.</p>
506
- </div>
504
+ <p v-else class="mt-2 text-sm leading-6 text-muted-fg">No tool manifest supplied.</p>
505
+ </section>
507
506
  </div>
508
507
  </aside>
509
508
  </slot>
@@ -127,7 +127,7 @@ export const agentConversationProps = {
127
127
  maxMessageWidth: {
128
128
  type: String,
129
129
  default: '48rem',
130
- _edit: { description: 'CSS max-width applied to message bubbles.' },
130
+ _edit: { description: 'CSS max-width applied to the centered message column and composer.' },
131
131
  },
132
132
  };
133
133
  </script>
@@ -136,6 +136,10 @@ export const agentConversationProps = {
136
136
  import { computed, nextTick, ref, useId, watch } from 'vue';
137
137
  import DomIconButton from '../../components/icon-button/DomIconButton.vue';
138
138
  import DomTextareaComposer from '../../forms/textarea-composer/DomTextareaComposer.vue';
139
+ import DomToggleButtonGroup from '../../forms/toggle-button-group/DomToggleButtonGroup.vue';
140
+ import DomAvatar from '../../visual/avatar/DomAvatar.vue';
141
+ import DomEmptyState from '../../visual/empty-state/DomEmptyState.vue';
142
+ import DomSkeleton from '../../visual/skeleton/DomSkeleton.vue';
139
143
  import DomStatusPill from '../../visual/status-pill/DomStatusPill.vue';
140
144
 
141
145
  defineOptions({
@@ -181,6 +185,11 @@ const emit = defineEmits([
181
185
  'scroll-bottom',
182
186
  ]);
183
187
 
188
+ const modeOptions = [
189
+ { label: 'Text', value: 'text' },
190
+ { label: 'Audio', value: 'audio' },
191
+ ];
192
+
184
193
  const scroller = ref(null);
185
194
  const isPinnedToBottom = ref(true);
186
195
  const titleId = useId();
@@ -192,7 +201,7 @@ const statusTone = computed(() => statusToneFor(props.streaming ? 'streaming' :
192
201
  const visibleMessages = computed(() => props.messages.filter(isVisibleMessage));
193
202
  const canSubmit = computed(() => !props.disabled && !props.streaming && props.modelValue.trim().length > 0);
194
203
  const composerPlaceholder = computed(() => props.mode === 'audio' ? 'Describe what you want to say, or connect this to speech input...' : props.placeholder);
195
- const messageWidthStyle = computed(() => ({ maxWidth: props.maxMessageWidth }));
204
+ const columnStyle = computed(() => ({ maxWidth: props.maxMessageWidth }));
196
205
  const shouldShowJumpButton = computed(() => !isPinnedToBottom.value && visibleMessages.value.length > 2);
197
206
 
198
207
  watch(() => props.conversation?.id, async () => {
@@ -351,22 +360,6 @@ function authorName(message) {
351
360
  return findAgentById(props.agents, message.agentId)?.name || activeAgent.value?.name || 'Assistant';
352
361
  }
353
362
 
354
- /**
355
- * Resolves the short avatar label for a message.
356
- *
357
- * @param {AgentChatMessage} message Message record.
358
- * @returns {string} Avatar initials.
359
- */
360
- function avatarLabel(message) {
361
- return authorName(message)
362
- .split(/\s+/)
363
- .filter(Boolean)
364
- .slice(0, 2)
365
- .map(firstCharacter)
366
- .join('')
367
- .toUpperCase() || 'AI';
368
- }
369
-
370
363
  /**
371
364
  * Resolves the timestamp label for a message.
372
365
  *
@@ -417,19 +410,27 @@ function toolStatusLabel(status) {
417
410
  }
418
411
 
419
412
  /**
420
- * Returns status indicator classes for a tool card.
413
+ * Returns the status pill tone for a tool part.
421
414
  *
422
415
  * @param {string} status Tool status.
423
- * @returns {string} Token-based class list.
416
+ * @returns {'neutral'|'info'|'success'|'danger'} Status pill tone.
424
417
  */
425
- function toolStatusClass(status) {
418
+ function toolStatusTone(status) {
426
419
  return {
427
- queued: 'border-border bg-secondary text-muted-fg',
428
- running: 'border-primary/35 bg-primary/10 text-primary',
429
- complete: 'border-success/35 bg-success/10 text-success',
430
- error: 'border-destructive/35 bg-destructive/10 text-destructive',
431
- cancelled: 'border-border bg-muted text-muted-fg',
432
- }[status] || 'border-border bg-secondary text-muted-fg';
420
+ running: 'info',
421
+ complete: 'success',
422
+ error: 'danger',
423
+ }[status] || 'neutral';
424
+ }
425
+
426
+ /**
427
+ * Resolves the avatar image for a message author.
428
+ *
429
+ * @param {AgentChatMessage} message Message record.
430
+ * @returns {string} Avatar URL.
431
+ */
432
+ function avatarUrl(message) {
433
+ return (findAgentById(props.agents, message.agentId) || activeAgent.value)?.avatarUrl || '';
433
434
  }
434
435
 
435
436
  /**
@@ -489,139 +490,138 @@ function statusToneFor(status) {
489
490
  function trimText(value) {
490
491
  return String(value || '').trim();
491
492
  }
492
-
493
- /**
494
- * Returns the first character from a word.
495
- *
496
- * @param {string} value Source word.
497
- * @returns {string} First character.
498
- */
499
- function firstCharacter(value) {
500
- return value.charAt(0);
501
- }
502
493
  </script>
503
494
 
495
+
504
496
  <template>
505
497
  <section
506
- class="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-canvas text-canvas-fg shadow-sm"
498
+ class="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-canvas text-canvas-fg shadow-sm"
507
499
  :aria-labelledby="titleId"
508
500
  >
509
- <header v-if="showHeader" class="flex min-h-16 shrink-0 items-center gap-3 border-b border-border bg-secondary/25 px-3 py-2 sm:px-4">
501
+ <header v-if="showHeader" class="flex h-14 shrink-0 items-center gap-2 border-b border-border/70 px-3 sm:px-4">
510
502
  <slot name="header-leading" :conversation="conversation" :active-agent="activeAgent" />
511
503
  <div class="min-w-0 flex-1">
512
504
  <div class="flex min-w-0 items-center gap-2">
513
- <h2 :id="titleId" class="truncate text-sm font-semibold text-canvas-fg">{{ conversationTitle }}</h2>
514
- <DomStatusPill class="hidden sm:inline-flex" size="sm" :tone="statusTone">{{ streaming ? 'Streaming' : (conversation?.status || 'Idle') }}</DomStatusPill>
505
+ <h2 :id="titleId" class="truncate text-sm font-semibold tracking-tight text-canvas-fg">{{ conversationTitle }}</h2>
506
+ <DomStatusPill class="hidden shrink-0 capitalize sm:inline-flex" size="sm" :tone="statusTone" :pulse="streaming">{{ streaming ? 'Streaming' : (conversation?.status || 'Idle') }}</DomStatusPill>
515
507
  </div>
516
- <p class="mt-0.5 truncate text-xs text-muted-fg">{{ conversationSummary }}</p>
508
+ <p class="truncate text-xs text-muted-fg">{{ conversationSummary }}</p>
517
509
  </div>
518
- <div class="flex shrink-0 items-center gap-1">
519
- <button
520
- type="button"
521
- class="rounded-full px-2.5 py-1 text-xs font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
522
- :class="mode === 'text' ? 'bg-primary text-primary-fg' : 'text-muted-fg hover:bg-secondary hover:text-canvas-fg'"
523
- :aria-pressed="mode === 'text'"
524
- @click="setMode('text')"
525
- >
526
- Text
527
- </button>
528
- <button
529
- type="button"
530
- class="rounded-full px-2.5 py-1 text-xs font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
531
- :class="mode === 'audio' ? 'bg-primary text-primary-fg' : 'text-muted-fg hover:bg-secondary hover:text-canvas-fg'"
532
- :aria-pressed="mode === 'audio'"
533
- @click="setMode('audio')"
534
- >
535
- Audio
536
- </button>
510
+ <div class="flex shrink-0 items-center gap-1.5">
511
+ <DomToggleButtonGroup
512
+ :model-value="mode"
513
+ :options="modeOptions"
514
+ :chrome="false"
515
+ label="Input mode"
516
+ size="sm"
517
+ variant="switch"
518
+ @update:model-value="setMode"
519
+ />
537
520
  <slot name="header-actions" :conversation="conversation" :active-agent="activeAgent" :streaming="streaming" />
538
521
  </div>
539
522
  </header>
540
523
 
541
524
  <div
542
525
  ref="scroller"
543
- class="min-h-0 flex-1 overflow-y-auto px-3 py-4 sm:px-5"
526
+ class="min-h-0 flex-1 overflow-y-auto"
544
527
  @scroll.passive="updateScrollState"
545
528
  >
546
- <div v-if="loading" class="mx-auto flex min-h-full max-w-3xl flex-col justify-end gap-3">
547
- <div v-for="index in 3" :key="index" class="h-24 animate-pulse rounded-lg border border-border bg-secondary/35"></div>
548
- </div>
549
-
550
- <slot v-else-if="!visibleMessages.length" name="empty">
551
- <div class="flex min-h-full items-center justify-center">
552
- <div class="max-w-sm text-center">
553
- <div class="mx-auto grid size-12 place-items-center rounded-full bg-secondary text-muted-fg">
554
- <svg viewBox="0 0 24 24" class="size-5" fill="none" aria-hidden="true">
555
- <path d="M5 7h14M5 12h10M5 17h6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
556
- </svg>
529
+ <div class="mx-auto flex min-h-full w-full flex-col px-4 py-6 sm:px-6" :style="columnStyle">
530
+ <div v-if="loading" class="mt-auto flex flex-col gap-8">
531
+ <div v-for="index in 2" :key="index" class="flex flex-col gap-8">
532
+ <DomSkeleton class="ml-auto h-10 w-2/5 rounded-2xl" />
533
+ <div class="flex gap-3">
534
+ <DomSkeleton variant="circle" width="2rem" height="2rem" class="shrink-0" />
535
+ <DomSkeleton class="flex-1" variant="text" :lines="3" />
557
536
  </div>
558
- <p class="mt-3 text-sm font-semibold text-canvas-fg">No messages yet</p>
559
- <p class="mt-1 text-sm leading-6 text-muted-fg">{{ emptyText }}</p>
560
537
  </div>
561
538
  </div>
562
- </slot>
563
539
 
564
- <div v-else class="mx-auto flex max-w-5xl flex-col gap-5">
565
- <article
566
- v-for="message in visibleMessages"
567
- :key="message.id"
568
- class="flex min-w-0 gap-3"
569
- :class="isUserMessage(message) ? 'justify-end' : 'justify-start'"
570
- >
571
- <div v-if="!isUserMessage(message)" class="mt-1 hidden size-8 shrink-0 place-items-center rounded-full border border-border bg-secondary text-xs font-semibold text-muted-fg sm:grid">
572
- {{ avatarLabel(message) }}
540
+ <slot v-else-if="!visibleMessages.length" name="empty">
541
+ <div class="flex flex-1 items-center justify-center">
542
+ <DomEmptyState
543
+ class="w-full max-w-sm border-transparent bg-transparent"
544
+ size="sm"
545
+ title="No messages yet"
546
+ :description="emptyText"
547
+ icon="M7 8h10M7 12h6m-9 8 2.6-3H18a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v14Z"
548
+ />
573
549
  </div>
574
-
575
- <slot name="message" :message="message" :active-agent="activeAgent" :parts="partsForMessage(message)">
576
- <div
577
- class="min-w-0"
578
- :class="isUserMessage(message) ? 'flex justify-end' : 'flex justify-start'"
579
- :style="messageWidthStyle"
580
- >
550
+ </slot>
551
+
552
+ <div v-else class="flex flex-col gap-7">
553
+ <article
554
+ v-for="message in visibleMessages"
555
+ :key="message.id"
556
+ class="flex min-w-0 gap-3"
557
+ :class="isUserMessage(message) ? 'justify-end' : 'justify-start'"
558
+ >
559
+ <DomAvatar
560
+ v-if="!isUserMessage(message)"
561
+ class="mt-0.5 hidden sm:inline-grid"
562
+ size="sm"
563
+ shape="rounded"
564
+ :src="avatarUrl(message)"
565
+ :name="authorName(message)"
566
+ />
567
+
568
+ <slot name="message" :message="message" :active-agent="activeAgent" :parts="partsForMessage(message)">
581
569
  <div
582
- class="min-w-0 rounded-lg border px-4 py-3 text-sm leading-6 shadow-sm"
583
- :class="isUserMessage(message)
584
- ? 'border-primary/20 bg-primary text-primary-fg'
585
- : 'border-border bg-canvas'"
570
+ class="flex min-w-0 flex-col"
571
+ :class="isUserMessage(message) ? 'max-w-[85%] items-end' : 'flex-1'"
586
572
  >
587
- <div class="mb-2 flex items-center justify-between gap-3 text-xs" :class="isUserMessage(message) ? 'text-primary-fg/80' : 'text-muted-fg'">
588
- <span class="font-semibold">{{ authorName(message) }}</span>
589
- <span v-if="messageTime(message)">{{ messageTime(message) }}</span>
573
+ <div v-if="!isUserMessage(message)" class="mb-1 flex h-6 items-center gap-2 text-xs">
574
+ <span class="font-semibold text-canvas-fg">{{ authorName(message) }}</span>
575
+ <span v-if="messageTime(message)" class="text-muted-fg">{{ messageTime(message) }}</span>
590
576
  </div>
591
577
 
592
- <div class="space-y-3">
578
+ <div
579
+ class="min-w-0 space-y-3 text-sm"
580
+ :class="isUserMessage(message)
581
+ ? 'rounded-2xl rounded-br-md bg-secondary px-4 py-2.5 leading-6 text-secondary-fg'
582
+ : 'leading-7 text-canvas-fg'"
583
+ >
593
584
  <template v-for="(part, index) in partsForMessage(message)" :key="part.id || `${message.id}-${index}`">
594
- <div v-if="part.type === 'text'" class="space-y-2">
585
+ <div v-if="part.type === 'text'" class="space-y-3">
595
586
  <p v-for="paragraph in textParagraphs(part.text)" :key="paragraph" class="whitespace-pre-wrap">{{ paragraph }}</p>
596
587
  </div>
597
588
 
598
589
  <details
599
590
  v-else-if="part.type === 'reasoning' && showReasoning"
600
- class="rounded-md border border-border bg-secondary/35 px-3 py-2"
591
+ class="group/reasoning"
601
592
  :open="part.open"
602
593
  >
603
- <summary class="cursor-pointer text-xs font-semibold text-muted-fg">Reasoning summary</summary>
604
- <p class="mt-2 whitespace-pre-wrap text-xs leading-5 text-muted-fg">{{ part.text }}</p>
594
+ <summary class="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-md text-xs font-medium text-muted-fg transition hover:text-canvas-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 [&::-webkit-details-marker]:hidden">
595
+ <svg viewBox="0 0 24 24" class="size-3.5" fill="none" aria-hidden="true">
596
+ <path d="M12 3v3m0 12v3M3 12h3m12 0h3M6.3 6.3l2.1 2.1m7.2 7.2 2.1 2.1m0-11.4-2.1 2.1m-7.2 7.2-2.1 2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
597
+ </svg>
598
+ Reasoning
599
+ <svg viewBox="0 0 24 24" class="size-3.5 transition group-open/reasoning:rotate-90" fill="none" aria-hidden="true">
600
+ <path d="m9 6 6 6-6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
601
+ </svg>
602
+ </summary>
603
+ <p class="mt-2 whitespace-pre-wrap border-l-2 border-border pl-3 text-xs leading-5 text-muted-fg">{{ part.text }}</p>
605
604
  </details>
606
605
 
607
- <div v-else-if="part.type === 'audio'" class="rounded-md border border-border bg-secondary/35 p-3">
608
- <div class="flex items-center gap-3">
609
- <div class="grid size-8 place-items-center rounded-full bg-primary/10 text-primary">
610
- <svg viewBox="0 0 24 24" class="size-4" fill="none" aria-hidden="true">
611
- <path d="M12 5v14M7 9v6M17 9v6M4 11v2M20 11v2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
612
- </svg>
613
- </div>
614
- <div class="min-w-0 flex-1">
615
- <p class="text-xs font-semibold text-canvas-fg">{{ part.label || 'Audio message' }}</p>
616
- <div class="mt-2 flex h-6 items-end gap-1" aria-hidden="true">
617
- <span v-for="bar in 18" :key="bar" class="w-1 rounded-full bg-primary/45" :style="{ height: `${8 + ((bar * 7) % 18)}px` }"></span>
618
- </div>
606
+ <div v-else-if="part.type === 'audio'" class="flex items-center gap-3 rounded-xl bg-secondary/70 p-2.5 pr-4 text-secondary-fg">
607
+ <span class="grid size-8 shrink-0 place-items-center rounded-full bg-primary text-primary-fg">
608
+ <svg viewBox="0 0 24 24" class="size-4" fill="none" aria-hidden="true">
609
+ <path d="M12 4a3 3 0 0 0-3 3v5a3 3 0 1 0 6 0V7a3 3 0 0 0-3-3Zm-6 8a6 6 0 0 0 12 0m-6 6v2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
610
+ </svg>
611
+ </span>
612
+ <div class="min-w-0 flex-1">
613
+ <p class="text-xs font-medium">{{ part.label || 'Audio message' }}</p>
614
+ <div class="mt-1 flex h-5 items-center gap-0.5" aria-hidden="true">
615
+ <span v-for="bar in 28" :key="bar" class="w-0.5 rounded-full bg-current opacity-40" :style="{ height: `${4 + ((bar * 7) % 16)}px` }"></span>
619
616
  </div>
617
+ <p v-if="part.text" class="mt-1 text-xs leading-5 text-muted-fg">{{ part.text }}</p>
620
618
  </div>
621
- <p v-if="part.text" class="mt-2 text-xs leading-5 text-muted-fg">{{ part.text }}</p>
622
619
  </div>
623
620
 
624
- <div v-else-if="part.type === 'tool-call' || part.type === 'tool-result'" class="rounded-md border border-border bg-secondary/25 p-3">
621
+ <div
622
+ v-else-if="part.type === 'tool-call' || part.type === 'tool-result'"
623
+ class="overflow-hidden rounded-xl border border-border bg-canvas text-canvas-fg shadow-xs"
624
+ >
625
625
  <slot
626
626
  name="tool"
627
627
  :tool="part"
@@ -637,50 +637,59 @@ function firstCharacter(value) {
637
637
  :result="part.result"
638
638
  @action="emitToolAction($event, part, message)"
639
639
  />
640
- <div v-else class="space-y-3">
641
- <div class="flex items-start justify-between gap-3">
642
- <div class="min-w-0">
643
- <p class="truncate text-sm font-semibold text-canvas-fg">{{ part.label || part.name || 'Tool call' }}</p>
644
- <p class="mt-0.5 text-xs text-muted-fg">{{ part.name || 'tool' }}</p>
645
- </div>
646
- <span class="shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium" :class="toolStatusClass(part.status)">
647
- {{ toolStatusLabel(part.status) }}
640
+ <details v-else class="group/tool">
641
+ <summary class="flex cursor-pointer list-none items-center gap-3 px-3 py-2.5 transition hover:bg-secondary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50 [&::-webkit-details-marker]:hidden">
642
+ <span class="grid size-7 shrink-0 place-items-center rounded-lg bg-secondary text-muted-fg">
643
+ <svg viewBox="0 0 24 24" class="size-3.5" fill="none" aria-hidden="true">
644
+ <path d="M14.7 6.3a4 4 0 0 0-5.4 5.2L4 16.8V20h3.2l5.3-5.3a4 4 0 0 0 5.2-5.4l-2.5 2.5-2.3-.7-.7-2.3 2.5-2.5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" />
645
+ </svg>
648
646
  </span>
649
- </div>
650
- <div class="grid gap-3 md:grid-cols-2">
651
- <div>
652
- <p class="mb-1 text-[11px] font-semibold uppercase text-muted-fg">Arguments</p>
653
- <pre class="max-h-40 overflow-auto rounded-md border border-border bg-canvas p-2 text-xs leading-5 text-muted-fg">{{ formatJson(part.args) }}</pre>
647
+ <span class="min-w-0 flex-1">
648
+ <span class="block truncate text-sm font-medium leading-5">{{ part.label || part.name || 'Tool call' }}</span>
649
+ <span class="block truncate font-mono text-[11px] leading-4 text-muted-fg">{{ part.name || 'tool' }}</span>
650
+ </span>
651
+ <DomStatusPill class="shrink-0" size="sm" :tone="toolStatusTone(part.status)" :pulse="part.status === 'running'">{{ toolStatusLabel(part.status) }}</DomStatusPill>
652
+ <svg viewBox="0 0 24 24" class="size-4 shrink-0 text-muted-fg transition group-open/tool:rotate-90" fill="none" aria-hidden="true">
653
+ <path d="m9 6 6 6-6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
654
+ </svg>
655
+ </summary>
656
+ <div class="grid gap-3 border-t border-border bg-secondary/30 p-3 md:grid-cols-2">
657
+ <div class="min-w-0">
658
+ <p class="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-fg">Arguments</p>
659
+ <pre class="max-h-40 overflow-auto rounded-lg bg-canvas p-2.5 font-mono text-xs leading-5 text-muted-fg ring-1 ring-border">{{ formatJson(part.args) }}</pre>
654
660
  </div>
655
- <div>
656
- <p class="mb-1 text-[11px] font-semibold uppercase text-muted-fg">Result</p>
657
- <pre class="max-h-40 overflow-auto rounded-md border border-border bg-canvas p-2 text-xs leading-5 text-muted-fg">{{ formatJson(part.result) }}</pre>
661
+ <div class="min-w-0">
662
+ <p class="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-fg">Result</p>
663
+ <pre class="max-h-40 overflow-auto rounded-lg bg-canvas p-2.5 font-mono text-xs leading-5 text-muted-fg ring-1 ring-border">{{ formatJson(part.result) }}</pre>
658
664
  </div>
659
665
  </div>
660
- </div>
666
+ </details>
661
667
  </slot>
662
668
  </div>
663
669
 
664
- <div v-else-if="part.type === 'handoff'" class="rounded-md border border-warning/35 bg-warning/10 p-3 text-warning">
665
- <p class="text-xs font-semibold">{{ part.label || 'Agent handoff' }}</p>
666
- <p class="mt-1 text-xs leading-5">{{ part.text }}</p>
670
+ <div v-else-if="part.type === 'handoff'" class="flex gap-2.5 rounded-xl border border-warning/30 bg-warning/10 px-3 py-2.5">
671
+ <svg viewBox="0 0 24 24" class="mt-0.5 size-4 shrink-0 text-warning" fill="none" aria-hidden="true">
672
+ <path d="M7 7h11m0 0-3-3m3 3-3 3M17 17H6m0 0 3-3m-3 3 3 3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
673
+ </svg>
674
+ <div class="min-w-0">
675
+ <p class="text-xs font-semibold text-canvas-fg">{{ part.label || 'Agent handoff' }}</p>
676
+ <p class="mt-0.5 text-xs leading-5 text-muted-fg">{{ part.text }}</p>
677
+ </div>
667
678
  </div>
668
679
  </template>
669
680
 
670
- <div v-if="isStreamingMessage(message)" class="flex items-center gap-1.5 pt-1 text-muted-fg" aria-label="Assistant is typing">
681
+ <div v-if="isStreamingMessage(message)" class="flex h-6 items-center gap-1 text-muted-fg" role="status" aria-label="Assistant is typing">
671
682
  <span class="size-1.5 animate-pulse rounded-full bg-current"></span>
672
- <span class="size-1.5 animate-pulse rounded-full bg-current [animation-delay:120ms]"></span>
673
- <span class="size-1.5 animate-pulse rounded-full bg-current [animation-delay:240ms]"></span>
683
+ <span class="size-1.5 animate-pulse rounded-full bg-current [animation-delay:150ms]"></span>
684
+ <span class="size-1.5 animate-pulse rounded-full bg-current [animation-delay:300ms]"></span>
674
685
  </div>
675
686
  </div>
676
- </div>
677
- </div>
678
- </slot>
679
687
 
680
- <div v-if="isUserMessage(message)" class="mt-1 hidden size-8 shrink-0 place-items-center rounded-full border border-primary/25 bg-primary/10 text-xs font-semibold text-primary sm:grid">
681
- {{ avatarLabel(message) }}
682
- </div>
683
- </article>
688
+ <span v-if="isUserMessage(message) && messageTime(message)" class="mt-1 px-1 text-[11px] text-muted-fg">{{ messageTime(message) }}</span>
689
+ </div>
690
+ </slot>
691
+ </article>
692
+ </div>
684
693
  </div>
685
694
  </div>
686
695
 
@@ -695,16 +704,16 @@ function firstCharacter(value) {
695
704
  />
696
705
  </div>
697
706
 
698
- <footer v-if="showFooter" class="shrink-0 border-t border-border bg-canvas p-3">
707
+ <footer v-if="showFooter" class="relative shrink-0 px-3 pb-3 sm:px-4 sm:pb-4 before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-6 before:bg-linear-to-t before:from-canvas before:to-transparent">
699
708
  <slot name="footer" :value="modelValue" :mode="mode" :submit="submitComposer" :set-mode="setMode">
700
- <div>
709
+ <div class="mx-auto w-full [--skin-input-shadow:var(--shadow-md)] [&_.skin-input]:rounded-2xl" :style="columnStyle">
701
710
  <DomTextareaComposer
702
711
  :model-value="modelValue"
703
712
  :placeholder="composerPlaceholder"
704
713
  :disabled="disabled || streaming"
705
714
  :submit-disabled="!canSubmit"
706
715
  :show-submit="!streaming"
707
- :rows="2"
716
+ :rows="1"
708
717
  :max-rows="8"
709
718
  :chrome="false"
710
719
  @update:model-value="updateComposer"
@@ -727,9 +736,9 @@ function firstCharacter(value) {
727
736
  />
728
737
  </template>
729
738
  </DomTextareaComposer>
730
- <div class="mt-2 flex items-center justify-between gap-2 px-1 text-[11px] text-muted-fg">
731
- <span>{{ mode === 'audio' ? 'Audio handoff ready' : 'Enter to send, Shift + Enter for a new line' }}</span>
732
- <span v-if="activeAgent">{{ activeAgent.model }}</span>
739
+ <div class="mt-2 flex items-center justify-between gap-2 px-2 text-[11px] text-muted-fg">
740
+ <span class="hidden truncate sm:inline">{{ mode === 'audio' ? 'Audio handoff ready' : 'Enter to send · Shift + Enter for a new line' }}</span>
741
+ <span v-if="activeAgent?.model" class="ml-auto shrink-0 whitespace-nowrap font-mono">{{ activeAgent.model }}</span>
733
742
  </div>
734
743
  </div>
735
744
  </slot>
@@ -46,14 +46,15 @@ export const agentConversationListProps = {
46
46
  <script setup>
47
47
  import { computed, useId } from 'vue';
48
48
  import DomIconButton from '../../components/icon-button/DomIconButton.vue';
49
- import DomStatusPill from '../../visual/status-pill/DomStatusPill.vue';
49
+ import DomAvatar from '../../visual/avatar/DomAvatar.vue';
50
+ import DomSkeleton from '../../visual/skeleton/DomSkeleton.vue';
50
51
 
51
52
  defineOptions({
52
53
  __doc: {
53
54
  nav: { section: 'ai' },
54
55
  name: 'Agent conversation list',
55
56
  tag: '<DomAgentConversationList>',
56
- description: 'A reusable AI conversation navigation list with search, agent labels, unread counts, status pills, and active-thread selection.',
57
+ description: 'A reusable AI conversation navigation list with search, agent labels, unread counts, status indicators, and active-thread selection.',
57
58
  icon: 'M5 6h14M5 12h14M5 18h8',
58
59
  events: [
59
60
  { name: 'update:search', payload: 'string', description: 'Fired when the sidebar search changes.' },
@@ -148,20 +149,13 @@ function conversationSummary(conversation) {
148
149
  }
149
150
 
150
151
  /**
151
- * Returns a compact label for the agent avatar.
152
+ * Returns the agent name used for a conversation avatar.
152
153
  *
153
154
  * @param {Record<string, unknown>} conversation Conversation summary.
154
- * @returns {string} Initials.
155
+ * @returns {string} Agent name.
155
156
  */
156
- function agentInitials(conversation) {
157
- const name = agentForConversation(conversation)?.name || 'AI';
158
- return name
159
- .split(/\s+/)
160
- .filter(Boolean)
161
- .slice(0, 2)
162
- .map(firstCharacter)
163
- .join('')
164
- .toUpperCase() || 'AI';
157
+ function agentName(conversation) {
158
+ return agentForConversation(conversation)?.name || 'AI';
165
159
  }
166
160
 
167
161
  /**
@@ -182,18 +176,18 @@ function updatedLabel(conversation) {
182
176
  }
183
177
 
184
178
  /**
185
- * Resolves a status pill tone for a conversation.
179
+ * Resolves the status dot class for a conversation.
186
180
  *
187
181
  * @param {Record<string, unknown>} conversation Conversation summary.
188
- * @returns {'neutral'|'info'|'success'|'warning'|'danger'} Status tone.
182
+ * @returns {string} Token-based dot classes.
189
183
  */
190
- function statusTone(conversation) {
184
+ function statusDotClass(conversation) {
191
185
  return {
192
- streaming: 'info',
193
- waiting: 'warning',
194
- error: 'danger',
195
- complete: 'success',
196
- }[conversation.status] || 'neutral';
186
+ streaming: 'bg-primary animate-pulse',
187
+ waiting: 'bg-warning',
188
+ error: 'bg-destructive',
189
+ complete: 'bg-success',
190
+ }[conversation.status] || 'bg-muted-fg/50';
197
191
  }
198
192
 
199
193
  /**
@@ -218,37 +212,27 @@ function conversationMatchesQuery(conversation, query) {
218
212
  .toLowerCase()
219
213
  .includes(query);
220
214
  }
221
-
222
- /**
223
- * Returns the first character from a word.
224
- *
225
- * @param {string} value Source word.
226
- * @returns {string} First character.
227
- */
228
- function firstCharacter(value) {
229
- return value.charAt(0);
230
- }
231
215
  </script>
232
216
 
233
217
  <template>
234
218
  <aside class="flex h-full min-h-0 w-full min-w-0 flex-col bg-canvas text-canvas-fg">
235
- <header class="shrink-0 border-b border-border bg-secondary/25 p-3">
236
- <div class="flex items-center justify-between gap-3">
237
- <h2 class="truncate text-sm font-semibold text-canvas-fg">{{ title }}</h2>
219
+ <header class="shrink-0 px-3 pb-2">
220
+ <div class="flex h-14 items-center justify-between gap-3 px-1">
221
+ <h2 class="truncate text-sm font-semibold tracking-tight text-canvas-fg">{{ title }}</h2>
238
222
  <div class="flex shrink-0 items-center gap-1">
239
223
  <slot name="actions" :conversations="conversations" />
240
224
  <DomIconButton
241
225
  v-if="showNewButton"
242
226
  label="New conversation"
243
227
  size="sm"
244
- variant="secondary"
228
+ variant="ghost"
245
229
  icon="M12 5v14M5 12h14"
246
230
  @click="createConversation"
247
231
  />
248
232
  </div>
249
233
  </div>
250
234
  <label :for="searchId" class="sr-only">Search conversations</label>
251
- <div class="mt-3 flex items-center gap-2 rounded-md border border-border bg-canvas px-3 py-2">
235
+ <div class="skin-input flex items-center gap-2 px-2.5 py-1.5 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30">
252
236
  <svg viewBox="0 0 24 24" class="size-4 shrink-0 text-muted-fg" fill="none" aria-hidden="true">
253
237
  <path d="M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm4.5 10.5L20 20" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
254
238
  </svg>
@@ -263,18 +247,19 @@ function firstCharacter(value) {
263
247
  </div>
264
248
  </header>
265
249
 
266
- <div class="min-h-0 flex-1 overflow-y-auto p-2">
267
- <div v-if="loading" class="space-y-2">
268
- <div v-for="index in 5" :key="index" class="h-20 animate-pulse rounded-lg border border-border bg-secondary/35"></div>
250
+ <div class="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
251
+ <div v-if="loading" class="space-y-1">
252
+ <div v-for="index in 5" :key="index" class="flex gap-2.5 px-2.5 py-2.5">
253
+ <DomSkeleton variant="circle" width="2rem" height="2rem" class="shrink-0" />
254
+ <DomSkeleton class="flex-1" variant="text" :lines="2" />
255
+ </div>
269
256
  </div>
270
257
 
271
258
  <slot v-else-if="!filteredConversations.length" name="empty">
272
- <div class="grid min-h-40 place-items-center rounded-lg border border-dashed border-border p-4 text-center">
273
- <p class="text-sm leading-6 text-muted-fg">{{ emptyText }}</p>
274
- </div>
259
+ <p class="px-3 py-8 text-center text-sm leading-6 text-muted-fg">{{ emptyText }}</p>
275
260
  </slot>
276
261
 
277
- <div v-else class="space-y-1">
262
+ <div v-else class="space-y-0.5">
278
263
  <slot
279
264
  v-for="conversation in filteredConversations"
280
265
  :key="conversation.id"
@@ -285,27 +270,38 @@ function firstCharacter(value) {
285
270
  >
286
271
  <button
287
272
  type="button"
288
- class="w-full rounded-lg border p-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
289
- :class="isActiveConversation(conversation)
290
- ? 'border-primary/40 bg-primary/10 shadow-sm'
291
- : 'border-transparent hover:border-border hover:bg-secondary/45'"
273
+ class="w-full rounded-lg px-2.5 py-2.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
274
+ :class="isActiveConversation(conversation) ? 'bg-secondary' : 'hover:bg-secondary/50'"
275
+ :aria-current="isActiveConversation(conversation) ? 'true' : undefined"
292
276
  @click="selectConversation(conversation)"
293
277
  >
294
- <div class="flex items-start gap-3">
295
- <div class="grid size-9 shrink-0 place-items-center rounded-full border border-border bg-canvas text-xs font-semibold text-muted-fg">
296
- {{ agentInitials(conversation) }}
297
- </div>
278
+ <div class="flex items-start gap-2.5">
279
+ <span class="relative shrink-0">
280
+ <DomAvatar
281
+ size="sm"
282
+ shape="rounded"
283
+ :src="agentForConversation(conversation)?.avatarUrl || ''"
284
+ :name="agentName(conversation)"
285
+ />
286
+ <span
287
+ class="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full ring-2"
288
+ :class="[statusDotClass(conversation), isActiveConversation(conversation) ? 'ring-secondary' : 'ring-canvas']"
289
+ :title="conversation.status || 'idle'"
290
+ aria-hidden="true"
291
+ ></span>
292
+ </span>
298
293
  <div class="min-w-0 flex-1">
299
- <div class="flex min-w-0 items-start justify-between gap-2">
300
- <p class="truncate text-sm font-semibold text-canvas-fg">{{ conversationTitle(conversation) }}</p>
294
+ <div class="flex min-w-0 items-baseline justify-between gap-2">
295
+ <p class="truncate text-sm font-medium text-canvas-fg">{{ conversationTitle(conversation) }}</p>
301
296
  <span class="shrink-0 text-[11px] text-muted-fg">{{ updatedLabel(conversation) }}</span>
302
297
  </div>
303
- <p class="mt-1 line-clamp-2 text-xs leading-5 text-muted-fg">{{ conversationSummary(conversation) }}</p>
304
- <div class="mt-2 flex items-center justify-between gap-2">
305
- <DomStatusPill size="sm" :tone="statusTone(conversation)" variant="soft">{{ conversation.status || 'idle' }}</DomStatusPill>
298
+ <div class="mt-0.5 flex min-w-0 items-center gap-2">
299
+ <p class="min-w-0 flex-1 truncate text-xs leading-5 text-muted-fg">
300
+ <span class="sr-only">{{ conversation.status || 'idle' }}. </span>{{ conversationSummary(conversation) }}
301
+ </p>
306
302
  <span
307
303
  v-if="conversation.unreadCount"
308
- class="grid min-w-5 place-items-center rounded-full bg-primary px-1.5 py-0.5 text-[11px] font-semibold text-primary-fg"
304
+ class="grid h-4.5 min-w-4.5 shrink-0 place-items-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-fg"
309
305
  >
310
306
  {{ conversation.unreadCount }}
311
307
  </span>
@@ -264,8 +264,8 @@ export const monthCalendarProps = {
264
264
  * @param {string} value Requested indicator style.
265
265
  * @returns {boolean} Whether the indicator is supported.
266
266
  */
267
- validator: (value) => ['cell', 'circle', 'none'].includes(value),
268
- _edit: { group: 'Styling', options: ['cell', 'circle', 'none'], description: 'Highlight today with a cell outline, a coloured circle behind the date, or no visual indicator.' },
267
+ validator: (value) => ['cell', 'circle', 'both', 'none'].includes(value),
268
+ _edit: { group: 'Styling', options: ['cell', 'circle', 'both', 'none'], description: 'Highlight today with a cell outline, a coloured circle behind the date, both, or no visual indicator.' },
269
269
  },
270
270
  /**
271
271
  * @type {MonthCalendarDayClass}
@@ -273,7 +273,7 @@ export const monthCalendarProps = {
273
273
  todayDateClass: {
274
274
  type: [String, Array, Object, Function],
275
275
  default: 'bg-primary text-primary-fg',
276
- _edit: { group: 'Styling', description: 'Classes for today’s date circle. Replaces the default primary background and foreground colours when todayIndicator is circle.' },
276
+ _edit: { group: 'Styling', description: 'Classes for today’s date circle. Replaces the default primary background and foreground colours when todayIndicator is circle or both.' },
277
277
  },
278
278
  /**
279
279
  * @type {MonthCalendarDayClass}
@@ -1632,7 +1632,7 @@ function onDayClick(cell, event) {
1632
1632
  v-if="hasRowRail"
1633
1633
  role="rowheader"
1634
1634
  :aria-label="rowHeaderLabelFor(week)"
1635
- class="dom-month-calendar__row-rail flex gap-3 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-fg"
1635
+ class="dom-month-calendar__row-rail flex gap-3 px-3 py-[var(--dom-month-calendar-row-rail-padding)] text-xs font-semibold uppercase tracking-wide text-muted-fg"
1636
1636
  :class="[
1637
1637
  week.index > 0 && 'border-t border-border',
1638
1638
  section.continuous ? 'bg-canvas' : 'bg-secondary/35',
@@ -1648,7 +1648,7 @@ function onDayClick(cell, event) {
1648
1648
  {{ label }}
1649
1649
  </span>
1650
1650
  </div>
1651
- <span v-if="showWeekNumbers" class="text-base leading-none tabular-nums">{{ week.weekNumber }}</span>
1651
+ <span v-if="showWeekNumbers" class="order-[var(--dom-month-calendar-week-number-order)] inline-flex size-7 shrink-0 items-center justify-center text-base leading-none tabular-nums">{{ week.weekNumber }}</span>
1652
1652
  </div>
1653
1653
  <component
1654
1654
  :is="clickable ? 'button' : 'div'"
@@ -1681,7 +1681,7 @@ function onDayClick(cell, event) {
1681
1681
  clickable && !cell.disabled && hoverableDays && 'hover:bg-secondary/50',
1682
1682
  clickable && cell.disabled && 'cursor-not-allowed opacity-55',
1683
1683
  !clickable && 'cursor-default',
1684
- cell.today && !cell.hidden && todayIndicator === 'cell' && 'ring-2 ring-inset ring-ring/40',
1684
+ cell.today && !cell.hidden && (todayIndicator === 'cell' || todayIndicator === 'both') && 'ring-2 ring-inset ring-ring/40',
1685
1685
  dayClassFor(cell),
1686
1686
  dragAndDrop && dragState.active && canDropOnCell(cell) && !isDropTarget(cell) && 'bg-primary/5',
1687
1687
  dragAndDrop && isDropTarget(cell) && 'bg-primary/10 ring-2 ring-inset ring-ring/60',
@@ -1698,10 +1698,9 @@ function onDayClick(cell, event) {
1698
1698
  <template v-if="!cell.hidden">
1699
1699
  <div class="flex items-center justify-between gap-2">
1700
1700
  <span
1701
- class="text-base font-bold leading-none tabular-nums"
1701
+ class="inline-flex size-7 shrink-0 items-center justify-center rounded-full text-base font-bold leading-none tabular-nums"
1702
1702
  :class="[
1703
- todayIndicator === 'circle' && 'inline-flex size-7 shrink-0 items-center justify-center rounded-full',
1704
- cell.today && todayIndicator === 'circle' && resolveDayClass(todayDateClass, cell),
1703
+ cell.today && (todayIndicator === 'circle' || todayIndicator === 'both') && resolveDayClass(todayDateClass, cell),
1705
1704
  ]"
1706
1705
  >{{ cell.day }}</span>
1707
1706
  <span v-if="showDayNames" class="text-base lowercase leading-none tabular-nums">{{ cell.weekday }}</span>
@@ -2644,11 +2644,11 @@ export interface DomMonthCalendarProps {
2644
2644
  */
2645
2645
  showDayNames?: boolean;
2646
2646
  /**
2647
- * Highlight today with a cell outline, a coloured circle behind the date, or no visual indicator.
2647
+ * Highlight today with a cell outline, a coloured circle behind the date, both, or no visual indicator.
2648
2648
  */
2649
- todayIndicator?: "cell" | "circle" | "none";
2649
+ todayIndicator?: "cell" | "circle" | "both" | "none";
2650
2650
  /**
2651
- * Classes for today’s date circle. Replaces the default primary background and foreground colours when todayIndicator is circle.
2651
+ * Classes for today’s date circle. Replaces the default primary background and foreground colours when todayIndicator is circle or both.
2652
2652
  */
2653
2653
  todayDateClass?: string | Array<unknown> | Record<string, unknown> | ((...args: Array<unknown>) => unknown);
2654
2654
  /**
package/src/tailwind.css CHANGED
@@ -14,6 +14,8 @@
14
14
  --dom-month-calendar-row-rail-direction: row;
15
15
  --dom-month-calendar-row-rail-justify: space-between;
16
16
  --dom-month-calendar-row-rail-min-height: 2.5rem;
17
+ --dom-month-calendar-row-rail-padding: 0.5rem;
18
+ --dom-month-calendar-week-number-order: 0;
17
19
  --dom-month-calendar-week-columns: minmax(0, 1fr);
18
20
  }
19
21
 
@@ -33,6 +35,8 @@
33
35
  --dom-month-calendar-row-rail-direction: column;
34
36
  --dom-month-calendar-row-rail-justify: flex-start;
35
37
  --dom-month-calendar-row-rail-min-height: 11rem;
38
+ --dom-month-calendar-row-rail-padding: 0.75rem;
39
+ --dom-month-calendar-week-number-order: -1;
36
40
  --dom-month-calendar-week-columns: var(--dom-month-calendar-wide-week-columns);
37
41
  --dom-month-calendar-wide-border-left-width: 1px;
38
42
  --dom-month-calendar-wide-border-top-width: 0;
@@ -109,7 +113,7 @@
109
113
  }
110
114
 
111
115
  .skin-control {
112
- background-color: var(--skin-control-bg, var(--secondary));
116
+ background-color: var(--skin-control-bg, var(--canvas));
113
117
  background-image: var(--skin-control-bg-image, none);
114
118
  border-color: var(--skin-control-border, var(--border));
115
119
  border-style: var(--skin-control-border-style, solid);
package/src/theme.css CHANGED
@@ -11,6 +11,8 @@
11
11
  --shadow-lg: 0 8px 20px -6px rgb(15 23 42 / 0.16), 0 2px 6px -2px rgb(15 23 42 / 0.08);
12
12
  --shadow-xl: 0 18px 44px -18px rgb(15 23 42 / 0.24), 0 8px 18px -12px rgb(15 23 42 / 0.16);
13
13
  --shadow-2xl: 0 24px 60px -18px rgb(15 23 42 / 0.24), 0 8px 20px -10px rgb(15 23 42 / 0.14);
14
+ --dom-command-palette-background: color-mix(in oklch, var(--canvas) 68%, transparent);
15
+ --dom-command-palette-shadow: 0 40px 100px -24px rgb(15 23 42 / 0.3), 0 16px 40px -20px rgb(15 23 42 / 0.2);
14
16
  --chart-1: oklch(0.55 0.19 257);
15
17
  --chart-2: oklch(0.58 0.13 175);
16
18
  --chart-3: oklch(0.65 0.16 65);
@@ -45,6 +47,8 @@
45
47
  --shadow-lg: 0 1px 0 0 rgb(255 255 255 / 0.05) inset, 0 16px 34px -18px rgb(0 0 0 / 0.58), 0 5px 12px -8px rgb(0 0 0 / 0.5);
46
48
  --shadow-xl: 0 1px 0 0 rgb(255 255 255 / 0.05) inset, 0 24px 52px -24px rgb(0 0 0 / 0.68), 0 10px 22px -14px rgb(0 0 0 / 0.56);
47
49
  --shadow-2xl: 0 1px 0 0 rgb(255 255 255 / 0.06) inset, 0 34px 76px -30px rgb(0 0 0 / 0.72), 0 16px 34px -20px rgb(0 0 0 / 0.58);
50
+ --dom-command-palette-background: color-mix(in oklch, var(--canvas) 68%, transparent);
51
+ --dom-command-palette-shadow: 0 40px 100px -24px rgb(0 0 0 / 0.78), 0 16px 40px -20px rgb(0 0 0 / 0.62);
48
52
  --chart-1: oklch(0.72 0.15 257);
49
53
  --chart-2: oklch(0.75 0.13 175);
50
54
  --chart-3: oklch(0.8 0.15 75);