# Patterns

A conversation is a composition. The transcript, bubbles, reasoning, tool calls, citations, and the composer are separate components, and the page decides how they sit together. Nothing here calls a model. The body is a slot. Long replies are Markdown, and a source becomes a citation.

Before any transcript exists, the page is a greeting and one composer. The mode can move among writing, coding, research, and creative work. Sensitive and deep modes insert a chip instead. @ opens people, and / opens commands. Files sit in the drawer, and a long paste becomes a chip too. The microphone appears only when the browser can recognize speech. Choosing a category writes that suggestion into the composer.

Once a thread exists, the transcript is a column and the composer stays at the bottom. While the assistant is still writing, send becomes stop and the log is marked as streaming. Reasoning opens first, a tool call moves from running to complete, and the answer carries a citation. A generated document opens from a card. The full playground page keeps that document in a resizable side panel, and below 768px it becomes a full-screen dialog.

Both examples below can be used. The full pages are in the playground: [AI Chat Landing](/en/playground/?template=ai-chat-landing) and [AI Chat Conversation](/en/playground/?template=ai-chat). Density, unread rules, and metadata are documented on [Chat](/en/components/chat).

### Landing

Greeting, modes, mentions, commands, attachments, and category suggestions share one composer. Choosing a suggestion writes that prompt and switches the mode.

```vue
<script setup lang="ts">
import type { ChatComposerInputHandle, ChatComposerTrigger } from '@astryx-vue/core'
import {
  Button,
  ChatComposer,
  ChatComposerDrawer,
  ChatComposerInput,
  ChatDictationButton,
  ClickableCard,
  createStaticSource,
  DropdownMenu,
  DropdownMenuDivider,
  DropdownMenuItem,
  Grid,
  Heading,
  HStack,
  Icon,
  IconButton,
  Layout,
  LayoutContent,
  Text,
  ToggleButton,
  ToggleButtonGroup,
  Token,
  useChatDictation,
  VStack,
} from '@astryx-vue/core'
import { computed, nextTick, ref, useTemplateRef } from 'vue'

const CATEGORY_SUGGESTIONS: Record<string, Array<{ heading: string, body: string, prompt: string }>> = {
  writing: [
    { heading: 'Draft a professional email', body: 'Compose a clear, polished email for any audience', prompt: 'Help me draft a professional email' },
    { heading: 'Improve my writing', body: 'Enhance the clarity, tone, and flow of my text', prompt: 'Review and improve the following text:' },
    { heading: 'Create a project proposal', body: 'Write a proposal with goals, timeline, and deliverables', prompt: 'Help me write a project proposal for' },
    { heading: 'Summarize a document', body: 'Condense a long document into key takeaways', prompt: 'Summarize the following document into key points:' },
  ],
  coding: [
    { heading: 'Debug my code', body: 'Find and fix issues in a code snippet', prompt: 'Help me debug the following code:' },
    { heading: 'Write a function', body: 'Generate a well-typed function with error handling', prompt: 'Write a function that' },
    { heading: 'Explain this code', body: 'Break down complex code into understandable pieces', prompt: 'Explain what the following code does:' },
    { heading: 'Review my pull request', body: 'Check for bugs, performance, and best practices', prompt: 'Review this code for bugs and improvements:' },
  ],
  research: [
    { heading: 'Compare options', body: 'Analyze pros and cons of different approaches', prompt: 'Compare the pros and cons of' },
    { heading: 'Explain a concept', body: 'Break down a complex topic in simple terms', prompt: 'Explain the concept of' },
    { heading: 'Find best practices', body: 'Research standards and recommended approaches', prompt: 'What are the best practices for' },
    { heading: 'Summarize findings', body: 'Compile research into a structured overview', prompt: 'Summarize the key findings on' },
  ],
  creative: [
    { heading: 'Brainstorm ideas', body: 'Generate creative concepts for a project', prompt: 'Brainstorm ideas for' },
    { heading: 'Write a story', body: 'Create an engaging narrative with characters', prompt: 'Write a short story about' },
    { heading: 'Design a concept', body: 'Explore product or visual design ideas', prompt: 'Help me design a concept for' },
    { heading: 'Create a tagline', body: 'Craft a memorable phrase for a brand or product', prompt: 'Create a catchy tagline for' },
  ],
}

const MENTION_ITEMS = [
  { id: 'cindy', label: 'Cindy Zhang', description: 'Design Systems' },
  { id: 'alex', label: 'Alex Johnson', description: 'Frontend' },
  { id: 'sam', label: 'Sam Rivera', description: 'Backend' },
  { id: 'jordan', label: 'Jordan Lee', description: 'Product' },
  { id: 'taylor', label: 'Taylor Kim', description: 'Design' },
  { id: 'morgan', label: 'Morgan Chen', description: 'Infrastructure' },
]

const COMMAND_ITEMS = [
  { id: 'summarize', label: 'summarize', description: 'Summarize the conversation' },
  { id: 'translate', label: 'translate', description: 'Translate text to another language' },
  { id: 'search', label: 'search', description: 'Search the web or documents' },
  { id: 'code', label: 'code', description: 'Generate or explain code' },
  { id: 'help', label: 'help', description: 'Show available commands' },
]

const TOKEN_MODES: Record<string, string> = {
  sensitive: '/sensitive',
  deep: '/deep-mode',
}

const composerTriggers: ChatComposerTrigger[] = [
  {
    character: '@',
    menuLabel: 'People',
    searchSource: createStaticSource(MENTION_ITEMS),
    onSelect: item => ({ value: `@${item.id}`, label: item.label, variant: 'blue' }),
  },
  {
    character: '/',
    menuLabel: 'Commands',
    searchSource: createStaticSource(COMMAND_ITEMS),
    onSelect: item => ({ value: `/${item.label}`, label: `/${item.label}`, variant: 'yellow' }),
  },
]

const mode = ref<string | null>('auto')
const category = ref<string | null>(null)
const attachments = ref([
  'project_brief.pdf',
  'wireframes_v2.fig',
  'api_spec.yaml',
  'user_research.csv',
  'brand_guidelines.pdf',
])
const draft = ref('')
const isModeMenuOpen = ref(false)
const composerInputRef = useTemplateRef<ChatComposerInputHandle>('composerInput')
const shouldFocusComposer = ref(false)
const dictation = useChatDictation({ input: composerInputRef })

const suggestions = computed(() => (category.value ? CATEGORY_SUGGESTIONS[category.value] : null))
const modeLabel = computed(() => {
  if (mode.value === 'writing')
    return 'Writing'
  if (mode.value === 'coding')
    return 'Coding'
  if (mode.value === 'research')
    return 'Research'
  if (mode.value === 'creative')
    return 'Creative'
  return 'Auto'
})

async function applySuggestion(prompt: string) {
  draft.value = prompt
  if (category.value)
    mode.value = category.value
  await nextTick()
  composerInputRef.value?.focus()
}

function insertMention(item: typeof MENTION_ITEMS[number]) {
  const input = composerInputRef.value
  if (!input)
    return
  input.focus()
  input.insertToken({ value: `@${item.id}`, label: item.label, variant: 'blue' })
}

function insertModeToken(label: string) {
  composerInputRef.value?.focus()
  composerInputRef.value?.insertToken({ value: label, label, variant: 'orange' })
  shouldFocusComposer.value = true
}

function onModeOpenChange(isOpen: boolean) {
  isModeMenuOpen.value = isOpen
  if (!isOpen && shouldFocusComposer.value) {
    shouldFocusComposer.value = false
    window.setTimeout(() => composerInputRef.value?.focus(), 50)
  }
}

function addFiles(files: File[]) {
  attachments.value = [...attachments.value, ...files.map(file => file.name)]
}

function removeAttachment(name: string) {
  attachments.value = attachments.value.filter(item => item !== name)
}

function chooseMode(key: string) {
  const tokenLabel = TOKEN_MODES[key]
  if (tokenLabel)
    insertModeToken(tokenLabel)
  else
    mode.value = key
}
</script>

<template>
  <Layout :content-width="720" :padding="6">
    <LayoutContent>
      <VStack :gap="8" align="center" class="page">
        <VStack :gap="1">
          <HStack :gap="2" align="center">
            <Icon name="heroicons:sparkles" size="md" color="accent" />
            <Text type="large">
              Hi, Andrew
            </Text>
          </HStack>
          <Heading :level="1" type="display-2">
            Where should we start?
          </Heading>
        </VStack>

        <ChatComposer v-model="draft" class="composer" placeholder="Ask anything">
          <template #input>
            <ChatComposerInput
              ref="composerInput"
              class="composer-input"
              :triggers="composerTriggers"
              @files="addFiles"
            />
          </template>
          <template v-if="attachments.length" #drawer>
            <ChatComposerDrawer :count="attachments.length">
              <Token
                v-for="name in attachments"
                :key="name"
                :label="name"
                @remove="removeAttachment(name)"
              />
            </ChatComposerDrawer>
          </template>
          <template #headerActions>
            <DropdownMenu :has-chevron="false" :menu-width="240">
              <template #trigger="triggerProps">
                <IconButton v-bind="triggerProps" label="Reference" variant="ghost" size="sm">
                  <Icon name="heroicons:at-symbol" />
                </IconButton>
              </template>
              <DropdownMenuItem
                v-for="item in MENTION_ITEMS"
                :key="item.id"
                :label="item.label"
                :description="item.description"
                @click="insertMention(item)"
              />
            </DropdownMenu>
          </template>
          <template #footerActions>
            <DropdownMenu
              :menu-width="200"
              :is-menu-open="isModeMenuOpen"
              @open-change="onModeOpenChange"
            >
              <template #trigger="triggerProps">
                <Button v-bind="triggerProps" :label="modeLabel" variant="ghost" size="md">
                  <template #icon>
                    <Icon v-if="mode === 'writing'" name="heroicons:pencil-square" size="sm" />
                    <Icon v-else-if="mode === 'coding'" name="heroicons:code-bracket" size="sm" />
                    <Icon v-else-if="mode === 'research'" name="heroicons:magnifying-glass" size="sm" />
                    <Icon v-else-if="mode === 'creative'" name="heroicons:light-bulb" size="sm" />
                    <Icon v-else name="heroicons:sparkles" size="sm" />
                  </template>
                  <template #end>
                    <Icon icon="chevronDown" size="sm" color="inherit" />
                  </template>
                </Button>
              </template>
              <DropdownMenuItem label="Auto" @click="chooseMode('auto')">
                <template #icon>
                  <Icon name="heroicons:sparkles" size="sm" />
                </template>
              </DropdownMenuItem>
              <DropdownMenuItem label="Writing" @click="chooseMode('writing')">
                <template #icon>
                  <Icon name="heroicons:pencil-square" size="sm" />
                </template>
              </DropdownMenuItem>
              <DropdownMenuItem label="Coding" @click="chooseMode('coding')">
                <template #icon>
                  <Icon name="heroicons:code-bracket" size="sm" />
                </template>
              </DropdownMenuItem>
              <DropdownMenuItem label="Research" @click="chooseMode('research')">
                <template #icon>
                  <Icon name="heroicons:magnifying-glass" size="sm" />
                </template>
              </DropdownMenuItem>
              <DropdownMenuItem label="Creative" @click="chooseMode('creative')">
                <template #icon>
                  <Icon name="heroicons:light-bulb" size="sm" />
                </template>
              </DropdownMenuItem>
              <DropdownMenuDivider />
              <DropdownMenuItem label="Sensitive" @click="chooseMode('sensitive')">
                <template #icon>
                  <Icon name="heroicons:lock-closed" size="sm" />
                </template>
              </DropdownMenuItem>
              <DropdownMenuItem label="Deep Mode" @click="chooseMode('deep')">
                <template #icon>
                  <Icon name="heroicons:clock" size="sm" />
                </template>
              </DropdownMenuItem>
            </DropdownMenu>
            <DropdownMenu
              :menu-width="200"
              :items="[
                { label: 'Preferences' },
                { label: 'Keyboard shortcuts' },
                { label: 'About' },
              ]"
            >
              <template #trigger="triggerProps">
                <Button v-bind="triggerProps" label="Settings" variant="ghost" size="md">
                  <template #icon>
                    <Icon name="heroicons:cog-6-tooth" size="sm" />
                  </template>
                  <template #end>
                    <Icon icon="chevronDown" size="sm" color="inherit" />
                  </template>
                </Button>
              </template>
            </DropdownMenu>
          </template>
          <template #sendActions>
            <ChatDictationButton :dictation="dictation" />
          </template>
        </ChatComposer>

        <VStack :gap="6" class="categories">
          <ToggleButtonGroup v-model:value="category" label="Category" size="lg">
            <ToggleButton value="writing" label="Writing">
              <template #icon>
                <Icon name="heroicons:pencil-square" size="sm" />
              </template>
            </ToggleButton>
            <ToggleButton value="coding" label="Coding">
              <template #icon>
                <Icon name="heroicons:code-bracket" size="sm" />
              </template>
            </ToggleButton>
            <ToggleButton value="research" label="Research">
              <template #icon>
                <Icon name="heroicons:magnifying-glass" size="sm" />
              </template>
            </ToggleButton>
            <ToggleButton value="creative" label="Creative">
              <template #icon>
                <Icon name="heroicons:light-bulb" size="sm" />
              </template>
            </ToggleButton>
          </ToggleButtonGroup>

          <Grid v-if="suggestions" :columns="{ minWidth: 280 }" :gap="3">
            <ClickableCard
              v-for="suggestion in suggestions"
              :key="suggestion.heading"
              :label="suggestion.heading"
              variant="muted"
              :padding="3"
              @click="applySuggestion(suggestion.prompt)"
            >
              <VStack :gap="0.5">
                <Heading :level="4">
                  {{ suggestion.heading }}
                </Heading>
                <Text type="body" color="secondary" size="sm">
                  {{ suggestion.body }}
                </Text>
              </VStack>
            </ClickableCard>
          </Grid>
        </VStack>
      </VStack>
    </LayoutContent>
  </Layout>
</template>

<style scoped>
.page { width: 100%; }
.composer { width: 100%; }
.composer-input { min-height: 84px; }
.categories { padding-inline: var(--astryx-spacing-3); width: 100%; }

/* 窄屏放不下一整行：分类按钮可以换行，输入框页脚里的模式 / 设置按钮可以收缩。 */
.categories :deep(.astryx-toggle-button-group) { flex-wrap: wrap; justify-content: center; }
.composer :deep(.astryx-chat-composer__footer-start) { min-width: 0; flex-wrap: wrap; flex-shrink: 1; }
.composer :deep(.astryx-chat-composer__footer-end) { flex-shrink: 0; }
</style>
```

### Conversation

One transcript carries reasoning, tool calls, a citation, and a document card. Sending grows the reply in that order, and stop is available while it is still running. The card opens a full-screen dialog.

```vue
<script setup lang="ts">
import type { ChatComposerInputHandle, ChatComposerToken, ChatComposerTrigger, ChatToolCallItem } from '@astryx-vue/core'
import {
  Avatar,
  ChatComposer,
  ChatComposerDrawer,
  ChatComposerInput,
  ChatLayout,
  ChatMessage,
  ChatMessageBubble,
  ChatMessageList,
  ChatMessageMetadata,
  ChatReasoning,
  ChatTokenizedText,
  ChatToolCalls,
  ClickableCard,
  createStaticSource,
  Dialog,
  DialogHeader,
  DropdownMenu,
  Heading,
  HStack,
  Icon,
  IconButton,
  Layout,
  LayoutContent,
  Markdown,
  Section,
  StackItem,
  Text,
  Timestamp,
  Token,
  useStreamingText,
  VStack,
} from '@astryx-vue/core'
import { computed, onBeforeUnmount, reactive, ref, useTemplateRef } from 'vue'

const ARTIFACT_TITLE = 'JWT Token Refresh: Design & Rollout'
const ARTIFACT_BODY = `## The Fix

The middleware catches \`TokenExpiredError\` and refreshes before it rejects. A missing refresh token still returns \`401\`.

## Rollout

Ship behind \`silent_refresh\` at 5% and alert if failures exceed **2%**.`

const SOURCES = {
  jwt: { title: 'RFC 7519', url: 'https://datatracker.ietf.org/doc/html/rfc7519' },
}

const MENTION_ITEMS = [
  { id: 'agent', label: 'Agent', description: 'Assistant' },
  { id: 'cindy', label: 'Cindy Zhang', description: 'Design Systems' },
  { id: 'alex', label: 'Alex Johnson', description: 'Frontend' },
]

const MESSAGE_TOKENS: ChatComposerToken[] = [
  { value: '@agent', label: '@Agent', variant: 'blue' },
  { value: '@cindy', label: 'Cindy Zhang', variant: 'blue' },
  { value: '@alex', label: 'Alex Johnson', variant: 'blue' },
]

const composerTriggers: ChatComposerTrigger[] = [{
  character: '@',
  menuLabel: 'People',
  searchSource: createStaticSource(MENTION_ITEMS),
  onSelect: item => ({
    value: `@${item.id}`,
    label: item.id === 'agent' ? '@Agent' : item.label,
    variant: 'blue' as const,
  }),
}]

const REASONING = 'Validation throws before the refresh call. The catch has to refresh, then fall back to 401.'
const LIVE_ANSWER = 'The expiry path now refreshes first. The token format follows [jwt], and the rollout thresholds are in the document.'

interface LiveUser {
  kind: 'user'
  id: number
  text: string
  files: string[]
  at: string
}

interface LiveAssistant {
  kind: 'assistant'
  id: number
  at: string
  reasoning: string
  reasoningStreaming: boolean
  reasoningDuration?: string
  tools: ChatToolCallItem[]
  answer: string
  answerStreaming: boolean
  showArtifact: boolean
  stopped: boolean
}

const composerMode = ref<'ask' | 'edit'>('ask')
const attachments = ref<string[]>(['middleware.ts'])
const isArtifactOpen = ref(false)
const isStreaming = ref(false)
const liveTurns = ref<Array<LiveUser | LiveAssistant>>([])
const activeAssistantId = ref<number | null>(null)
const reasoningSource = ref('')
const reasoningLive = ref(false)
const answerSource = ref('')
const answerLive = ref(false)
const reasoningText = useStreamingText(reasoningSource, reasoningLive)
const answerText = useStreamingText(answerSource, answerLive)
const composerInputRef = useTemplateRef<ChatComposerInputHandle>('composerInput')
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInput')
const placeholder = computed(() => composerMode.value === 'ask' ? 'Ask anything...' : 'Describe your edit...')

let nextId = 1
let timers: number[] = []

function addFiles(files: File[]) {
  if (files.length === 0)
    return
  attachments.value = [...attachments.value, ...files.map(file => file.name)]
}

function onPickFiles(event: Event) {
  const input = event.target
  if (!(input instanceof HTMLInputElement))
    return
  addFiles(Array.from(input.files ?? []))
  input.value = ''
}

function removeAttachment(name: string) {
  attachments.value = attachments.value.filter(item => item !== name)
}

function mention() {
  composerInputRef.value?.focus()
  composerInputRef.value?.insertText('@')
}

function clearTimers() {
  for (const id of timers)
    window.clearTimeout(id)
  timers = []
}

function displayedReasoning(turn: LiveAssistant): string {
  return turn.id === activeAssistantId.value && reasoningLive.value ? reasoningText.value : turn.reasoning
}

function displayedAnswer(turn: LiveAssistant): string {
  return turn.id === activeAssistantId.value && answerLive.value ? answerText.value : turn.answer
}

function stop() {
  clearTimers()
  const turn = liveTurns.value.find(item => item.kind === 'assistant' && item.id === activeAssistantId.value)
  if (turn && turn.kind === 'assistant') {
    if (reasoningLive.value)
      reasoningSource.value = reasoningText.value
    if (answerLive.value)
      answerSource.value = answerText.value
    turn.reasoning = reasoningSource.value
    turn.answer = answerSource.value
    turn.reasoningStreaming = false
    turn.answerStreaming = false
    turn.stopped = true
    turn.tools = turn.tools.map(tool => tool.status === 'running' || tool.status === 'pending'
      ? { ...tool, status: 'error', errorMessage: 'Stopped' }
      : tool)
  }
  reasoningLive.value = false
  answerLive.value = false
  isStreaming.value = false
}

function beginAssistant() {
  const turn = reactive<LiveAssistant>({
    kind: 'assistant',
    id: nextId,
    at: new Date().toISOString(),
    reasoning: '',
    reasoningStreaming: true,
    tools: [],
    answer: '',
    answerStreaming: false,
    showArtifact: false,
    stopped: false,
  })
  nextId += 1
  activeAssistantId.value = turn.id
  liveTurns.value = [...liveTurns.value, turn]
  isStreaming.value = true
  reasoningSource.value = REASONING
  reasoningLive.value = true
  timers.push(window.setTimeout(() => {
    if (!isStreaming.value)
      return
    reasoningLive.value = false
    turn.reasoning = REASONING
    turn.reasoningStreaming = false
    turn.reasoningDuration = '1.2s'
    turn.tools = [{ name: 'read', target: 'middleware.ts', status: 'running' }]
  }, 700))
  timers.push(window.setTimeout(() => {
    if (!isStreaming.value)
      return
    turn.tools = [{ name: 'read', target: 'middleware.ts', status: 'complete', duration: '38ms' }]
    answerSource.value = LIVE_ANSWER
    answerLive.value = true
    turn.answerStreaming = true
  }, 1400))
  timers.push(window.setTimeout(() => {
    if (!isStreaming.value)
      return
    answerLive.value = false
    turn.answer = LIVE_ANSWER
    turn.answerStreaming = false
    turn.showArtifact = true
    isStreaming.value = false
  }, 2400))
}

function onSubmit(value: string) {
  if (isStreaming.value)
    return
  const files = attachments.value
  attachments.value = []
  liveTurns.value = [...liveTurns.value, {
    kind: 'user',
    id: nextId,
    text: value,
    files,
    at: new Date().toISOString(),
  }]
  nextId += 1
  beginAssistant()
}

onBeforeUnmount(clearTimers)
</script>

<template>
  <div class="pattern-frame">
    <input
      ref="fileInput"
      class="file-input"
      type="file"
      multiple
      tabindex="-1"
      aria-hidden="true"
      @change="onPickFiles"
    >
    <ChatLayout density="balanced" class="pattern-layout">
      <ChatMessageList :is-streaming="isStreaming">
        <ChatMessage sender="user">
          <HStack :gap="1" wrap="wrap">
            <Token label="middleware.ts" />
          </HStack>
          <ChatMessageBubble>
            <ChatTokenizedText text="@agent The JWT refresh logic seems broken." :tokens="MESSAGE_TOKENS" />
            <template #metadata>
              <ChatMessageMetadata>
                <template #timestamp>
                  <Timestamp value="2026-04-29T10:15:00" format="time" />
                </template>
              </ChatMessageMetadata>
            </template>
          </ChatMessageBubble>
        </ChatMessage>
        <ChatMessage sender="assistant">
          <template #avatar>
            <Avatar name="Agent" size="md" />
          </template>
          <ChatReasoning duration="2s">
            Validation runs before any refresh, so an expired access token never reaches the refresh call.
          </ChatReasoning>
          <ChatToolCalls
            :default-is-expanded="true"
            :calls="[
              { name: 'read', target: 'middleware.ts', status: 'complete', duration: '38ms' },
              { name: 'edit', target: 'middleware.ts', status: 'complete', duration: '85ms', additions: 8, deletions: 2 },
            ]"
          />
          <ChatMessageBubble variant="ghost">
            <Markdown density="compact" content="The catch returns `401` before `refreshToken()` runs. The token shape is specified in [jwt]." :sources="SOURCES" />
          </ChatMessageBubble>
          <ChatMessageBubble variant="ghost" width="100%">
            <ClickableCard :label="`Open ${ARTIFACT_TITLE}`" variant="muted" :padding="3" :max-width="360" @click="isArtifactOpen = true">
              <HStack :gap="3" align="center" width="100%">
                <Icon name="heroicons:document-text" size="md" color="secondary" />
                <StackItem size="fill">
                  <VStack :gap="0">
                    <Text type="label" weight="semibold">
                      {{ ARTIFACT_TITLE }}
                    </Text>
                    <Text type="supporting" color="secondary">
                      Document
                    </Text>
                  </VStack>
                </StackItem>
                <Icon name="heroicons:chevron-right" size="sm" color="secondary" />
              </HStack>
            </ClickableCard>
          </ChatMessageBubble>
        </ChatMessage>

        <template v-for="turn in liveTurns" :key="turn.id">
          <ChatMessage v-if="turn.kind === 'user'" sender="user">
            <HStack v-if="turn.files.length" :gap="1" wrap="wrap">
              <Token v-for="name in turn.files" :key="name" :label="name" />
            </HStack>
            <ChatMessageBubble>
              <ChatTokenizedText :text="turn.text" :tokens="MESSAGE_TOKENS" />
            </ChatMessageBubble>
          </ChatMessage>
          <ChatMessage v-else sender="assistant">
            <template #avatar>
              <Avatar name="Agent" size="md" />
            </template>
            <ChatReasoning
              v-if="turn.reasoningStreaming || displayedReasoning(turn)"
              :is-streaming="turn.reasoningStreaming"
              :duration="turn.reasoningDuration"
              :default-is-expanded="true"
            >
              {{ displayedReasoning(turn) }}
            </ChatReasoning>
            <ChatToolCalls v-if="turn.tools.length" :calls="turn.tools" />
            <ChatMessageBubble v-if="displayedAnswer(turn) || turn.stopped" variant="ghost">
              <Markdown
                v-if="displayedAnswer(turn)"
                density="compact"
                :content="displayedAnswer(turn)"
                :is-streaming="turn.answerStreaming"
                :sources="SOURCES"
              />
              <Text v-else type="body" color="secondary">
                Stopped before a reply was ready.
              </Text>
            </ChatMessageBubble>
            <ChatMessageBubble v-if="turn.showArtifact" variant="ghost" width="100%">
              <ClickableCard :label="`Open ${ARTIFACT_TITLE}`" variant="muted" :padding="3" :max-width="360" @click="isArtifactOpen = true">
                <HStack :gap="3" align="center" width="100%">
                  <Icon name="heroicons:document-text" size="md" color="secondary" />
                  <StackItem size="fill">
                    <Text type="label" weight="semibold">
                      {{ ARTIFACT_TITLE }}
                    </Text>
                  </StackItem>
                  <Icon name="heroicons:chevron-right" size="sm" color="secondary" />
                </HStack>
              </ClickableCard>
            </ChatMessageBubble>
          </ChatMessage>
        </template>
      </ChatMessageList>
      <template #composer>
        <ChatComposer
          :is-disabled="isStreaming"
          :is-stop-shown="isStreaming"
          :placeholder="placeholder"
          @submit="onSubmit"
          @stop="stop"
        >
          <template #input>
            <ChatComposerInput ref="composerInput" :triggers="composerTriggers" @files="addFiles" />
          </template>
          <template v-if="attachments.length" #drawer>
            <ChatComposerDrawer :count="attachments.length" label="Attachments">
              <Token v-for="name in attachments" :key="name" :label="name" @remove="removeAttachment(name)" />
            </ChatComposerDrawer>
          </template>
          <template #headerActions>
            <IconButton label="Mention" variant="ghost" size="sm" :is-disabled="isStreaming" @click="mention">
              <Icon name="heroicons:at-symbol" />
            </IconButton>
            <IconButton label="Attach" variant="ghost" size="sm" :is-disabled="isStreaming" @click="fileInputRef?.click()">
              <Icon name="heroicons:paper-clip" />
            </IconButton>
          </template>
          <template #footerActions>
            <DropdownMenu
              :button="{ label: composerMode === 'ask' ? 'Ask' : 'Edit', variant: 'ghost', size: 'sm', isDisabled: isStreaming }"
              :items="[
                { label: 'Ask', onClick: () => { composerMode = 'ask' } },
                { label: 'Edit', onClick: () => { composerMode = 'edit' } },
              ]"
            />
          </template>
        </ChatComposer>
      </template>
    </ChatLayout>

    <Dialog :is-open="isArtifactOpen" purpose="info" variant="fullscreen" @open-change="isArtifactOpen = $event">
      <Layout>
        <template #header>
          <DialogHeader
            :title="ARTIFACT_TITLE"
            subtitle="Document · Updated just now"
            has-divider
            has-close-button
            close-button-label="Close document"
            @open-change="isArtifactOpen = $event"
          />
        </template>
        <LayoutContent :padding="0">
          <Section v-if="isArtifactOpen" variant="transparent" class="artifact-scroll">
            <VStack :gap="2" class="article-body">
              <Heading :level="2">
                {{ ARTIFACT_TITLE }}
              </Heading>
              <Markdown :content="ARTIFACT_BODY" />
            </VStack>
          </Section>
        </LayoutContent>
      </Layout>
    </Dialog>
  </div>
</template>

<style scoped>
.pattern-frame {
  position: relative;
  display: flex;
  height: 100%;
  min-height: 0;
}
.pattern-layout { flex: 1; min-height: 0; min-width: 0; }
.artifact-scroll { flex: 1; overflow-y: auto; }
.article-body { max-width: 720px; margin-inline: auto; }
.file-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
</style>
```

## Pinned headers and footers

In a long region, a header scrolls out of sight and a commit button scrolls out of reach, so people scroll back and forth. `Layout` defaults to `height="fill"`: it fills its container and only the body scrolls, while the header and footer stay put.

```vue
<script setup lang="ts">
import { Button } from '@astryx-vue/core/Button'
import { Layout, LayoutContent, LayoutFooter, LayoutHeader } from '@astryx-vue/core/Layout'

const rows = [/* a long list */]
</script>

<template>
  <Layout :padding="4" default-has-dividers>
    <template #header>
      <LayoutHeader>Orders</LayoutHeader>
    </template>

    <LayoutContent>
      <OrderRow v-for="row in rows" :key="row.id" v-bind="row" />
    </LayoutContent>

    <template #footer>
      <LayoutFooter>
        <Button label="Save" />
        <Button label="Cancel" variant="secondary" />
      </LayoutFooter>
    </template>
  </Layout>
</template>
```

A few notes.

- Write `padding` once, on `Layout`. The header, body, and footer share one inline padding, so their text lines up on one vertical line.
- `defaultHasDividers` draws the line on both the header and the footer, so neither region needs its own `hasDivider`.
- A title plus one primary action goes in `LayoutHeader`. When the header carries a row of interactive controls, such as filters, sort, or bulk actions, use `Toolbar` instead; it handles arrow-key movement by the toolbar accessibility rules.
- The footer holds only the actions that must stay reachable: Save, Submit, Next. Secondary actions stay in the body.

To check it: scroll the body to the bottom. The header and footer have not moved, the dividers still run edge to edge, and the header title still shares a left edge with the first line of the body.

## Side detail panels

In a master-detail view, selecting a row usually navigates away, and coming back means finding your place again. A fixed-width panel on the end edge keeps the list in place instead.

```vue
<script setup lang="ts">
import { EmptyState } from '@astryx-vue/core/EmptyState'
import { Layout, LayoutContent, LayoutPanel } from '@astryx-vue/core/Layout'
import { ref } from 'vue'

const selected = ref<Order | null>(null)
</script>

<template>
  <Layout>
    <LayoutContent>
      <OrderRow
        v-for="row in rows"
        :key="row.id"
        v-bind="row"
        @select="selected = row"
      />
    </LayoutContent>

    <template #end>
      <LayoutPanel :width="380" has-divider is-scrollable label="Order details">
        <OrderDetail v-if="selected" :order="selected" />
        <EmptyState
          v-else
          is-compact
          title="No order selected"
          description="Pick a row on the left and its details appear here."
        />
      </LayoutPanel>
    </template>
  </Layout>
</template>
```

- A panel width between 340 and 420 works well. `width` as a number is pixels.
- `hasDivider` draws the edge facing the content. Do not draw a line on both sides.
- `isScrollable` lets long details scroll inside the panel without moving the list.
- Put an `EmptyState` in when nothing is selected. An empty panel collapses to a sliver, then the page jumps wider on the first selection.

To let people drag the width, hand `useResizable` to both the panel and a `ResizeHandle`.

```vue
<script setup lang="ts">
import { useResizable } from '@astryx-vue/core'
import { Layout, LayoutContent, LayoutPanel } from '@astryx-vue/core/Layout'
import { ResizeHandle } from '@astryx-vue/core/Resizable'

const detail = useResizable({
  defaultSize: 380,
  minSize: 320,
  maxSize: 560,
})
</script>

<template>
  <Layout>
    <LayoutContent>Body</LayoutContent>

    <template #end>
      <ResizeHandle
        direction="horizontal"
        is-reversed
        has-divider
        :resizable="detail.props"
        label="Resize the details panel"
      />
      <LayoutPanel :width="detail.size" :has-divider="false" is-scrollable label="Details">
        Detail content
      </LayoutPanel>
    </template>
  </Layout>
</template>
```

On the end edge, turn `isReversed` on so dragging left makes the panel wider. The handle carries `has-divider` and the panel turns `hasDivider` off, so one edge draws one line.

On a narrow screen, do not squeeze. Let the panel go and show the details in a `Dialog` or a `BottomSheet` instead. See [Responsive layout](/en/guide/responsive).
