@getdom/studio 0.2.1 → 0.2.3

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE.md +23 -9
  3. package/package.json +1 -1
  4. package/src/pages/_layout/componentCatalog.generated.js +115 -13
  5. package/src/pages/components/command-palette/DomCommandPalette.vue +6 -1
  6. package/src/pages/data/agent-chat/DomAgentChatShell.vue +52 -53
  7. package/src/pages/data/agent-chat/DomAgentConversation.vue +163 -154
  8. package/src/pages/data/agent-chat/DomAgentConversationList.vue +53 -57
  9. package/src/pages/data/month-calendar/DomMonthCalendar.vue +8 -9
  10. package/src/pages/forms/_shared/DomOptionStatus.vue +18 -0
  11. package/src/pages/forms/_shared/useOptionMenu.js +18 -2
  12. package/src/pages/forms/_shared/useOptionPaging.js +42 -0
  13. package/src/pages/forms/_shared/useSelectedOptions.js +25 -0
  14. package/src/pages/forms/autocomplete/DomAutocomplete.vue +36 -8
  15. package/src/pages/forms/combobox/DomCombobox.vue +44 -7
  16. package/src/pages/forms/font-picker/DomFontPicker.vue +416 -0
  17. package/src/pages/forms/font-picker/FontPreview.vue +39 -0
  18. package/src/pages/forms/font-picker/fontPicker.js +102 -0
  19. package/src/pages/forms/font-picker/googleFonts.js +1951 -0
  20. package/src/pages/forms/select/DomSelect.vue +118 -29
  21. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +67 -15
  22. package/src/pages/lib/headless/autocomplete.js +5 -0
  23. package/src/pages/lib/headless/combobox.js +129 -25
  24. package/src/pages/lib/headless/dropdown.js +3 -2
  25. package/src/pages/lib/headless/listbox.d.ts +1 -0
  26. package/src/pages/lib/headless/listbox.js +41 -5
  27. package/src/pages/lib/vue/index.d.ts +136 -6
  28. package/src/pages/lib/vue/index.js +1 -0
  29. package/src/tailwind.css +5 -1
  30. package/src/theme.css +4 -0
@@ -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>