Эх сурвалжийг харах

对齐 Bot 模式的会话缓存、增量同步与长列表滚动

Samien 1 сар өмнө
parent
commit
346847d394

+ 27 - 0
docs/bot-transcript-parity.md

@@ -0,0 +1,27 @@
+# Bot 模式会话加载与滚动对齐
+
+分支:`codex/bot-transcript-parity`。参考 OpenMausBot 当前本地版本 `9d2a992c`,本次只调整 Bot 模式。
+
+## 行为
+
+- 新增 `/api/app/workspace/snapshot`:一次读取 Bot、会话及各活跃会话最近 50 条消息,带一致的事件游标和 `hasOlder`。
+- 新增 `/api/app/events/stream`:账号隔离的 SSE,持久事件重放、断线续接、撤销授权检查、慢连接关闭和游标重置恢复。原消息读取与事件轮询接口保留兼容。
+- Web 缓存按会话和任务保留;切换直接显示已加载内容。实时事件直接更新对应实体,按动画帧发布,不再每 1.2 秒重新拉取列表和最近 100 条消息。
+- 快照请求和历史分页会重放请求期间的新事件,防止旧响应覆盖新文字或恢复已隐藏、已删除内容;切换账号清除缓存和事件流。
+- Bot 使用独立 `WorkspaceMessageTimeline.vue`,默认挂载尾部 120 条,按需展开,保留稳定消息对象,补偿历史插入位置,并依据用户手势控制跟随底部。
+- 普通聊天继续使用原 `MessageTimeline.vue` 和原实时协议。
+
+## 跨端
+
+iOS、Android 同名分支接入快照、缓存和 SSE。iOS 使用系统导航推进;首次打开会话有顶部胶囊展开及头像回落,时长采用 OpenMausBot 的 40ms 起始延迟、0.5/0.55 秒弹簧、1 秒停留和 600ms 收尾;减少动态效果时跳过。旧服务器不声明新能力时,移动端继续使用兼容路径。
+
+iOS 保留 UICollectionView 复用并缩小行更新依赖,时间分隔直接读时间戳,避免重复完整消息转换。Android 保留 LazyColumn,缓存每行正文转换,增加真正的底部锚点并区分用户上滑和内容增长。
+
+## 验证
+
+- Web/服务端:126 项定向测试通过;生产构建及类型检查通过。
+- Playwright:11 项通过,覆盖普通聊天回归、Bot 单聊/群聊、未读跨端、权限、附件、流式 Markdown;新增 300 条消息场景验证缓存切换无详情 GET、返回仅挂载 120 条、历史和实时更新不移动阅读位置、375px 深色与减少动态效果。
+- iOS:43 项单元测试通过;系统推进与交互返回、自动分页历史两项模拟器 UI 测试通过。
+- Android:core/model 50 项、core/network 41 项通过;Debug APK 和 lintDebug 通过。API 36 模拟器两次启动均在 QEMU 引擎挂起后崩溃,故本轮未完成 Android 仪器化 UI 验证。
+
+本轮未发布或部署,也未声称已完成与 OpenMausBot 同设备同数据的帧率、耗时对照基准。验证覆盖实现行为与既有功能回归。

+ 542 - 0
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -0,0 +1,542 @@
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import AgentAvatar from '@/components/common/AgentAvatar.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import InteractionCard from '../messages/InteractionCard.vue'
+import MarkdownContent from '../messages/MarkdownContent.vue'
+import ToolTrace from '../messages/ToolTrace.vue'
+import TurnTrace from '../messages/TurnTrace.vue'
+import type { UiInteraction, UiLocalFileLink, UiMessage } from '../messages/types'
+import { buildMessageTimelineRows } from '@/utils/turnTrace'
+import { copyTextToClipboard } from '@/utils/clipboard'
+import { displayContentForMessage } from '@/utils/messageDisplay'
+import { formatMessageTime } from '@/utils/messageTime'
+
+const props = withDefaults(defineProps<{
+  messages: UiMessage[]
+  identity: string
+  title?: string
+  subtitle?: string
+  emptyTitle?: string
+  emptyDescription?: string
+  emptyLogo?: boolean
+  transparentHeader?: boolean
+  forkSourceTitle?: string
+  loading?: boolean
+  loadingOlder?: boolean
+  hasOlder?: boolean
+  connected?: boolean
+  synced?: boolean
+  showTools?: boolean
+  showAssistantIdentity?: boolean
+  interaction?: UiInteraction | null
+  mentionNames?: string[]
+  agentAvatars?: Record<string, string>
+  agentStates?: Record<string, 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying'>
+  thinking?: boolean
+  allowBranch?: boolean
+  readOnly?: boolean
+}>(), {
+  title: '',
+  subtitle: '',
+  emptyTitle: '开始一段新对话',
+  emptyDescription: '从下方输入框发送消息,或选择左侧的历史会话。',
+  emptyLogo: false,
+  transparentHeader: false,
+  forkSourceTitle: '',
+  loading: false,
+  loadingOlder: false,
+  hasOlder: false,
+  connected: false,
+  synced: false,
+  showTools: true,
+  showAssistantIdentity: true,
+  interaction: null,
+  mentionNames: () => [],
+  agentAvatars: () => ({}),
+  agentStates: () => ({}),
+  thinking: false,
+  allowBranch: true,
+  readOnly: false,
+})
+
+const emit = defineEmits<{
+  loadOlder: []
+  quote: [message: UiMessage]
+  branch: [message: UiMessage]
+  preview: [attachment: NonNullable<UiMessage['attachments']>[number]]
+  previewFile: [file: UiLocalFileLink]
+  approve: [approved: boolean]
+  clarify: [text: string]
+}>()
+
+const scroller = ref<HTMLElement | null>(null)
+const pinnedToBottom = ref(true)
+const showJump = ref(false)
+const copiedMessageId = ref('')
+const copyFailedMessageId = ref('')
+const thinkingElapsedMs = ref(0)
+// Like OpenMausBot, mount the tail first and explicitly expand older content.
+const windowStart = ref(0)
+const windowSize = 120
+const windowedMessages = computed(() => props.messages.slice(windowStart.value))
+const timelineRows = computed(() => buildMessageTimelineRows(windowedMessages.value))
+const mentionSignature = computed(() => props.mentionNames.join("\u0000"))
+let transcriptObserver: ResizeObserver | undefined
+let followFrame: number | undefined
+let previousTop = 0
+let touchY = 0
+let historyAnchor: { id: string; top: number } | undefined
+function stopFollowing() { pinnedToBottom.value = false }
+function captureHistoryAnchor() {
+  const el = scroller.value
+  const row = el && [...el.querySelectorAll<HTMLElement>('[data-message-id]')].find(row => row.getBoundingClientRect().bottom > el.getBoundingClientRect().top)
+  if (row) historyAnchor = { id: row.dataset.messageId!, top: row.getBoundingClientRect().top }
+  stopFollowing()
+}
+async function restoreHistoryAnchor() {
+  await nextTick()
+  const el = scroller.value, anchor = historyAnchor
+  if (!el || !anchor) return
+  const row = el.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(anchor.id)}"]`)
+  if (row) el.scrollTop += row.getBoundingClientRect().top - anchor.top
+  previousTop = el.scrollTop; historyAnchor = undefined
+}
+async function showEarlier() {
+  captureHistoryAnchor()
+  if (windowStart.value > 0) { windowStart.value = Math.max(0, windowStart.value - windowSize); await restoreHistoryAnchor() }
+  else emit('loadOlder')
+}
+watch(() => props.identity, () => {
+  windowStart.value = Math.max(0, props.messages.length - windowSize)
+  pinnedToBottom.value = true; showJump.value = false; historyAnchor = undefined
+  void nextTick(() => scheduleFollow())
+}, { immediate: true })
+watch(() => props.messages[0]?.id, (id, oldId) => {
+  if (!oldId || id === oldId) return
+  const offset = props.messages.findIndex(message => message.id === oldId)
+  if (offset > 0 && windowStart.value > 0) windowStart.value += offset
+  if (historyAnchor) void restoreHistoryAnchor()
+})
+function scheduleFollow() {
+  if (!pinnedToBottom.value || followFrame !== undefined) return
+  followFrame = requestAnimationFrame(() => {
+    followFrame = undefined
+    if (pinnedToBottom.value) scrollToBottom('auto')
+  })
+}
+const showThinkingIndicator = computed(() => {
+  if (!props.thinking) return false
+  const current = [...props.messages].reverse().find(message => message.role === 'assistant' && message.status === 'streaming')
+  // A tool trace is progress detail, not assistant prose. Keep the bottom
+  // activity indicator visible while that tool-only stream segment is active.
+  return !current?.content.trim() && !current?.reasoning?.trim()
+})
+let copyResetTimer: number | undefined
+let thinkingTimer: number | undefined
+let thinkingStartedAt = 0
+
+const formatTime = formatMessageTime
+
+function avatarState(message: UiMessage): 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying' {
+  if (message.status === 'streaming') return (message.profile && props.agentStates[message.profile]) || 'working'
+  if (message.status === 'pending' || message.status === 'unknown-receipt') return 'waiting'
+  if (message.status === 'failed') return 'failure'
+  const outcome = message.profile ? props.agentStates[message.profile] : undefined
+  if ((outcome === 'success' || outcome === 'failure') && [...props.messages].reverse().find(m => m.profile === message.profile && m.role === 'assistant')?.id === message.id) return outcome
+  return 'idle'
+}
+
+function delegationSummary(metadata?: Record<string, unknown>): string {
+  const total = Number(metadata?.task_count ?? 0)
+  const completed = Number(metadata?.completed_count ?? 0)
+  const failed = Number(metadata?.failed_count ?? 0)
+  const seconds = Number(metadata?.duration_seconds ?? 0)
+  const duration = seconds > 0 ? ` · ${seconds < 60 ? `${Math.round(seconds)} 秒` : `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`}` : ''
+  return `${total || completed + failed || 1} 个子任务 · ${completed} 已完成${failed ? ` · ${failed} 失败` : ''}${duration}`
+}
+
+function systemSummary(content: string): string {
+  const model = content.match(/(?:changed to|切换为)\s*([^\s,。\]]+?)(?=\s+via\b|\s*$|\])/i)?.[1]
+  if (model) return `模型已切换:${model}`
+  return '系统信息'
+}
+
+function backgroundProcessSummary(metadata?: Record<string, unknown>): string {
+  const exitCode = Number(metadata?.exit_code)
+  const signal = typeof metadata?.signal === 'string' ? metadata.signal : ''
+  if (exitCode === 0) return '后台子任务已完成'
+  if (signal) return `后台子任务已终止 · ${signal}`
+  return Number.isFinite(exitCode) ? `后台子任务失败 · 退出码 ${exitCode}` : '后台子任务已结束'
+}
+
+function isCompactionEvent(message: UiMessage): boolean {
+  return message.timelineMetadata?.eventKind === 'compaction'
+    || message.content.trim().toLocaleLowerCase().startsWith('[context compaction')
+}
+
+function onScroll() {
+  const el = scroller.value
+  if (!el) return
+  const distance = el.scrollHeight - el.scrollTop - el.clientHeight
+  // Content growth is not a user gesture. Only a downward return to the end re-arms follow.
+  if (el.scrollTop > previousTop && distance < 80) pinnedToBottom.value = true
+  previousTop = el.scrollTop
+  showJump.value = !pinnedToBottom.value && distance > 100
+}
+function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
+  const el = scroller.value
+  if (!el) return
+  pinnedToBottom.value = true
+  el.scrollTo({ top: el.scrollHeight, behavior })
+  previousTop = el.scrollTop
+}
+function onMarkdownRendered() { scheduleFollow() }
+
+function scrollToMessage(id: string): boolean {
+  const root = scroller.value
+  if (!root) return false
+  const message = root.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
+  if (!message) {
+    const index = props.messages.findIndex(message => message.id === id)
+    if (index < 0) return false
+    stopFollowing(); windowStart.value = Math.max(0, index - Math.floor(windowSize / 2))
+    void nextTick(() => scrollToMessage(id)); return true
+  }
+  stopFollowing()
+  message.scrollIntoView({ block: 'center', behavior: 'smooth' })
+  message.classList.remove('message--revealed')
+  requestAnimationFrame(() => message.classList.add('message--revealed'))
+  window.setTimeout(() => message.classList.remove('message--revealed'), 1800)
+  return true
+}
+
+function scrollToAnchor(messageId: string, anchorId: string): boolean {
+  const root = scroller.value
+  if (!root) return false
+  const message = root.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(messageId)}"]`)
+  if (!message) { const index = props.messages.findIndex(m => m.id === messageId); if (index < 0) return false; stopFollowing(); windowStart.value = Math.max(0, index - 60); void nextTick(() => scrollToAnchor(messageId, anchorId)); return true }
+  stopFollowing()
+  const target = anchorId ? message.querySelector<HTMLElement>(`#${CSS.escape(anchorId)}`) : null
+  ;(target || message).scrollIntoView({ block: target ? 'start' : 'center', behavior: 'smooth' })
+  message.classList.remove('message--revealed')
+  requestAnimationFrame(() => message.classList.add('message--revealed'))
+  window.setTimeout(() => message.classList.remove('message--revealed'), 1800)
+  return true
+}
+
+async function copyMessage(message: UiMessage) {
+  const text = displayContentForMessage(message.role, message.content)
+  const copied = await copyTextToClipboard(text)
+  copiedMessageId.value = copied ? message.id : ''
+  copyFailedMessageId.value = copied ? '' : message.id
+  if (copyResetTimer !== undefined) window.clearTimeout(copyResetTimer)
+  copyResetTimer = window.setTimeout(() => {
+    copiedMessageId.value = ''
+    copyFailedMessageId.value = ''
+    copyResetTimer = undefined
+  }, 1400)
+}
+
+function hasConversationActions(message: UiMessage): boolean {
+  return message.role === 'user' || (message.role === 'assistant' && Boolean(message.content.trim()))
+}
+
+function deliveryLabel(status?: UiMessage['status']): string {
+  return ({
+    preparing: '正在准备附件',
+    attached: '附件已就绪',
+    pending: '正在发送',
+    accepted: '已发送',
+    failed: '发送失败',
+    'unknown-receipt': '回执未知,请检查后重试',
+  } as Partial<Record<NonNullable<UiMessage['status']>, string>>)[status || 'settled'] || ''
+}
+
+function formatThinkingElapsed(value: number): string {
+  const seconds = Math.max(0, Math.floor(value / 1000))
+  return `${seconds} 秒`
+}
+
+watch(() => props.messages.length, async () => {
+  if (!pinnedToBottom.value) return
+  await nextTick()
+  scheduleFollow()
+})
+
+watch(() => props.messages.at(-1)?.content, async () => {
+  if (!pinnedToBottom.value) return
+  await nextTick()
+  scheduleFollow()
+})
+
+watch(showThinkingIndicator, visible => {
+  if (thinkingTimer !== undefined) window.clearInterval(thinkingTimer)
+  thinkingTimer = undefined
+  if (!visible) {
+    thinkingElapsedMs.value = 0
+    return
+  }
+  thinkingStartedAt = Date.now()
+  thinkingTimer = window.setInterval(() => { thinkingElapsedMs.value = Date.now() - thinkingStartedAt }, 250)
+}, { immediate: true })
+
+onMounted(() => nextTick(() => {
+  scheduleFollow()
+  transcriptObserver = new ResizeObserver(scheduleFollow)
+  const stack = scroller.value?.querySelector('.message-stack')
+  if (stack) transcriptObserver.observe(stack)
+}))
+watch(() => props.messages.length, () => nextTick(() => {
+  const stack = scroller.value?.querySelector('.message-stack')
+  if (stack) transcriptObserver?.observe(stack)
+}))
+onBeforeUnmount(() => {
+  transcriptObserver?.disconnect()
+  if (followFrame !== undefined) cancelAnimationFrame(followFrame)
+  if (copyResetTimer !== undefined) window.clearTimeout(copyResetTimer)
+  if (thinkingTimer !== undefined) window.clearInterval(thinkingTimer)
+})
+
+defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBottom: () => pinnedToBottom.value })
+</script>
+
+<template>
+  <section class="timeline-frame" :class="{ 'timeline-frame--transparent': transparentHeader }">
+    <header v-if="title || transparentHeader" class="timeline-header" :class="{ 'timeline-header--transparent': transparentHeader }">
+      <slot name="header-leading" />
+      <div v-if="!transparentHeader"><h2>{{ title }}</h2><p v-if="subtitle">{{ subtitle }}</p></div>
+      <div v-if="!transparentHeader" class="timeline-state" :title="connected ? (synced ? '实时连接与历史均已同步' : '实时连接已就绪,历史同步中') : '实时连接未就绪'">
+        <span class="status-dot" :class="connected ? (synced ? 'status-dot--online' : 'status-dot--working') : ''" />
+        {{ connected ? (synced ? '已同步' : '同步中') : '离线' }}
+      </div>
+      <slot name="header-actions" />
+    </header>
+
+    <div ref="scroller" class="timeline" @scroll.passive="onScroll"
+      @wheel.passive="event => { if (event.deltaY < 0) stopFollowing() }"
+      @touchstart.passive="event => { touchY = event.touches[0]?.clientY ?? 0 }"
+      @touchmove.passive="event => { const y = event.touches[0]?.clientY ?? touchY; if (y > touchY) stopFollowing(); touchY = y }"
+      @pointerdown="event => { const el = scroller; if (el && event.clientX >= el.getBoundingClientRect().right - 18) stopFollowing() }"
+      @keydown="event => { if (['PageUp', 'Home', 'ArrowUp'].includes(event.key)) stopFollowing() }">
+      <div v-if="loading && !messages.length" class="message-skeletons" aria-label="正在加载消息">
+        <div v-for="n in 4" :key="n" :class="{ user: n % 2 === 0 }"><i /><span /></div>
+      </div>
+      <div v-else-if="!messages.length && emptyLogo" class="new-chat-empty">
+        <img class="new-chat-empty__logo" src="/brand/AppIcon-1024.png" alt="" aria-hidden="true" />
+        <p>聊点什么</p>
+      </div>
+      <EmptyState v-else-if="!messages.length" icon="chat" :title="emptyTitle" :description="emptyDescription" />
+      <div v-else class="message-stack">
+        <div v-if="forkSourceTitle" class="fork-divider" role="separator">
+          <span><AppIcon name="branch" :size="13" />FORK 来源 <strong>{{ forkSourceTitle }}</strong></span>
+        </div>
+        <button v-if="hasOlder || windowStart > 0" class="load-older" type="button" :disabled="loadingOlder" @click="showEarlier">
+          {{ loadingOlder ? '正在加载…' : '加载更早消息' }}
+        </button>
+
+        <template v-for="row in timelineRows" :key="row.id">
+        <TurnTrace v-if="row.kind === 'trace' && showTools" :group="row" />
+        <template v-else-if="row.kind === 'message'">
+        <article
+          v-for="message in [row.message]"
+          :key="message.id"
+          v-memo="[message, showTools, showAssistantIdentity, mentionSignature, agentAvatars[message.profile || ''], avatarState(message), copiedMessageId === message.id, copyFailedMessageId === message.id, readOnly, allowBranch]"
+          :data-message-id="message.id"
+          class="message"
+          :class="[`message--${message.role}`, {
+            'message--failed': message.status === 'failed',
+            'message--tool-only': message.role === 'assistant' && !message.content && !message.reasoning && Boolean(message.tools?.length),
+            'message--assistant-internal': message.role === 'assistant' && !message.content.trim(),
+            'message--assistant-anonymous': message.role === 'assistant' && !showAssistantIdentity,
+          }]"
+        >
+          <AgentAvatar v-if="message.role !== 'user' && (message.role !== 'assistant' || showAssistantIdentity)" class="message__avatar" :name="message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')" :avatar="message.profile ? agentAvatars[message.profile] || '' : ''" :size="27" :state="avatarState(message)" />
+          <div class="message__body">
+            <template v-if="message.timelineKind === 'delegation-complete'">
+              <details class="delegation-event">
+                <summary><AppIcon name="groups" :size="14" /><span><strong>子任务已完成</strong><small>{{ delegationSummary(message.timelineMetadata) }}</small></span></summary>
+                <MarkdownContent process-content v-if="message.content" :content="message.content" />
+              </details>
+            </template>
+            <template v-else-if="message.timelineKind === 'background-process'">
+              <details class="system-event background-process-event">
+                <summary><AppIcon name="tools" :size="13" /><span>{{ backgroundProcessSummary(message.timelineMetadata) }}</span></summary>
+                <MarkdownContent process-content v-if="message.content" :content="message.content" />
+              </details>
+            </template>
+            <template v-else-if="message.timelineKind === 'system' && isCompactionEvent(message)">
+              <details class="delegation-event compaction-event">
+                <summary><AppIcon name="archive" :size="14" /><span><strong>上下文已压缩</strong><small>压缩摘要已归档 · 点击查看</small></span></summary>
+                <MarkdownContent process-content v-if="message.content" :content="message.content" />
+              </details>
+            </template>
+            <template v-else-if="message.timelineKind === 'system'">
+              <details class="system-event">
+                <summary><AppIcon name="settings" :size="13" /><span>{{ systemSummary(message.content) }}</span></summary>
+                <MarkdownContent process-content v-if="message.content" :content="message.content" />
+              </details>
+            </template>
+            <template v-else>
+            <div v-if="message.role !== 'assistant' || showAssistantIdentity" class="message__meta">
+              <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? '机器人' : '系统') }}</strong>
+              <span v-if="message.metadata" class="message__execution">{{ message.metadata }}</span>
+              <time>{{ formatTime(message.createdAt) }}</time>
+              <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: '发送失败', 'unknown-receipt': '回执未知' }[message.status] }}</span>
+            </div>
+
+            <details v-if="message.reasoning" class="message__reasoning">
+              <summary><AppIcon name="brain" :size="13" />思考过程 · {{ message.reasoning.length }} 字</summary>
+              <MarkdownContent process-content :content="message.reasoning" :streaming="message.status === 'streaming'" @rendered="onMarkdownRendered" />
+            </details>
+
+            <div v-if="message.role === 'user' && message.attachments?.length" class="message__attachments">
+              <button v-for="attachment in message.attachments" :key="attachment.id" type="button" :class="{ 'message__attachment--image': attachment.kind === 'image' && attachment.url }" @click="emit('preview', attachment)">
+                <img v-if="attachment.kind === 'image' && attachment.url" :src="attachment.url" :alt="attachment.name" />
+                <span v-else><AppIcon :name="attachment.kind || 'file'" :size="17" /></span>
+                <strong v-if="attachment.kind !== 'image' || !attachment.url">{{ attachment.name }}</strong>
+              </button>
+            </div>
+
+            <div class="message__content">
+              <MarkdownContent
+                :file-profile="message.profile"
+                :content="displayContentForMessage(message.role, message.content)"
+                :streaming="message.status === 'streaming'"
+                :legacy-media="message.role === 'assistant'"
+                :plain="message.role === 'user'"
+                :mention-names="mentionNames"
+                :outline-prefix="message.role === 'assistant' ? `outline-${message.id}` : ''"
+                :file-cards="message.role === 'user' || message.role === 'assistant'"
+                :process-content="message.role !== 'user' && message.role !== 'assistant'"
+                @rendered="onMarkdownRendered"
+                @file-link="(name, url) => emit('previewFile', { name, url })"
+              />
+            </div>
+
+            <div
+              v-if="message.role === 'user' && deliveryLabel(message.status)"
+              class="message__delivery"
+              :class="`message__delivery--${message.status}`"
+              role="status"
+              aria-live="polite"
+            >
+              <AppIcon :name="message.status === 'accepted' ? 'check' : message.status === 'failed' || message.status === 'unknown-receipt' ? 'alert' : 'dots'" :size="11" />
+              {{ deliveryLabel(message.status) }}
+            </div>
+
+            <div v-if="message.role === 'assistant' && message.attachments?.length" class="message__attachments">
+              <button v-for="attachment in message.attachments" :key="attachment.id" type="button" @click="emit('preview', attachment)">
+                <img v-if="attachment.kind === 'image' && attachment.url" :src="attachment.url" :alt="attachment.name" />
+                <span v-else><AppIcon :name="attachment.kind || 'file'" :size="17" /></span>
+                <strong>{{ attachment.name }}</strong>
+              </button>
+            </div>
+
+            <div v-if="showTools && message.tools?.length" class="message__tools">
+              <ToolTrace v-for="tool in message.tools" :key="tool.id" :tool="tool" />
+            </div>
+
+            <p v-if="message.error" class="message__error"><AppIcon name="alert" :size="13" />{{ message.error }}</p>
+
+            <div v-if="hasConversationActions(message)" class="message__actions">
+              <time v-if="message.role === 'assistant' && !showAssistantIdentity && message.createdAt" class="message__action-time">{{ formatTime(message.createdAt) }}</time>
+              <button
+                type="button"
+                :class="{
+                  'message-action--copied': copiedMessageId === message.id,
+                  'message-action--copy-failed': copyFailedMessageId === message.id,
+                }"
+                :title="copiedMessageId === message.id ? '已复制' : copyFailedMessageId === message.id ? '复制失败' : '复制'"
+                :aria-label="copiedMessageId === message.id ? '已复制' : copyFailedMessageId === message.id ? '复制失败' : '复制消息'"
+                @click="copyMessage(message)"
+              ><AppIcon :name="copiedMessageId === message.id ? 'check' : copyFailedMessageId === message.id ? 'alert' : 'copy'" :size="13" /></button>
+              <button v-if="!readOnly" type="button" title="引用" aria-label="引用消息" @click="emit('quote', message)"><AppIcon name="quote" :size="13" /></button>
+              <button v-if="!readOnly && allowBranch && message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
+            </div>
+            </template>
+          </div>
+        </article>
+        </template>
+        </template>
+
+        <div v-if="showThinkingIndicator" class="thinking-indicator" role="status" aria-live="polite" aria-label="正在思考">
+          <span><strong>正在思考</strong><small>{{ formatThinkingElapsed(thinkingElapsedMs) }}</small></span>
+        </div>
+
+        <InteractionCard
+          v-if="interaction"
+          :interaction="interaction"
+          @approve="emit('approve', $event)"
+          @clarify="emit('clarify', $event)"
+        />
+      </div>
+    </div>
+
+    <Transition name="view-fade">
+      <button v-if="showJump" class="jump-bottom" type="button" @click="scrollToBottom()">
+        <AppIcon name="chevron-down" :size="15" />回到底部
+      </button>
+    </Transition>
+  </section>
+</template>
+
+<style scoped>
+.timeline-frame { position: relative; display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
+.timeline-header { display: flex; z-index: 6; min-height: 62px; align-items: center; gap: 13px; padding: 10px 18px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--canvas) 92%, transparent); backdrop-filter: blur(16px); }
+.timeline-header--transparent { position: absolute; top: 0; right: 0; left: 0; min-height: 0; justify-content: flex-end; padding: 11px 14px; border: 0; background: transparent; backdrop-filter: none; pointer-events: none; }
+.timeline-header--transparent > * { pointer-events: auto; }
+.timeline-header > div:first-child { min-width: 0; flex: 1; }.timeline-header h2 { overflow: hidden; margin: 0; font-size: 14px; font-weight: 630; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; }.timeline-header p { overflow: hidden; margin: 3px 0 0; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.timeline-state { display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 9px; }
+.timeline { min-height: 0; flex: 1; overflow-y: auto; overflow-anchor: none; overscroll-behavior: contain; background: var(--conversation-canvas); }
+.message-stack { width: min(780px, 100%); min-height: 100%; margin: 0 auto; padding: 24px 24px 36px; }
+.timeline-frame--transparent .message-stack { padding-top: 58px; }
+.load-older { display: block; margin: 0 auto 18px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; }.load-older:hover { background: var(--surface-soft); color: var(--text-secondary); }
+.message { position: relative; display: flex; max-width: 100%; gap: 10px; margin: 0 0 21px; }
+.message__avatar { display: grid; place-items: center; width: 27px; height: 27px; flex: 0 0 27px; background: transparent; color: var(--text-secondary); font-size: 11px; font-weight: 700; }
+.message__body { position: relative; min-width: 0; max-width: min(680px, calc(100% - 37px)); }
+.message__meta { display: flex; min-height: 20px; align-items: center; gap: 7px; color: var(--text-muted); font-size: 10px; }.message__meta strong { display: inline-flex; align-items: center; color: var(--text-primary); font-size: 12px; font-weight: 600; }.message__remote-agent { margin-right: 4px; color: var(--accent); }.message__meta span { color: var(--warning); }.message__meta .message__execution { overflow: hidden; max-width: 230px; color: var(--text-secondary); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
+.message__content { color: var(--text-primary); }
+.message__content :deep(.plain-text), .message__content :deep(.markdown) { font-size: 15px; }
+.message__content :deep(.code-lang), .message__content :deep(.code-copy) { font-size: 10px; }
+.message__content :deep(.code-block code) { font-size: 12px; }
+.message__content :deep(.file-link-card)::after { font-size: 10px; }
+.message--user { justify-content: flex-end; margin-top: 27px; }
+.message--user .message__body { max-width: min(610px, 76%); padding: 11px 15px; border-radius: 20px 20px 6px 20px; background: #eceff3; }
+.dark .message--user .message__body { background: #2d323a; }
+.message--user .message__meta { display: none; }
+.message__delivery { display: flex; min-height: 18px; align-items: center; justify-content: flex-end; gap: 4px; margin: 5px 1px -4px; color: var(--text-muted); font-size: 10px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
+.thinking-indicator { display: flex; min-height: 34px; align-items: center; margin: 5px 0 20px; color: var(--text-muted); }.thinking-indicator > span { display: inline-flex; align-items: baseline; gap: 7px; }.thinking-indicator strong { color: var(--text-secondary); font-size: 12px; font-weight: 600; background: linear-gradient(90deg, var(--text-muted), var(--text-primary), var(--text-muted)); background-size: 200% 100%; background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; animation: thinking-shimmer 2.2s linear infinite; }.thinking-indicator small { font-size: 10px; font-variant-numeric: tabular-nums; }
+@keyframes thinking-shimmer { to { background-position: -200% 0; } }
+.message--system { justify-content: center; }.message--system .message__avatar, .message--system .message__meta { display: none; }.message--system .message__body { max-width: 82%; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; color: var(--text-muted); text-align: center; }
+.delegation-event { min-width: 230px; max-width: 460px; text-align: left; }.delegation-event summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; }.delegation-event summary::-webkit-details-marker { display: none; }.delegation-event summary > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; }.delegation-event strong { color: var(--text-secondary); font-size: 12px; font-weight: 650; }.delegation-event small { color: var(--text-muted); font-size: 10px; }.delegation-event :deep(.markdown) { max-height: 260px; margin-top: 9px; overflow: auto; color: var(--text-secondary); font-size: 11px; }
+.system-event { max-width: 460px; text-align: left; }.system-event summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; color: var(--text-muted); font-size: 11px; }.system-event summary::-webkit-details-marker { display: none; }.system-event :deep(.markdown) { max-height: 220px; margin-top: 8px; overflow: auto; color: var(--text-secondary); font-size: 11px; }
+.message--tool-only { margin-block: 4px; }.message--tool-only .message__avatar, .message--tool-only .message__meta, .message--tool-only .message__actions { display: none; }.message--tool-only .message__body { max-width: 100%; }
+.message--assistant-internal { margin-bottom: 6px; }.message--assistant-internal .message__reasoning { margin-bottom: 4px; padding-bottom: 4px; }.message--assistant-internal .message__tools { margin-top: 4px; }
+.message--assistant-anonymous .message__body { max-width: min(680px, 100%); }
+.message__reasoning { max-width: 420px; margin: 3px 0 10px; padding: 0 0 8px; border: 0; border-bottom: 1px dashed var(--line-strong); color: var(--text-secondary); font-size: 12px; }.message__reasoning summary { display: flex; align-items: center; gap: 5px; list-style: none; color: var(--text-muted); cursor: pointer; font-size: 10px; }.message__reasoning summary::-webkit-details-marker { display: none; }.message__reasoning summary::before { content: '›'; color: var(--text-muted); font-size: 14px; line-height: 1; transition: transform 120ms ease; }.message__reasoning[open] summary::before { transform: rotate(90deg); }.message__reasoning :deep(.markdown) { margin-top: 8px; }
+.fork-divider { display: flex; align-items: center; gap: 12px; margin: 8px 0 28px; color: var(--text-muted); font-size: 11px; }.fork-divider::before, .fork-divider::after { height: 1px; flex: 1; background: var(--line); content: ''; }.fork-divider > span { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); white-space: nowrap; }.fork-divider strong { max-width: 180px; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }
+.message__attachments { display: flex; margin-top: 8px; flex-wrap: wrap; gap: 7px; }.message__attachments button { display: flex; min-width: 90px; max-width: 210px; min-height: 42px; align-items: center; gap: 7px; padding: 5px 8px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-secondary); cursor: pointer; }.message__attachments button:hover { border-color: var(--line-strong); }.message__attachments img { width: 48px; height: 48px; margin: -5px 0 -5px -8px; object-fit: cover; }.message__attachments button > span { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 7px; background: var(--surface-soft); }.message__attachments strong { min-width: 0; overflow: hidden; font-size: 11px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }
+.message--user .message__attachments { margin: 0 0 9px; }.message--user .message__attachments button { width: 100%; max-width: none; min-height: 50px; padding: 7px 9px; border: 0; border-radius: 11px; background: rgba(255,255,255,.48); color: var(--text-primary); }.message--user .message__attachments button > span { width: 28px; height: 28px; background: rgba(255,255,255,.65); }.message--user .message__attachments strong { font-size: 12px; font-weight: 560; }.dark .message--user .message__attachments button { background: rgba(255,255,255,.08); }.message--user .message__attachments button.message__attachment--image { display: block; width: auto; min-width: 0; max-width: min(360px, 100%); min-height: 0; padding: 0; border: 0; border-radius: 8px; background: transparent; }.message--user .message__attachments button.message__attachment--image img { display: block; width: auto; height: auto; max-width: min(360px, 100%); max-height: 420px; margin: 0; border-radius: inherit; object-fit: contain; }
+.message__tools { margin-top: 9px; }.message__error { display: flex; align-items: center; gap: 5px; margin: 7px 0 0; color: var(--danger); font-size: 10px; }
+.message__actions { display: flex; position: absolute; left: -3px; top: 100%; gap: 2px; padding-top: 2px; opacity: 0; transition: opacity 120ms ease; }.message:hover .message__actions, .message:focus-within .message__actions { opacity: 1; }.message--user .message__actions { right: 0; left: auto; }.message__actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.message__actions button:hover { background: var(--surface-soft); color: var(--text-primary); }.message__actions .message-action--copied { color: var(--success); }.message__actions .message-action--copy-failed { color: var(--danger); }
+.message--assistant-anonymous .message__actions { align-items: center; }.message__action-time { min-width: 36px; padding: 0 4px 0 3px; color: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
+.message--failed .message__body { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); }
+.message--revealed .message__body { animation: reveal-message 1.8s ease; }
+@keyframes reveal-message { 0%, 28% { box-shadow: 0 0 0 5px var(--focus-ring); } 100% { box-shadow: 0 0 0 0 transparent; } }
+.jump-bottom { display: flex; position: absolute; z-index: 8; bottom: 8px; left: 50%; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; font-size: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.1); transform: translateX(-50%); }
+.new-chat-empty { display: flex; min-height: 100%; align-items: center; justify-content: center; gap: 18px; padding: 12vh 24px; box-sizing: border-box; flex-direction: column; color: var(--text-muted); text-align: center; }
+.new-chat-empty__logo { display: block; width: min(180px, 34vw); height: auto; opacity: .18; filter: grayscale(1); }
+.new-chat-empty p { margin: 0; color: var(--text-secondary); font-size: 17px; font-weight: 520; letter-spacing: .02em; }
+.message-skeletons { width: min(760px, 100%); margin: 0 auto; padding: 35px 24px; }.message-skeletons div { display: flex; gap: 10px; margin: 0 0 25px; }.message-skeletons div.user { justify-content: flex-end; }.message-skeletons i { width: 27px; height: 27px; border-radius: 9px; background: var(--surface-hover); }.message-skeletons span { width: min(480px, 70%); height: 64px; border-radius: 12px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }.message-skeletons .user i { display: none; }.message-skeletons .user span { width: 44%; height: 46px; }
+@keyframes shimmer { to { background-position: -200% 0; } }
+
+@media (max-width: 768px) {
+  .timeline-header { min-height: 54px; padding: 8px 12px; }.timeline-state { font-size: 0; }.timeline-state .status-dot { width: 7px; height: 7px; }
+  .new-chat-empty { gap: 15px; padding-block: 9vh; }.new-chat-empty__logo { width: min(150px, 42vw); }.new-chat-empty p { font-size: 16px; }
+  .message-stack { padding: 18px 14px 30px; }.message { margin-bottom: 18px; }.message__body { max-width: calc(100% - 37px); }.message--user .message__body { max-width: 88%; }
+  .message__actions { opacity: .65; }.message__meta time { display: none; }
+}
+@media (prefers-reduced-motion: reduce) { .thinking-indicator strong { animation: none; } }
+</style>

+ 121 - 0
src/client/components/workspace/transcriptStore.ts

@@ -0,0 +1,121 @@
+import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
+import type { WorkspaceAgent, WorkspaceConversation, WorkspaceEvent, WorkspaceInteraction, WorkspaceMessage, WorkspaceRun, WorkspaceTask } from '@shared/workspace'
+
+export interface WorkspaceDetail {
+  conversation: WorkspaceConversation
+  messages: WorkspaceMessage[]
+  task?: WorkspaceTask | null
+  tasks?: WorkspaceTask[]
+  assignments?: AgentAssignment[]
+  run: WorkspaceRun | null
+  interactions: WorkspaceInteraction[]
+  context: Record<string, unknown> | null
+  hiddenMessageIds?: string[]
+  hasOlder?: boolean
+  cursor?: number
+}
+export interface WorkspaceSnapshot {
+  agents: WorkspaceAgent[]
+  conversations: WorkspaceConversation[]
+  details: WorkspaceDetail[]
+  cursor: number
+}
+export const transcriptKey = (id: string, taskId?: string | null) => `${id}:${taskId ?? ''}`
+function upsert<T extends { id: string }>(rows: T[], value: T): T[] {
+  const index = rows.findIndex(row => row.id === value.id)
+  if (index < 0) return [...rows, value]
+  const next = rows.slice(); next[index] = value; return next
+}
+
+/** Apply only the entity named by an event; settled message objects keep their identity. */
+export function foldDetail(detail: WorkspaceDetail, event: WorkspaceEvent): WorkspaceDetail {
+  if (event.conversationId !== detail.conversation.id) return detail
+  const data = event.data as Record<string, unknown>
+  const inTask = (task: unknown) => (task ?? null) === (detail.task?.id ?? null)
+  switch (event.type) {
+    case 'conversation.changed': return { ...detail, conversation: event.data as WorkspaceConversation }
+    case 'message.changed': {
+      const message = event.data as WorkspaceMessage
+      if (!inTask(message.conversationTaskId)) return detail
+      const messages = message.visible === false ? detail.messages.filter(m => m.id !== message.id)
+        : upsert(detail.messages, message).sort((a, b) => a.seq - b.seq)
+      return { ...detail, messages }
+    }
+    case 'task.changed': {
+      const task = event.data as WorkspaceTask
+      return { ...detail, tasks: upsert(detail.tasks ?? [], task), task: detail.task?.id === task.id ? task : detail.task }
+    }
+    case 'task.deleted': return { ...detail, tasks: (detail.tasks ?? []).filter(t => t.id !== data.id) }
+    case 'run.changed': return inTask(data.conversationTaskId) ? { ...detail, run: event.data as WorkspaceRun } : detail
+    case 'context.changed': return inTask(data.conversationTaskId) ? { ...detail, context: data } : detail
+    case 'interaction.changed': return inTask(data.conversationTaskId) ? { ...detail, interactions: data.resolved
+      ? detail.interactions.filter(i => i.id !== data.id) : upsert(detail.interactions, event.data as WorkspaceInteraction) } : detail
+    case 'assignment.changed': return data.goalId === detail.task?.id
+      ? { ...detail, assignments: upsert(detail.assignments ?? [], event.data as AgentAssignment) } : detail
+    case 'goal.changed': return data.id === detail.task?.id
+      ? { ...detail, task: { ...detail.task!, goal: event.data as AgentGoal } } : detail
+    default: return detail
+  }
+}
+
+export class WorkspaceTranscriptStore {
+  agents: WorkspaceAgent[] = []
+  conversations: WorkspaceConversation[] = []
+  readonly details = new Map<string, WorkspaceDetail>()
+  cursor = 0
+  readonly selectedTasks = new Map<string, string>()
+  private reads = new Map<symbol, WorkspaceEvent[]>()
+  beginRead(): symbol { const token = Symbol(); this.reads.set(token, []); return token }
+  cancelRead(token: symbol): void { this.reads.delete(token) }
+  finishRead(token: symbol, incoming: WorkspaceDetail, preserveHistory = true): WorkspaceDetail {
+    const key = transcriptKey(incoming.conversation.id, incoming.task?.id)
+    const previous = this.details.get(key)
+    let detail = incoming
+    if (previous && preserveHistory) {
+      const messages = new Map(previous.messages.map(message => [message.id, message]))
+      for (const message of incoming.messages) messages.set(message.id, message)
+      detail = { ...incoming, hasOlder: previous.hasOlder,
+        messages: [...messages.values()].filter(m => m.visible !== false && !incoming.hiddenMessageIds?.includes(m.id)).sort((a, b) => a.seq - b.seq) }
+    }
+    // A response that started before a live event must never overwrite that event.
+    for (const event of this.reads.get(token) ?? []) {
+      if (event.seq <= (incoming.cursor ?? 0)) continue
+      const id = (event.data as { id?: string }).id
+      if ((event.type === 'conversation.deleted' && id === incoming.conversation.id)
+        || (event.type === 'task.deleted' && id === incoming.task?.id)) {
+        this.reads.delete(token)
+        throw new Error('会话或任务已被删除')
+      }
+      detail = foldDetail(detail, event)
+    }
+    this.reads.delete(token)
+    this.details.set(key, detail)
+    return detail
+  }
+  hydrate(snapshot: WorkspaceSnapshot): void {
+    this.agents = snapshot.agents; this.conversations = snapshot.conversations; this.cursor = snapshot.cursor
+    this.details.clear()
+    for (const detail of snapshot.details) this.details.set(transcriptKey(detail.conversation.id, detail.task?.id), detail)
+  }
+  get(id: string, taskId?: string | null): WorkspaceDetail | undefined {
+    if (taskId) return this.details.get(transcriptKey(id, taskId))
+    const remembered = this.selectedTasks.get(id)
+    return (remembered ? this.details.get(transcriptKey(id, remembered)) : undefined)
+      ?? [...this.details.values()].find(detail => detail.conversation.id === id)
+  }
+  apply(event: WorkspaceEvent): void {
+    if (event.seq <= this.cursor) return
+    for (const events of this.reads.values()) events.push(event)
+    const data = event.data as { id: string }
+    if (event.type === 'agent.changed') this.agents = upsert(this.agents, event.data as WorkspaceAgent)
+    if (event.type === 'agent.deleted') this.agents = this.agents.filter(a => a.id !== data.id)
+    if (event.type === 'conversation.changed') this.conversations = upsert(this.conversations, event.data as WorkspaceConversation)
+    if (event.type === 'conversation.deleted') this.conversations = this.conversations.filter(c => c.id !== data.id)
+    for (const [key, detail] of this.details) {
+      if ((event.type === 'conversation.deleted' && detail.conversation.id === data.id)
+        || (event.type === 'task.deleted' && detail.task?.id === data.id)) this.details.delete(key)
+      else this.details.set(key, foldDetail(detail, event))
+    }
+    this.cursor = event.seq
+  }
+}

+ 128 - 52
src/client/views/ConversationsView.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import { publishServerIdentity } from '@/api/serverIdentity'
 import type { ServerIdentity } from '@shared/serverIdentity'
-import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue'
 import { useRoute, useRouter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
@@ -18,7 +18,8 @@ import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
 import type { SidebarItem } from '@/components/app/types'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import { createUuid } from '@/utils/id'
-import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import MessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
+import { WorkspaceTranscriptStore, transcriptKey, type WorkspaceDetail, type WorkspaceSnapshot } from '@/components/workspace/transcriptStore'
 import ComposerShell from '@/components/composer/ComposerShell.vue'
 import type { ComposerSubmit, ComposerReference } from '@/components/composer/types'
 import PreviewModal from '@/components/library/PreviewModal.vue'
@@ -55,7 +56,7 @@ const auth = useAuthStore(),
   router = useRouter()
 const agents = ref<Agent[]>([]),
   conversations = ref<Conversation[]>([]),
-  messages = ref<Message[]>([]),
+  messages = shallowRef<Message[]>([]),
   sources = ref<Source[]>([])
 const active = ref<Conversation>(),
   run = ref<Run | null>(null),
@@ -92,7 +93,14 @@ const quoted = ref<UiMessage | null>(null)
 const preview = ref<UiLibraryItem | null>(null)
 const mediaIndex = ref<number | null>(null)
 const showThinking = ref(true)
-const uiMessages = computed(() => workspaceMessagesToUi(messages.value))
+const transcriptStore = new WorkspaceTranscriptStore()
+const renderedMessages = new WeakMap<Message, UiMessage>()
+const uiMessages = computed(() => messages.value.filter(m => m.visible !== false).map(message => {
+  let rendered = renderedMessages.get(message)
+  if (!rendered) { rendered = workspaceMessagesToUi([message])[0]!; renderedMessages.set(message, rendered) }
+  return rendered
+}))
+const loadingOlder = ref(false)
 const media = computed(() => mediaItemsFromMessages(uiMessages.value))
 const lightboxMedia = computed(() => media.value.filter(item => item.kind === 'image' || item.kind === 'video').map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })))
 const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, content: quoted.value.content, author: quoted.value.author } : null)
@@ -234,11 +242,13 @@ async function saveProfileIdentity(input: ProfileIdentityInput) {
   }
 }
 async function refresh() {
+  const startedAtCursor = transcriptStore.cursor
+  const owner = auth.user?.id
   const [a, c] = await Promise.all([
     apiRequest<{ agents: Agent[] }>('/api/app/agents'),
     apiRequest<{ conversations: Conversation[]; cursor: number }>('/api/app/conversations'),
   ])
-  if (disposed) return
+  if (disposed || auth.user?.id !== owner || transcriptStore.cursor !== startedAtCursor) return
   agents.value = a.agents
   const localConversations = new Map(conversations.value.map(row => [row.id, row]))
   conversations.value = c.conversations.map(conversation => {
@@ -246,7 +256,9 @@ async function refresh() {
     return local && local.unreadVersion === conversation.unreadVersion && [...pendingReads].some(key => key.startsWith(`${conversation.id}:`))
       ? { ...conversation, unread: local.unread, unreadCount: local.unreadCount } : conversation
   })
-  if (!cursor) cursor = c.cursor
+  transcriptStore.agents = agents.value
+  transcriptStore.conversations = conversations.value
+  if (!cursor) { cursor = c.cursor; transcriptStore.cursor = c.cursor }
 }
 async function load(id = selected.value, append = false) {
   if (!id) {
@@ -264,34 +276,23 @@ async function load(id = selected.value, append = false) {
   const previousFiles = composer.value?.filesSnapshot() ?? []
   const previousQuote = quoted.value
   const requestedTask = selectedTask.value || (active.value?.id === id ? activeTask.value?.id : undefined)
-  if (!append) loading.value = true
+  const cached = !append ? transcriptStore.get(id, requestedTask) : undefined
+  const readToken = transcriptStore.beginRead()
+  if (!append) loading.value = !cached
   if (active.value?.id !== id) {
     active.value = conversations.value.find(c => c.id === id)
-    messages.value = [];run.value = null;interactions.value = [];context.value = null
+    messages.value = cached?.messages ?? [];run.value = null;interactions.value = [];context.value = null
     activeTask.value = null;tasks.value = [];assignments.value = [];quoted.value = null;older.value = false
   }
   try {
-    const r = await apiRequest<{
-      conversation: Conversation
-      messages: Message[]
-      run: Run | null
-      interactions: Interaction[]
-      context: Record<string, unknown> | null
-      hiddenMessageIds?: string[]
-      task?: WorkspaceTask | null
-      tasks?: WorkspaceTask[]
-      assignments?: AgentAssignment[]
-    }>(`/api/app/conversations/${id}${requestedTask ? `?taskId=${encodeURIComponent(requestedTask)}` : ''}`)
+    const incoming = cached ?? await apiRequest<WorkspaceDetail>(`/api/app/conversations/${id}?limit=50${requestedTask ? `&taskId=${encodeURIComponent(requestedTask)}` : ''}`)
     if (disposed || own !== generation) return
+    const r = cached ?? transcriptStore.finishRead(readToken, incoming)
     const atBottom = timeline.value?.isFollowingBottom() ?? true
     active.value = r.conversation
+    if (r.task) transcriptStore.selectedTasks.set(id, r.task.id)
     activeTask.value = r.task ?? null;tasks.value = r.tasks ?? [];assignments.value = r.assignments ?? []
-    messages.value = append
-      ? [...new Map([...messages.value, ...r.messages].map((m) => [m.id, m])).values()].sort(
-          (a, b) => a.seq - b.seq,
-        )
-      : r.messages
-    messages.value = messages.value.filter(m => m.visible !== false && !r.hiddenMessageIds?.includes(m.id))
+    messages.value = r.messages
     run.value = r.run
     interactions.value = r.interactions
     context.value = r.context
@@ -311,7 +312,7 @@ async function load(id = selected.value, append = false) {
       if (saved?.length) await composer.value?.attachFiles(saved)
       quoted.value = taskQuotes.get(composerKey.value) ?? null
     }
-    if (!append) older.value = r.messages.length === 100
+    if (!append) older.value = r.hasOlder ?? r.messages.length === 50
     if (!append || atBottom) {
       await nextTick()
       if (!append) timeline.value?.scrollToBottom('auto')
@@ -330,6 +331,7 @@ async function load(id = selected.value, append = false) {
     }
     if (own === generation) error.value = e instanceof Error ? e.message : '加载失败'
   } finally {
+    transcriptStore.cancelRead(readToken)
     if (own === generation) loading.value = false
   }
 }
@@ -422,23 +424,78 @@ function openTaskLink(event: MouseEvent) {
     void router.push(url.pathname+url.search)
   } catch { /* Leave other links to their normal handler. */ }
 }
-async function poll() {
-  try {
-    const r = await apiRequest<{ events: WorkspaceEvent[]; cursor: number; serverIdentity?: ServerIdentity }>(
-      `/api/app/events?after=${cursor}`,
-    )
-    if (disposed) return
-    publishServerIdentity(r.serverIdentity)
-    cursor = r.cursor
-    if (r.events.length) {
-      await refresh()
-      if (r.events.some((e) => e.conversationId === selected.value || e.type === 'agent.changed'))
-        await load(selected.value, true)
+let eventSource: EventSource | undefined
+let eventFrame: number | undefined
+let eventQueue: WorkspaceEvent[] = []
+function presentDetail(detail: WorkspaceDetail) {
+  const atBottom = timeline.value?.isFollowingBottom() ?? true
+  active.value = detail.conversation
+  messages.value = detail.messages
+  activeTask.value = detail.task ?? null; tasks.value = detail.tasks ?? []
+  run.value = detail.run; interactions.value = detail.interactions
+  context.value = detail.context; assignments.value = detail.assignments ?? []
+  older.value = detail.hasOlder ?? false
+  if (atBottom) void markRead().catch(() => {})
+}
+function flushEvents() {
+  eventFrame = undefined
+  for (const event of eventQueue) transcriptStore.apply(event)
+  eventQueue = []
+  cursor = transcriptStore.cursor
+  agents.value = transcriptStore.agents
+  conversations.value = transcriptStore.conversations
+  if (selected.value) {
+    const detail = transcriptStore.get(selected.value, selectedTask.value || activeTask.value?.id)
+    if (detail) presentDetail(detail)
+    else if (!conversations.value.some(c => c.id === selected.value)) void router.replace('/conversations')
+    else if (activeTask.value && !transcriptStore.details.has(transcriptKey(selected.value, activeTask.value.id))) {
+      void router.replace(`/conversations/${selected.value}`); void load(selected.value, true)
     }
-  } catch (e) {
-    if (!disposed) error.value = e instanceof Error ? e.message : '连接中断,正在重连'
-  } finally {
-    if (!disposed) timer = setTimeout(() => void poll(), 1200)
+  }
+}
+async function hydrateWorkspace() {
+  const owner = auth.user?.id
+  const snapshot = await apiRequest<WorkspaceSnapshot & { serverIdentity?: ServerIdentity }>('/api/app/workspace/snapshot')
+  if (disposed || auth.user?.id !== owner) return
+  transcriptStore.hydrate(snapshot)
+  agents.value = snapshot.agents; conversations.value = snapshot.conversations; cursor = snapshot.cursor
+  publishServerIdentity(snapshot.serverIdentity)
+}
+function connectEvents() {
+  eventSource?.close()
+  if (disposed) return
+  const source = new EventSource(`/api/app/events/stream?after=${cursor}`)
+  eventSource = source
+  source.addEventListener('workspace', event => {
+    if (disposed || eventSource !== source) return
+    try {
+      eventQueue.push(JSON.parse((event as MessageEvent).data))
+      if (eventFrame === undefined) eventFrame = requestAnimationFrame(flushEvents)
+    } catch { source.close(); error.value = '消息同步失败,正在恢复'; void recoverEvents() }
+  })
+  source.addEventListener('ready', event => {
+    if (eventSource !== source) return
+    flushEvents(); error.value = ''
+    publishServerIdentity(JSON.parse((event as MessageEvent).data).serverIdentity)
+  })
+  source.addEventListener('reset', () => { source.close(); void recoverEvents() })
+  source.onerror = () => {
+    if (disposed || eventSource !== source) return
+    error.value = '连接中断,正在重连'
+    void apiRequest('/api/app/capabilities').catch(cause => {
+      if (cause instanceof ApiError && cause.status === 401) source.close()
+    })
+  }
+}
+async function recoverEvents() {
+  eventSource?.close(); eventSource = undefined; eventQueue = []
+  if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
+  eventFrame = undefined
+  try { await hydrateWorkspace(); await load(); connectEvents() }
+  catch (cause) {
+    if (disposed) return
+    error.value = cause instanceof Error ? cause.message : '连接中断'
+    timer = setTimeout(() => void recoverEvents(), 1500)
   }
 }
 async function select(id: string) {
@@ -705,18 +762,23 @@ async function respond(i: Interaction, answer: string) {
 }
 async function loadOlder() {
   const c = active.value
-  if (!c) return
+  if (!c || loadingOlder.value) return
+  loadingOlder.value = true
+  const readToken = transcriptStore.beginRead()
   const taskId = activeTask.value?.id, own = generation
   try {
-    const r = await apiRequest<{ messages: Message[] }>(
-      `/api/app/conversations/${c.id}/messages?before=${messages.value[0]?.seq ?? 0}${activeTask.value ? `&taskId=${activeTask.value.id}` : ''}`,
+    const r = await apiRequest<{ messages: Message[]; cursor?: number }>(
+      `/api/app/conversations/${c.id}/messages?limit=50&before=${messages.value[0]?.seq ?? 0}${activeTask.value ? `&taskId=${activeTask.value.id}` : ''}`,
     )
     if (own !== generation || active.value?.id !== c.id || activeTask.value?.id !== taskId) return
-    messages.value = [...r.messages, ...messages.value]
-    older.value = r.messages.length === 100
+    const current = transcriptStore.get(c.id, taskId)
+    if (!current) return
+    const detail = transcriptStore.finishRead(readToken, { ...current, messages: r.messages, cursor: r.cursor })
+    detail.hasOlder = r.messages.length === 50
+    messages.value = detail.messages; older.value = detail.hasOlder
   } catch (e) {
     error.value = String(e)
-  }
+  } finally { transcriptStore.cancelRead(readToken); loadingOlder.value = false }
 }
 function rendered(m: Message) {
   return m.content.replace(
@@ -740,19 +802,33 @@ watch([selected,selectedTask], () => {
   void load()
 })
 watch(selected,()=>{dockView.value=undefined})
+watch(() => auth.user?.id, (owner, previous) => {
+  if (owner === previous) return
+  generation++
+  eventSource?.close(); eventSource = undefined; eventQueue = []
+  if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
+  eventFrame = undefined; cursor = 0
+  transcriptStore.hydrate({ agents: [], conversations: [], details: [], cursor: 0 })
+  transcriptStore.selectedTasks.clear()
+  agents.value = []; conversations.value = []; messages.value = []; active.value = undefined
+  tasks.value = []; activeTask.value = null; run.value = null; interactions.value = []; context.value = null
+  taskFiles.clear(); taskQuotes.clear()
+  if (owner) void recoverEvents()
+})
 onMounted(async () => {
   void auth.refreshProfileAvatars().catch(() => undefined)
   try {
-    await apiRequest('/api/app/capabilities')
-    await refresh()
+    await hydrateWorkspace()
+    if (!disposed) connectEvents()
     await load()
-    if (!disposed) void poll()
   } catch (e) {
     error.value = String(e)
   }
 })
 onBeforeUnmount(() => {
   disposed = true
+  eventSource?.close()
+  if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
   generation++
   if (timer) clearTimeout(timer)
 })
@@ -790,7 +866,7 @@ onBeforeUnmount(() => {
     /></template>
     <div v-show="!twoDesktops" class="conversation-with-computer">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
-      <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
+      <MessageTimeline ref="timeline" :identity="`${selected}:${activeTask?.id ?? ''}`" :loading-older="loadingOlder" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
         :loading="loading" :has-older="older && !!active" :connected="!error" :synced="!loading"
         :show-tools="showThinking" :allow-branch="false" :thinking="!!run && ['running','waiting','uncertain'].includes(run.status)"

+ 15 - 22
src/server/workspaceRoutes.ts

@@ -1,3 +1,4 @@
+import { workspaceDetail, streamWorkspace } from './workspaceSync.js'
 import { readServerIdentity } from './serverIdentity.js'
 import { randomUUID } from 'node:crypto'
 import Router from '@koa/router'
@@ -72,7 +73,7 @@ export function workspaceRouter(
         'voice',
         'context',
         ...(auth.require(ctx).role === 'admin' ? ['nodes', 'pairedNodes', 'remoteAgentReferences', 'editableNodeAddress'] : []),
-        'events',
+        'events', 'workspace-stream-v1',
       ],
     }
   })
@@ -235,27 +236,9 @@ export function workspaceRouter(
     ctx.body = { ok: true }
   })
   router.get('/api/app/conversations/:id', (ctx) => {
-    const user = owner(ctx),
-      conversation = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id),
-      requestedTaskId = typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
-      task = store.resolveTask(user, conversation.id, requestedTaskId),
-      tasks = conversation.kind === 'group' ? store.tasks(user, conversation.id) : [],
-      activeRunId = task?.activeRunId ?? (conversation.kind === 'direct' ? conversation.activeRunId : undefined)
-    ctx.body = {
-      conversation: store.conversationSummary(user, conversation),
-      task: task ?? null,
-      tasks,
-      assignments: task ? runtime.tasks.assignments(user, task.id) : [],
-      messages: store.messages(user, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, task?.id),
-      hiddenMessageIds: store.hiddenMessageIds(user, conversation.id, task?.id),
-      run: activeRunId
-        ? store.require<WorkspaceRun>(user, 'run', activeRunId)
-        : null,
-      interactions: store
-        .list<WorkspaceInteraction>(user, 'interaction')
-        .filter((i) => i.conversationId === conversation.id && i.conversationTaskId === task?.id && !i.resolved),
-      context: store.get(user, 'context', task?.id ?? conversation.id) ?? null,
-    }
+    ctx.body = workspaceDetail(store, runtime, owner(ctx), ctx.params.id,
+      typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
+      Math.max(1, Math.min(100, number(ctx.query.limit, 100))))
   })
   router.patch('/api/app/conversations/:id', async (ctx) => {
     const user = owner(ctx), input = body(ctx)
@@ -286,6 +269,7 @@ export function workspaceRouter(
         false,
         task?.id,
       ),
+      cursor: store.cursor(user),
     }
   })
   router.post('/api/app/conversations/:id/messages', async (ctx) => {
@@ -327,6 +311,15 @@ export function workspaceRouter(
     await runtime.respond(owner(ctx), ctx.params.id, b.answer)
     ctx.body = { ok: true }
   })
+  router.get('/api/app/workspace/snapshot', ctx => {
+    const user = owner(ctx)
+    const conversations = store.list<WorkspaceConversation>(user, 'conversation').map(c => store.conversationSummary(user, c))
+    const details = conversations.filter(c => !c.archived).map(c => workspaceDetail(store, runtime, user, c.id))
+    ctx.set('Cache-Control', 'no-store')
+    ctx.body = { agents: store.list<WorkspaceAgent>(user, 'agent').map(a => store.agentSummary(a)),
+      conversations, details, cursor: store.cursor(user), serverIdentity: readServerIdentity(store) }
+  })
+  router.get('/api/app/events/stream', ctx => streamWorkspace(ctx, store, auth))
   router.get('/api/app/events', (ctx) => {
     const user = owner(ctx),
       after = number(ctx.query.after, 0),

+ 1 - 0
src/server/workspaceStore.ts

@@ -998,6 +998,7 @@ export class WorkspaceStore {
     )
   }
   close(): void {
+    this.changes.emit('workspace.close')
     this.changes.removeAllListeners()
     this.db.close()
   }

+ 74 - 0
src/server/workspaceSync.ts

@@ -0,0 +1,74 @@
+import type Koa from 'koa'
+import type { LocalAuthStore } from './localAuth.js'
+import type { WorkspaceStore } from './workspaceStore.js'
+import type { WorkspaceRuntime } from './workspaceRuntime.js'
+import { readServerIdentity } from './serverIdentity.js'
+import type { WorkspaceConversation, WorkspaceEvent, WorkspaceInteraction, WorkspaceRun } from '../shared/workspace.js'
+
+/** Bot transcripts are a local projection. Snapshot and cursor are read in the same synchronous turn. */
+export function workspaceDetail(store: WorkspaceStore, runtime: WorkspaceRuntime, owner: string,
+  conversationId: string, taskId?: string, limit = 50) {
+  const conversation = store.require<WorkspaceConversation>(owner, 'conversation', conversationId)
+  const task = store.resolveTask(owner, conversationId, taskId)
+  const activeRunId = task?.activeRunId ?? (conversation.kind === 'direct' ? conversation.activeRunId : undefined)
+  const page = store.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, limit + 1, false, task?.id)
+  return {
+    conversation: store.conversationSummary(owner, conversation), task: task ?? null,
+    tasks: conversation.kind === 'group' ? store.tasks(owner, conversationId) : [],
+    assignments: task ? runtime.tasks.assignments(owner, task.id) : [],
+    messages: page.slice(-limit), hasOlder: page.length > limit,
+    hiddenMessageIds: store.hiddenMessageIds(owner, conversationId, task?.id),
+    run: activeRunId ? store.require<WorkspaceRun>(owner, 'run', activeRunId) : null,
+    interactions: store.list<WorkspaceInteraction>(owner, 'interaction')
+      .filter(value => value.conversationId === conversationId && value.conversationTaskId === task?.id && !value.resolved),
+    context: store.get<Record<string, unknown>>(owner, 'context', task?.id ?? conversationId) ?? null,
+    cursor: store.cursor(owner),
+  }
+}
+
+/** Durable replay followed by live updates. A slow reader reconnects from its last applied cursor. */
+export function streamWorkspace(ctx: Koa.Context, store: WorkspaceStore, auth: LocalAuthStore): void {
+  const owner = auth.require(ctx).id, version = auth.pushAuthorizationVersion(owner)
+  const res = ctx.res
+  const raw = ctx.get('last-event-id') || String(ctx.query.after ?? '0')
+  let cursor = /^\d+$/.test(raw) ? Number(raw) : 0
+  let closed = false
+  const valid = () => {
+    try { return auth.require(ctx).id === owner && auth.isUserActive(owner) && auth.pushAuthorizationVersion(owner) === version }
+    catch { return false }
+  }
+  const close = () => {
+    if (closed) return
+    closed = true; clearInterval(heartbeat)
+    store.changes.off(owner, deliver); store.changes.off('workspace.close', close)
+    if (!res.writableEnded) res.end()
+  }
+  const write = (text: string) => {
+    if (closed || res.destroyed || res.writableEnded || !valid()) { close(); return }
+    if (res.writableLength + Buffer.byteLength(text) > 4 * 1024 * 1024) { close(); return }
+    res.write(text)
+  }
+  const deliver = (event: WorkspaceEvent) => {
+    if (event.seq <= cursor) return
+    write(`id: ${event.seq}\nevent: workspace\ndata: ${JSON.stringify(event)}\n\n`)
+    cursor = event.seq
+  }
+  const heartbeat = setInterval(() => write(': heartbeat\n\n'), 15_000)
+  heartbeat.unref()
+  ctx.respond = false
+  res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' })
+  res.flushHeaders()
+  res.once('close', close); res.once('error', close)
+  store.changes.on(owner, deliver); store.changes.once('workspace.close', close)
+  const latest = store.cursor(owner)
+  if (!Number.isSafeInteger(cursor) || cursor > latest) {
+    write('event: reset\ndata: {}\n\n'); close(); return
+  }
+  // No await between replay and subscription: an event cannot fall through that boundary.
+  while (!closed && cursor < latest) {
+    const page = store.events(owner, cursor)
+    if (!page.length) break
+    for (const event of page) deliver(event)
+  }
+  write(`event: ready\ndata: ${JSON.stringify({ cursor, serverIdentity: readServerIdentity(store) })}\n\n`)
+}

+ 4 - 2
tests/client/conversationSettings.test.ts

@@ -13,19 +13,21 @@ vi.mock('@/stores/theme', () => ({ useThemeStore: () => ({}) }))
 const originalShowModal = HTMLDialogElement.prototype.showModal
 const originalClose = HTMLDialogElement.prototype.close
 let wrapper: VueWrapper | undefined
-afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks(); vi.mocked(apiRequest).mockReset(); HTMLDialogElement.prototype.showModal = originalShowModal; HTMLDialogElement.prototype.close = originalClose })
+afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.mocked(apiRequest).mockReset(); HTMLDialogElement.prototype.showModal = originalShowModal; HTMLDialogElement.prototype.close = originalClose })
 
 it.each(['direct', 'group'] as const)('opens and saves the menu target %s while another chat is selected', async kind => {
   const base: WorkspaceConversation = { id: 'current', kind: 'direct', name: '当前聊天', avatar: '', memberIds: ['current-bot'], instructions: '', administratorId: 'current-bot', mode: 'host', autoReplyIds: [], maxReplyRounds: 3, archived: false, pinned: false, readSeq: 0, lastSeq: 0, preview: '', createdAt: 1, updatedAt: 1 }
   const target: WorkspaceConversation = { ...base, id: 'target', kind, name: '目标聊天', memberIds: ['target-bot', ...(kind === 'group' ? ['current-bot'] : [])], administratorId: 'target-bot', instructions: '目标规则', activeAgentStates: kind === 'group' ? { 'target-bot': 'running' } : {} }
   const conversations = [base, target]
   const agents = ['current-bot', 'target-bot'].map(id => ({ id, name: id === 'target-bot' ? '目标机器人' : '当前机器人', avatar: '', instructions: `${id}规则`, nodeId: 'local', profile: 'default', archived: false }))
+  vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
   vi.mocked(apiRequest).mockImplementation(async path => {
+    if (path === '/api/app/workspace/snapshot') return { agents, conversations, cursor: 1, details: [] } as never
     if (path === '/api/app/agents') return { agents } as never
     if (path === '/api/app/agents/sources') return { sources: [{ nodeId: 'local', profile: 'default', name: '基础机器人' }] } as never
     if (path === '/api/app/conversations') return { conversations, cursor: 1 } as never
     if (path.startsWith('/api/app/events')) return { events: [], cursor: 1 } as never
-    const conversation = conversations.find(c => path === `/api/app/conversations/${c.id}`)
+    const conversation = conversations.find(c => path.split('?')[0] === `/api/app/conversations/${c.id}`)
     if (conversation) return { conversation, messages: [], runs: [], run: null, interactions: [], tasks: [] } as never
     return {} as never
   })

+ 54 - 0
tests/client/workspaceTranscript.test.ts

@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+import { foldDetail, WorkspaceTranscriptStore, type WorkspaceDetail } from '@/components/workspace/transcriptStore'
+import type { WorkspaceConversation, WorkspaceEvent, WorkspaceMessage } from '@shared/workspace'
+
+const conversation: WorkspaceConversation = { id: 'c', kind: 'direct', name: 'Bot', avatar: '', memberIds: ['a'], instructions: '', administratorId: 'a', mode: 'host', autoReplyIds: [], maxReplyRounds: 3, archived: false, pinned: false, readSeq: 0, lastSeq: 3, preview: '', createdAt: 1, updatedAt: 1 }
+const message = (id: string, seq: number, content = id): WorkspaceMessage => ({ id, seq, conversationId: 'c', role: 'assistant', content, reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: seq })
+const detail = (): WorkspaceDetail => ({ conversation, messages: [message('old', 1), message('live', 2)], cursor: 10, hasOlder: true, run: null, interactions: [], context: null })
+const event = (seq: number, value: WorkspaceMessage): WorkspaceEvent => ({ seq, type: 'message.changed', conversationId: value.conversationId, data: value })
+
+describe('Bot transcript cache', () => {
+  it('returns a warm transcript without losing its older pages and preserves settled identities on a live update', () => {
+    const store = new WorkspaceTranscriptStore(), initial = detail()
+    store.hydrate({ agents: [], conversations: [conversation], details: [initial], cursor: 10 })
+    expect(store.get('c')).toBe(initial)
+    store.apply(event(11, { ...message('live', 2, '正在生成'), status: 'streaming' }))
+    expect(store.get('c')!.messages[0]).toBe(initial.messages[0])
+    expect(store.get('c')!.messages[1]!.content).toBe('正在生成')
+    expect(store.get('c')!.hasOlder).toBe(true)
+  })
+  it('replays events received during a cold detail request instead of rolling back new text', () => {
+    const store = new WorkspaceTranscriptStore(), token = store.beginRead()
+    store.apply(event(12, message('live', 2, '更新后的内容')))
+    const current = store.finishRead(token, detail())
+    expect(current.messages.at(-1)!.content).toBe('更新后的内容')
+  })
+  it('does not apply pre-snapshot replay over a newer snapshot', () => {
+    const store = new WorkspaceTranscriptStore(), token = store.beginRead()
+    store.apply(event(9, message('live', 2, '旧事件')))
+    expect(store.finishRead(token, detail()).messages.at(-1)!.content).toBe('live')
+  })
+  it('removes hidden messages, ignores duplicate events, and evicts deleted conversations', () => {
+    const store = new WorkspaceTranscriptStore()
+    store.hydrate({ agents: [], conversations: [conversation], details: [detail()], cursor: 10 })
+    store.apply(event(11, { ...message('live', 2), visible: false }))
+    store.apply(event(11, message('live', 2)))
+    expect(store.get('c')!.messages.map(m => m.id)).toEqual(['old'])
+    store.apply({ seq: 12, type: 'conversation.deleted', conversationId: 'c', data: { id: 'c' } })
+    expect(store.get('c')).toBeUndefined()
+    expect(store.conversations).toEqual([])
+  })
+  it('does not resurrect a conversation deleted while its detail request was in flight', () => {
+    const store = new WorkspaceTranscriptStore(), token = store.beginRead()
+    store.apply({ seq: 12, type: 'conversation.deleted', conversationId: 'c', data: { id: 'c' } })
+    expect(() => store.finishRead(token, detail())).toThrow('已被删除')
+    expect(store.get('c')).toBeUndefined()
+  })
+  it('keeps background tasks separate and settles approval cards directly from events', () => {
+    const initial = detail()
+    expect(foldDetail(initial, event(11, { ...message('other', 3), conversationTaskId: 'other' }))).toBe(initial)
+    const pending = foldDetail(initial, { seq: 12, conversationId: 'c', type: 'interaction.changed', data: { id: 'approval', kind: 'approval', choices: [], message: '允许?' } })
+    expect(pending.interactions).toHaveLength(1)
+    expect(foldDetail(pending, { seq: 13, conversationId: 'c', type: 'interaction.changed', data: { id: 'approval', resolved: true } }).interactions).toHaveLength(0)
+  })
+})

+ 48 - 2
tests/e2e/workspace-chat.spec.ts

@@ -60,8 +60,8 @@ test('Bot unread dots sync across viewers and neither detail nor read requests d
       await page.route(detailPattern, async route => { await detailGate; await route.continue() })
       await page.route(readPattern, async route => { await readGate; await route.continue() })
       try {
-        const detailStarted = page.waitForRequest(request => detailPattern.test(request.url()))
-        await row.click(); await detailStarted
+        // Warm transcripts are already in the snapshot; a detail request is optional.
+        await row.click()
         await expect(page.getByRole('heading', { name: target.name, exact: true })).toBeVisible()
         releaseDetail()
         await expect(page.getByRole('textbox', { name: '输入消息,Enter 发送,Shift + Enter 换行' })).toBeEnabled()
@@ -657,3 +657,49 @@ test('only chat messages introduce files across transcript and library', async (
   await page.setViewportSize({ width: 1280, height: 1100 })
   await page.screenshot({ path: testInfo.outputPath('message-files-web.png') })
 })
+
+
+test('Bot transcript switches from cache and preserves the viewport during history and live updates', async ({ page }, testInfo) => {
+  await login(page)
+  const bootstrap = await (await page.request.get('/api/app/bootstrap')).json()
+  const headers = { 'X-CSRF-Token': bootstrap.csrfToken }
+  const seed = await (await page.request.post('/__test/workspace-transcript', { headers })).json()
+  try {
+    await page.goto(`/conversations/${seed.conversationId}`)
+    await expect(page.locator('.message')).toHaveCount(50)
+    const scroller = page.locator('.timeline')
+    for (let index = 0; index < 4; index++) {
+      await scroller.evaluate(el => { el.scrollTop = 0 })
+      const before = await page.locator('.message').first().getAttribute('data-message-id')
+      await page.getByRole('button', { name: '加载更早消息', exact: true }).click()
+      await expect(page.locator('.message')).toHaveCount(100 + index * 50)
+      expect(await page.locator(`[data-message-id="${before}"]`).evaluate(el => {
+        const scroller = el.closest('.timeline')!; return el.getBoundingClientRect().top - scroller.getBoundingClientRect().top
+      })).toBeGreaterThanOrEqual(-10)
+    }
+    // Returning keeps loaded history in memory but mounts only the 120-message tail.
+    await page.locator('.desktop-sidebar .bot-logo-trigger').click()
+    await expect(page.locator('.desktop-sidebar .sidebar-item').filter({ hasText: '长会话性能验收' })).toBeVisible()
+    const requests: string[] = []
+    page.on('request', request => { if (request.method() === 'GET') requests.push(new URL(request.url()).pathname) })
+    await page.locator(`.desktop-sidebar [data-sidebar-id="${seed.conversationId}"]`).click()
+    await expect(page.locator('.message')).toHaveCount(120)
+    expect(requests).not.toContain(`/api/app/conversations/${seed.conversationId}`)
+    await scroller.hover(); await page.mouse.wheel(0, -600)
+    await expect(page.getByRole('button', { name: '回到底部', exact: true })).toBeVisible()
+    const top = await scroller.evaluate(el => el.scrollTop)
+    await page.request.post(`/__test/workspace-transcript?id=${seed.conversationId}`, { headers })
+    await expect(page.locator('.message').last()).toContainText('实时追加的内容')
+    expect(Math.abs(await scroller.evaluate(el => el.scrollTop) - top)).toBeLessThan(4)
+    expect(requests).not.toContain('/api/app/agents')
+    expect(requests).not.toContain('/api/app/events')
+    await page.screenshot({ path: testInfo.outputPath('bot-cached-transcript.png') })
+    await page.setViewportSize({ width: 375, height: 812 })
+    await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce' })
+    await expect(page.getByRole('textbox', { name: '输入消息,Enter 发送,Shift + Enter 换行' })).toBeVisible()
+    await page.screenshot({ path: testInfo.outputPath('bot-cached-transcript-mobile-dark.png') })
+  } finally {
+    await page.request.patch(`/api/app/agents/${seed.agentId}`, { headers, data: { archived: true } })
+    await page.request.delete(`/api/app/agents/${seed.agentId}`, { headers })
+  }
+})

+ 17 - 0
tests/fixtures/workspace-server.ts

@@ -355,6 +355,23 @@ const grokAuth=process.env.WORKSPACE_FIXTURE_GROK_AUTH==='1'?new FixtureGrokAuth
 const runtime = createApplication({ config, auth, grokFetch:grokAuth?.fetch, pluginFetch: pluginsFixture?.fetch }),
   node = createNodeServer(runtime)
 const closeNative=process.env.WORKSPACE_FIXTURE_NATIVE==='1'?nativeEnvironmentFixture(runtime,home,port):undefined
+runtime.app.use((ctx, next) => {
+  if (ctx.path !== '/__test/workspace-transcript' || ctx.method !== 'POST') return next()
+  const owner = auth.require(ctx).id
+  if (typeof ctx.query.id === 'string') {
+    const message = runtime.workspace.messages(owner, ctx.query.id, Number.MAX_SAFE_INTEGER, 1)[0]!
+    runtime.workspace.saveMessage(owner, { ...message, content: message.content + '\n\n实时追加的内容', status: 'streaming' })
+    ctx.body = { ok: true }; return
+  }
+  const agent = runtime.workspace.createAgent(owner, { name: '长会话性能验收', profile: 'default' })
+  const conversation = runtime.workspace.list<any>(owner, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === agent.id)!
+  for (let index = 1; index <= 300; index++) runtime.workspace.saveMessage(owner, {
+    id: randomUUID(), conversationId: conversation.id, seq: index, role: index % 2 ? 'user' : 'assistant',
+    content: `消息 ${index}\n\n${'用于验证长会话缓存与滚动位置。'.repeat(12)}`, reasoning: '', status: 'complete',
+    attachments: [], tools: [], createdAt: Date.now() + index,
+  })
+  ctx.body = { agentId: agent.id, conversationId: conversation.id }
+})
 runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/browser-page')return next();ctx.type='html';ctx.body='<!doctype html><meta charset="utf-8"><title>浏览器操作验收</title><body style="font:22px sans-serif;padding:48px;background:#edf4ff"><h1>机器人的独立浏览器</h1><p>这是隔离的真实浏览器验收页面。</p><input aria-label="输入内容"><button onclick="document.querySelector(\'output\').textContent=document.querySelector(\'input\').value">确认</button><output></output></body>'})
 runtime.app.use((ctx,next) => {
   if (ctx.path !== '/__test/task-plan' || ctx.method !== 'POST') return next()

+ 71 - 0
tests/server/workspaceSync.test.ts

@@ -0,0 +1,71 @@
+// @vitest-environment node
+import Koa from 'koa'
+import { once } from 'node:events'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, expect, it } from 'vitest'
+import { WorkspaceStore } from '../../src/server/workspaceStore'
+import { streamWorkspace, workspaceDetail } from '../../src/server/workspaceSync'
+import type { LocalAuthStore } from '../../src/server/localAuth'
+import type { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
+import type { Server } from 'node:http'
+import type { WorkspaceConversation, WorkspaceMessage } from '../../src/shared/workspace'
+
+let home: string, store: WorkspaceStore, server: Server, version: number
+const auth = { require: () => ({ id: 'owner' }), isUserActive: () => true, pushAuthorizationVersion: () => version } as unknown as LocalAuthStore
+beforeEach(async () => {
+  home = mkdtempSync(join(tmpdir(), 'workspace-sync-')); store = new WorkspaceStore(home); version = 1
+  const app = new Koa(); app.use(ctx => streamWorkspace(ctx, store, auth))
+  server = app.listen(0, '127.0.0.1'); await once(server, 'listening')
+})
+afterEach(async () => { store.close(); server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); rmSync(home, { recursive: true, force: true }) })
+const url = () => `http://127.0.0.1:${(server.address() as { port: number }).port}/stream`
+
+it('replays more than one event page, stays owner-scoped, and continues live without a gap', async () => {
+  for (let i = 0; i < 270; i++) store.event('owner', 'message.changed', { id: String(i), role: 'assistant', attachments: [], content: `消息${i}` }, 'c')
+  store.event('someone-else', 'message.changed', { id: 'private', role: 'assistant', attachments: [], content: '别人的消息' }, 'private')
+  const controller = new AbortController()
+  const response = await fetch(url(), { signal: controller.signal, headers: { 'Last-Event-ID': '3' } })
+  expect(response.headers.get('content-type')).toContain('text/event-stream')
+  const reader = response.body!.getReader(), decoder = new TextDecoder()
+  let text = ''
+  while (!text.includes('event: ready')) text += decoder.decode((await reader.read()).value, { stream: true })
+  expect(text.match(/event: workspace/g)).toHaveLength(267)
+  expect(text).not.toContain('别人的消息')
+  const seq = store.event('owner', 'conversation.changed', { id: 'c', preview: '新增' }, 'c')
+  while (!text.includes(`id: ${seq}\n`)) text += decoder.decode((await reader.read()).value, { stream: true })
+  expect(text).toContain('新增')
+  controller.abort(); await reader.cancel().catch(() => {})
+})
+
+it('closes immediately on a live event after authorization changes', async () => {
+  const response = await fetch(url())
+  const reader = response.body!.getReader()
+  await reader.read()
+  version++
+  store.event('owner', 'conversation.changed', { id: 'c' }, 'c')
+  expect((await reader.read()).done).toBe(true)
+  expect(store.changes.listenerCount('owner')).toBe(0)
+})
+
+it('requests a fresh snapshot when the database cursor has moved backwards', async () => {
+  const response = await fetch(url(), { headers: { 'Last-Event-ID': '999' } })
+  expect(await response.text()).toContain('event: reset')
+})
+
+it('returns only the latest 50 messages with an exact hasOlder flag and a matching cursor', () => {
+  store.createAgent('owner', { name: 'Bot', nodeId: 'local', profile: 'default' })
+  const conversation = store.list<WorkspaceConversation>('owner', 'conversation')[0]!
+  for (let i = 1; i <= 51; i++) {
+    const message: WorkspaceMessage = { id: `m${i}`, conversationId: conversation.id, seq: i, role: 'user', content: String(i), reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: i }
+    store.saveMessage('owner', message)
+  }
+  const runtime = { tasks: { assignments: () => [] } } as unknown as WorkspaceRuntime
+  const detail = workspaceDetail(store, runtime, 'owner', conversation.id)
+  expect(detail.messages).toHaveLength(50)
+  expect(detail.messages[0]!.content).toBe('2')
+  expect(detail.hasOlder).toBe(true)
+  expect(detail.cursor).toBe(store.cursor('owner'))
+  expect(() => workspaceDetail(store, runtime, 'another', conversation.id)).toThrow()
+})