# 应用模式

现代应用（尤其是 AI 产品）通常由许多部件紧密配合而成。在 astryx-vue 中，消息气泡、推理过程、工具调用（Tool Calls）、参考引用以及底部输入框等，都是一个个高内聚的独立组件，你可以根据具体场景自由组合，决定它们如何在页面上协同工作。

组件库本身专注做好前端界面与交互，不绑定任何特定的大语言模型或后端。组件的内容通过插槽自由注入，较长的文本可以直接用 Markdown 组件排版，信息来源则用引用组件标记。

## 场景一：初始落地页（无聊天记录时）

在用户还没有开始对话时，页面中间通常呈现欢迎问候语和一块功能丰富的输入区：
- 支持切换模式，比如写作、编程、深度研究或艺术创作；
- 包含安全性或思考深度的条件胶囊标记；
- 输入 `@` 可以提及人物或知识库，输入 `/` 可以呼出快捷命令；
- 支持在抽屉中添加附件，粘贴超长文本时会自动折叠为精巧的小卡片；
- 仅在浏览器支持语音录入时才露出麦克风按钮；
- 点击下方推荐的分类卡片，示例提示词会自动填入输入框。

## 场景二：对话进行中（有聊天往来时）

当对话开始后，上方是一条条自适应滚动的历史消息，底部紧跟输入区：
- 当 AI 正在生成回复时，发送按钮会自动切换为「停止生成」，当前消息状态标记为输出中；
- 思考推理过程可以先折叠或展开呈现；
- 工具调用状态会自动从「正在运行」平滑过渡到「调用完成」；
- 回答中包含的数字小角标可以直接对应到文章引用来源；
- 生成的长篇文档能够收录为卡片，点击即可在侧栏或全屏弹窗中精读。

在宽屏桌面端，整套演练场支持把参考文档放入右侧可拖拽调整宽度的侧面板中；当屏幕宽度变窄（小于 768px）时，会自动退化为全屏浮层。

## 交互演示

你可以直接在下方两套预设模式中上手试玩。完整的全屏页面可在演练场体验：[AI 对话落地页](/playground/?template=ai-chat-landing) 与 [AI 对话](/playground/?template=ai-chat)。关于各个子组件的排版密度与属性细节，可参阅 [对话组件](/components/chat)。

### 落地页模式

包含问候语、模式切换、提及、命令、附件抽屉与分类建议卡片。点击建议卡片，会自动将建议文本填入输入框并切入对应模式。

```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>
```

### 会话模式

消息流中集成了深度推理、工具调用、引用角标以及生成的文档卡片。生成过程中支持随时点击停止，点击文档卡片可在弹窗中查看。

```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>
```

## 固定页眉与页脚

一块内容很长的区域，页眉一滚就看不见，提交按钮一滚就找不到，人就得来回滚。`Layout` 的 `height` 默认是 `fill`：它撑满容器，只有正文在中间滚动，页眉和页脚留在原地。

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

const rows = [/* 很长的列表 */]
</script>

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

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

    <template #footer>
      <LayoutFooter>
        <Button label="保存" />
        <Button label="取消" variant="secondary" />
      </LayoutFooter>
    </template>
  </Layout>
</template>
```

几点：

- `padding` 只写在 `Layout` 上，页眉、正文、页脚共用同一个左右内边距，文字对齐同一条竖线。
- `defaultHasDividers` 一次给页眉和页脚都画线，不用两个区域各写一个 `hasDivider`。
- 页眉里放的是标题加一个主操作，用 `LayoutHeader`。页眉里要放一排可交互的控件（筛选、排序、批量操作），改用 `Toolbar`，它按工具栏的无障碍规则处理方向键。
- 页脚只放必须一直够得着的动作：保存、提交、下一步。次要动作留在正文里。

验证方法：把正文滚到底。页眉和页脚没动，分隔线还是通到两边，页眉标题和正文第一行的左边缘仍然对齐。

## 侧边详情面板

主从结构里，选中一行就跳走一个页面，人回来还得重新找位置。更好的做法是右边开一块固定宽度的面板，列表留着不动。

```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="订单详情">
        <OrderDetail v-if="selected" :order="selected" />
        <EmptyState
          v-else
          is-compact
          title="还没有选择订单"
          description="在左边点一行，这里会显示它的详情。"
        />
      </LayoutPanel>
    </template>
  </Layout>
</template>
```

- 面板宽度建议在 340 到 420 之间。`width` 写数字按像素。
- `hasDivider` 画出朝内容的那条边，两边不要都画线。
- `isScrollable` 让长详情自己滚，列表不受影响。
- 没选中任何行时放一个 `EmptyState`。空着会让面板塌成一条缝，选中时页面又突然变宽。

要让用户自己拖宽度，把 `useResizable` 拖到面板和 `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>正文</LayoutContent>

    <template #end>
      <ResizeHandle
        direction="horizontal"
        is-reversed
        has-divider
        :resizable="detail.props"
        label="调整详情面板宽度"
      />
      <LayoutPanel :width="detail.size" :has-divider="false" is-scrollable label="详情">
        详情内容
      </LayoutPanel>
    </template>
  </Layout>
</template>
```

结束边的面板要把 `isReversed` 打开，往左拖才是变宽。分隔条本身要 `has-divider`，面板则关掉 `hasDivider`，一条边上只留一条线。

窄屏时不要硬挤。宽度不够就让面板退场，改用 `Dialog` 或 `BottomSheet` 展示详情，具体做法见 [响应式布局](/guide/responsive)。
