Skip to content
12 changes: 12 additions & 0 deletions apps/web/src/components/app-shell/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
tabsAtom,
workspacePinnedIdsAtom,
} from '@/atoms'
import { useReleaseThreadState } from '@/hooks/use-release-thread-state'
import { CreateWorkspaceDialog } from '@/components/workspace/CreateWorkspaceDialog'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
Expand Down Expand Up @@ -103,6 +104,7 @@ export function LeftSidebar({ forceCollapsed = false }: { forceCollapsed?: boole
const [workspaces, setWorkspaces] = useAtom(agentWorkspacesAtom)
const [pinnedIds, setPinnedIds] = useAtom(workspacePinnedIdsAtom)
const setSettingsInitialTab = useSetAtom(settingsInitialTabAtom)
const releaseThreadState = useReleaseThreadState()
const setArchiveInitialView = useSetAtom(archiveInitialViewAtom)
const [expandedWorkspaceIds, setExpandedWorkspaceIds] = useState<string[]>([])
const [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false)
Expand Down Expand Up @@ -339,6 +341,8 @@ export function LeftSidebar({ forceCollapsed = false }: { forceCollapsed?: boole
try {
await sidecarCall(AGENT_IPC_CHANNELS.ARCHIVE_THREAD, { threadId: thread.id })
removeThreadFromNavigation(thread.id)
// 归档后线程只能恢复后再打开,重开时 hydrate 重建,清掉无副作用
releaseThreadState(thread.id)
toast.success('已归档')
} catch (error) {
console.error('[LeftSidebar] 归档失败:', error)
Expand All @@ -362,6 +366,7 @@ export function LeftSidebar({ forceCollapsed = false }: { forceCollapsed?: boole
try {
await sidecarCall(AGENT_IPC_CHANNELS.TRASH_THREAD, { threadId: thread.id })
removeThreadFromNavigation(thread.id)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 相邻的 archiveThread(:334-352,同文件、同一菜单)没有接入这两行清理——归档路径上 Record 仍只增不减。

归档是完成会话的主要收尾动作(回收站是给不要的线程用的):长会话中归档 N 个线程,每个至多 2000 条事件对象 + threadMessagesCache 条目常驻内存直到重启。清掉并无副作用——归档线程恢复/重新打开时会走 AgentMessages.tsx:243 重新 hydrate,hydrateRuntimeEvents 也会从持久化事件重建 terminalStatus。建议本 PR 一并补上,或抽一个共享清理助手(见 ArchiveSettings 侧评论)。

releaseThreadState(thread.id)
toast.success('已移入回收站')
} catch (error) {
console.error('[LeftSidebar] 移入回收站失败:', error)
Expand Down Expand Up @@ -426,6 +431,13 @@ export function LeftSidebar({ forceCollapsed = false }: { forceCollapsed?: boole
await sidecarCall(AGENT_IPC_CHANNELS.DELETE_WORKSPACE, { id: workspaceId, mode })
setWorkspaces((previous) => previous.filter((w) => w.id !== workspaceId))
setCurrentWorkspaceId((current) => current === workspaceId ? null : current)
if (mode === 'deleteLumeData') {
// 会话随项目数据进回收站:与单线程删除路径一致,统一释放渲染端状态
// (keepHistory 会话转为普通会话仍在用,不能清)
for (const thread of threads.filter((item) => item.workspaceId === workspaceId)) {
releaseThreadState(thread.id)
}
}
const nextThreads = await sidecarCall<AgentThreadMeta[]>(AGENT_IPC_CHANNELS.LIST_THREADS, {})
setThreads(Array.isArray(nextThreads) ? nextThreads : [])
toast.success(mode === 'keepHistory' ? '已移除项目,会话已转为普通会话' : '已移除项目并将 Lume 用户数据移入回收站')
Expand Down
16 changes: 4 additions & 12 deletions apps/web/src/components/settings/ArchiveSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import {
Trash,
Trash2,
} from 'lucide-react'
import { useAtomValue, useSetAtom } from 'jotai'
import { useAtomValue } from 'jotai'
import { toast } from 'sonner'
import type { AgentThreadMeta } from '@lume/shared'
import { AGENT_IPC_CHANNELS } from '@lume/shared'
import { cn } from '@/lib/utils'
import { agentInputDraftAtom, agentInputHistoryAtom, agentWorkspacesAtom } from '@/atoms'
import { agentWorkspacesAtom } from '@/atoms'
import { useReleaseThreadState } from '@/hooks/use-release-thread-state'
import { sidecarCall } from '@/lib/desktop-api'
import { removeDraft, removeHistory } from '@/lib/agent-input-draft-state'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'

import { Button } from '@/components/ui/button'
Expand All @@ -34,15 +34,7 @@ function formatDate(timestamp: number): string {

export function ArchiveSettings({ initialView }: { initialView?: 'archive' | 'trash' }) {
const workspaces = useAtomValue(agentWorkspacesAtom)
const setDraftState = useSetAtom(agentInputDraftAtom)
const setHistoryState = useSetAtom(agentInputHistoryAtom)
const removeThreadInputState = React.useCallback(
(threadId: string) => {
setDraftState((prev) => removeDraft(prev, threadId))
setHistoryState((prev) => removeHistory(prev, threadId))
},
[setDraftState, setHistoryState],
)
const removeThreadInputState = useReleaseThreadState()
const [view, setView] = React.useState<View>(initialView ?? 'archive')
const [archivedThreads, setArchivedThreads] = React.useState<AgentThreadMeta[]>([])
const [trashedThreads, setTrashedThreads] = React.useState<AgentThreadMeta[]>([])
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/settings/DataManagementSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import {
import { toast } from 'sonner'
import { listen } from '@/lib/desktop-runtime/event'
import { relaunch } from '@/lib/desktop-runtime/process'
import { DATA_CATEGORY_META } from '@lume/shared'
import type { StorageStats } from '@lume/shared'
import { AGENT_IPC_CHANNELS, DATA_CATEGORY_META } from '@lume/shared'
import type { AgentThreadMeta, StorageStats } from '@lume/shared'
import { useReleaseThreadState } from '@/hooks/use-release-thread-state'
import { sidecarCall } from '@/lib/desktop-api'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Expand Down Expand Up @@ -44,6 +46,7 @@ import {
import { useModelMetaReload } from '@/lib/model-meta-context'

export function DataManagementSettings() {
const releaseThreadState = useReleaseThreadState()
const [stats, setStats] = React.useState<StorageStats | null>(null)
const [loadingStats, setLoadingStats] = React.useState(true)
const [selection, setSelection] = React.useState<CleanupSelection>(createDefaultCleanupSelection())
Expand Down Expand Up @@ -111,7 +114,10 @@ export function DataManagementSettings() {
const handleEmptyTrash = async () => {
setEmptying(true)
try {
// 与 ArchiveSettings 清空回收站同语义:回收站里的线程一并释放渲染端状态
const trashedThreads = await sidecarCall<AgentThreadMeta[]>(AGENT_IPC_CHANNELS.LIST_TRASHED_THREADS, {})
const { cleanedCount } = await emptyTrash()
for (const thread of trashedThreads ?? []) releaseThreadState(thread.id)
toast.success(`已清空回收站 ${cleanedCount} 项`)
await refreshStats()
} catch (error) {
Expand Down
79 changes: 78 additions & 1 deletion apps/web/src/hooks/runtime-event-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { appendRuntimeEvent, appendRuntimeEvents, hydrateRuntimeEvents } from './runtime-event-state'
import { appendRuntimeEvent, appendRuntimeEvents, hydrateRuntimeEvents, removeRuntimeEvents } from './runtime-event-state'
import type { AgentThreadRuntimeEventsResult, LumeRuntimeEvent } from '@lume/shared'

function runtimeEvent(event: Partial<LumeRuntimeEvent> & Pick<LumeRuntimeEvent, 'type'>): LumeRuntimeEvent {
Expand Down Expand Up @@ -290,6 +290,83 @@ describe('runtime-event-state', () => {
expect(hydrated['thread-1']?.terminalStatus).toBe('completed')
})

test('hydrate 同样受 MAX_EVENTS_PER_THREAD 上限约束', () => {
// 重开超长线程:sidecar 返回全量持久化事件,hydrate 后也必须 ≤ MAX,
// 与 append 路径上限一致(否则内存驻留无界,直到下一条 append 才触发 trim)。
const events: LumeRuntimeEvent[] = [
{
id: 'u0',
type: 'message.user.submitted',
threadId: 'thread-1',
runId: 'run-1',
createdAt: '2026-06-21T00:00:00.000Z',
text: 'hi',
} as LumeRuntimeEvent,
]
for (let i = 1; i <= 2500; i++) {
events.push({
id: `d${i}`,
type: 'assistant.delta',
threadId: 'thread-1',
runId: 'run-1',
messageId: `msg-${i}`,
sequence: i,
createdAt: '2026-06-21T00:00:00.000Z',
delta: `c${i}`,
} as LumeRuntimeEvent)
}
const hydrated = hydrateRuntimeEvents({}, { threadId: 'thread-1', events })

expect(hydrated['thread-1']?.events.length).toBeLessThanOrEqual(2000)
expect(hydrated['thread-1']?.events[0]?.type).toBe('message.user.submitted')
})

test('hydrate 先合并相邻同流 delta 再 trim:同一条消息的分片回放不触发上限、正文完整', () => {
// 回放路径没有 assistant.final,正文完全靠 delta 累积;若不先按 live 规则合并,
// 逐 chunk 的回放计数会膨胀到上限,trim 丢头部 delta = 该 turn 渲染为空泡。
const events: LumeRuntimeEvent[] = [
{
id: 'u0',
type: 'message.user.submitted',
threadId: 'thread-1',
runId: 'run-1',
createdAt: '2026-06-21T00:00:00.000Z',
text: 'hi',
} as LumeRuntimeEvent,
]
let full = ''
for (let i = 1; i <= 2500; i++) {
events.push({
id: `d${i}`,
type: 'assistant.delta',
threadId: 'thread-1',
runId: 'run-1',
messageId: 'msg-1',
sequence: i,
createdAt: '2026-06-21T00:00:00.000Z',
delta: `c${i};`,
} as LumeRuntimeEvent)
full += `c${i};`
}
const hydrated = hydrateRuntimeEvents({}, { threadId: 'thread-1', events })

const hydratedEvents = hydrated['thread-1']?.events ?? []
expect(hydratedEvents.length).toBe(2)
expect(hydratedEvents[1]?.type).toBe('assistant.delta')
expect(hydratedEvents[1]?.delta).toBe(full)
// 重开幂等:同样的回放再次 hydrate 不改变结果
expect(hydrateRuntimeEvents(hydrated, { threadId: 'thread-1', events })).toBe(hydrated)
})

test('removeRuntimeEvents 删除线程条目;条目不存在时返回原引用', () => {
const prev = appendRuntimeEvent({}, runtimeEvent({ type: 'run.completed' }))
const next = removeRuntimeEvents(prev, 'thread-1')

expect(next['thread-1']).toBeUndefined()
expect(removeRuntimeEvents(next, 'thread-1')).toBe(next)
expect(removeRuntimeEvents(next, 'other-thread')).toBe(next)
})

test('超过 MAX_EVENTS_PER_THREAD 时优先丢头部 delta,保留结构事件与 user 提交', () => {
let state: Record<string, { events: LumeRuntimeEvent[] }> = {}
const u0 = {
Expand Down
41 changes: 39 additions & 2 deletions apps/web/src/hooks/runtime-event-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ export function hydrateRuntimeEvents(
if (result.events.length === 0) {
return prev
}
const events = mergeHydratedRuntimeEvents(result.events, current?.events ?? [])
// 与 append 路径同上限:sidecar 回放不封顶,hydrate 不 trim 会让重开的超长线程
// 全量驻留内存(且直到下一条 append 前都无界)。merge 阶段已按 live 规则合并相邻
// 同流 delta(回放无 assistant.final,正文全靠 delta,先合并再 trim 才不会误伤)。
const events = trimRuntimeEvents(mergeHydratedRuntimeEvents(result.events, current?.events ?? []))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 数据丢失风险:hydrate 新增的 trim 会永久丢失头部 turn 的助手正文。

trimRuntimeEvents 的安全前提是"被丢的头部 delta 对应 turn 已有 assistant.final 重建文本"(见 230-233 行注释),但该前提只在 live 路径成立——sidecar 回放投影(apps/sidecar/src/services/agent-runtime/runner/run-item-events.ts:164-200)只产出 assistant.delta / assistant.thinking_delta,从不产出 assistant.final(在整个 apps/sidecar/src 里 grep assistant.final 为空;final 只由 web 侧 live adapter 合成)。而 web 投影中无 final 的助手文本完全靠 delta 累积(runtime-event-message-projection.ts:297-309,text += event.delta)。

后果:超过 2000 条回放事件的长线程重开(或 MESSAGE_APPENDED 每条消息全量回灌,useGlobalAgentListeners.ts:331)→ 头部 delta 被丢弃 → 这些 turn 的助手回复/思考渲染为空。PR 前 hydrate 不封顶、重开可见全量历史,这是回归。且回放按"每内容块一条 delta"产出、mergeHydratedRuntimeEvents 又不做同流合并,回放事件计数比 live 合并后更膨胀,上限比 live 更容易触发。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 follow-up 标记:渲染端 trim 只封内存,不封 IPC 载荷。

GET_THREAD_RUNTIME_EVENTS(apps/sidecar/src/rpc/agent-handlers.ts:780,listThreadRuntimeEvents 无 limit 参数)在每次 MESSAGE_APPENDED 时仍把整个持久化日志全量序列化、跨 IPC 传输、解析、merge,再被 trim 丢掉大部分。commit message 自己点名"sidecar 回放不封顶",但只修了渲染端一半——服务器侧(给 listThreadRuntimeEvents 加 limit/seq 下限,或 append 时不再全量回拉)建议留 follow-up 记录,避免一年后没人把长线程的 IPC 延迟归因到这。

if (current && sameRuntimeEvents(current.events, events)) {
return prev
}
Expand All @@ -91,6 +94,17 @@ export function hydrateRuntimeEvents(
}
}

/** 删除线程的 runtime events 条目(回收站/永久删除时调用,防止 Record 只增不减)。 */
export function removeRuntimeEvents(
prev: RuntimeEventState,
threadId: string,
): RuntimeEventState {
if (!(threadId in prev)) return prev
const next = { ...prev }
delete next[threadId]
return next
}

function mergeHydratedRuntimeEvents(
persistedEvents: LumeRuntimeEvent[],
liveEvents: LumeRuntimeEvent[],
Expand All @@ -103,7 +117,30 @@ function mergeHydratedRuntimeEvents(
seenIds.add(event.id)
merged.push(event)
}
return sortRuntimeEvents(merged)
// 回放按内容块/流事件逐条产出 delta,不像 live 路径边流边合并,计数远更膨胀——
// 直接 trim 会把头部 turn 的正文 delta 丢掉(回放没有 assistant.final 兜底重建,
// 投影 text 完全靠 delta 累积,丢了就是空泡)。先按 live 同规则合并相邻同流 delta,
// 让上限语义两条路径一致。
return mergeAdjacentStreamDeltas(sortRuntimeEvents(merged))
}

function mergeAdjacentStreamDeltas(events: LumeRuntimeEvent[]): LumeRuntimeEvent[] {
const merged: LumeRuntimeEvent[] = []
for (const event of events) {
const last = merged[merged.length - 1]
if (
last !== undefined
&& (last.type === 'assistant.delta' || last.type === 'assistant.thinking_delta')
&& (event.type === 'assistant.delta' || event.type === 'assistant.thinking_delta')
&& last.type === event.type
&& hasSameAssistantStreamOwner(last, event)
) {
merged[merged.length - 1] = { ...last, delta: last.delta + event.delta }
continue
}
merged.push(event)
}
return merged
}

function compareRuntimeEvents(a: LumeRuntimeEvent, b: LumeRuntimeEvent): number {
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/hooks/use-release-thread-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as React from 'react'
import { useSetAtom } from 'jotai'
import { agentInputDraftAtom, agentInputHistoryAtom, agentRuntimeEventsAtom } from '@/atoms'
import { removeRuntimeEvents } from '@/hooks/runtime-event-state'
import { threadMessagesCache } from '@/components/agent/thread-messages-cache'
import { removeDraft, removeHistory } from '@/lib/agent-input-draft-state'

/**
* 线程移入回收站/永久删除/随项目数据删除时的渲染端状态统一释放。
*
* 所有移除路径必须走这里而不是手拼子集:draft/history 是 atomWithStorage
* (localStorage,跨重启残留),runtimeEvents/messagesCache 是会话期内存驻留,
* 漏清任何一项都会孤儿化。恢复路径无需对称操作——重开线程时 hydrate 会重建。
*/
export function useReleaseThreadState() {
const setDraftState = useSetAtom(agentInputDraftAtom)
const setHistoryState = useSetAtom(agentInputHistoryAtom)
const setRuntimeEvents = useSetAtom(agentRuntimeEventsAtom)
return React.useCallback(
(threadId: string) => {
setDraftState((prev) => removeDraft(prev, threadId))
setHistoryState((prev) => removeHistory(prev, threadId))
setRuntimeEvents((prev) => removeRuntimeEvents(prev, threadId))
threadMessagesCache.invalidate(threadId)
},
[setDraftState, setHistoryState, setRuntimeEvents],
)
}
Loading