Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ To reduce frontend and executor memory pressure, runtime task lists, runtime han

Conversation rendering still uses `WorkbenchMessage` values produced from transcript loads and message actions. Task lists and status polling are for status, titles, running state, and workspace metadata. When investigating slow list refreshes or memory growth while switching tasks, first check whether raw messages or command output have been reintroduced into the runtime list, handle, or transcript metadata path.

### Pane Cache and Resource Lifetimes

The desktop workbench caches at most 10 ordinary panes and evicts them in least-recently-used order. An inactive pane that is no longer running releases transcript messages, historical DOM, pagination ranges, navigation indexes, and processing expansion state; returning to it reloads from the original runtime transcript. Historical messages far from the viewport in the current pane also retain only a height placeholder and restore their contents when scrolled near the viewport.

Terminal and built-in browser sessions are stateful active resources and do not follow ordinary pane eviction. A pane remains mounted while it owns a Terminal or browser tab so its terminal process and page session survive task switches. After the corresponding resources close, the pane is subject to the ordinary cache limit again. Changes to this boundary must continue to cover ordinary-pane LRU eviction, resource-pane retention, historical-message windowing, and the desktop memory E2E.

## Local Codex Streaming Logs

The local executor keeps Codex delta details enabled by default so developers can diagnose streaming order, phase classification, and final-content overwrite issues. By default, it records raw Codex delta events and run-state classification summaries.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ Debug 面板的快照会附带当前 runtime pane 的轻量内存摘要,用于

前端显示对话时仍以 transcript/message action 生成的 `WorkbenchMessage` 为准;任务列表和状态轮询只用于状态、标题、运行态和 workspace 信息。排查“列表很慢”或“切换任务内存上涨”时,应优先确认是否又把原始消息或命令输出加入了 runtime list/handle/transcript 元数据路径。

### Pane 缓存与资源生命周期

桌面工作台最多缓存 10 个普通 pane,并按最近使用顺序淘汰。非活跃且已停止运行的 pane 会释放 transcript 消息、历史 DOM、分页范围、导航索引和 processing 展开状态;再次切回时从 runtime transcript 原始数据重新加载。当前 pane 的远离视口历史消息也只保留高度占位,滚动靠近时再恢复内容。

Terminal 和内置浏览器属于有状态活动资源,不跟随普通 pane 淘汰。只要 pane 中仍有 Terminal 或浏览器标签,它就保持挂载,以保留终端进程和网页会话;关闭对应资源后,该 pane 才重新受普通缓存上限约束。修改这条边界时,必须同时覆盖普通 pane 的 LRU 淘汰、资源 pane 保活、历史消息窗口化和桌面内存 E2E。

## 本地 Codex 流式日志

本地 executor 的 Codex 调试日志默认保留 delta 详情,便于定位流式输出顺序、阶段识别和最终内容覆盖问题。默认会记录 Codex 原始 delta 与运行态分类摘要。
Expand Down
15 changes: 7 additions & 8 deletions wework/e2e/desktop/task-flow.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ const MEMORY_PROMPT = 'WEWORK_DESKTOP_E2E_MEMORY: run a tool and stream the repo
const MEMORY_COMPLETION_TEXT = 'WEWORK_DESKTOP_E2E_MEMORY_COMPLETE'
const MEMORY_SAMPLE_INTERVAL_MS = 500
const MEMORY_MAX_PEAK_GROWTH_KIB = Number(
process.env.WEWORK_E2E_MEMORY_MAX_PEAK_GROWTH_KIB ?? 512 * 1024
process.env.WEWORK_E2E_MEMORY_MAX_PEAK_GROWTH_KIB ?? 256 * 1024
)
const MEMORY_MAX_SETTLED_GROWTH_KIB = Number(
process.env.WEWORK_E2E_MEMORY_MAX_SETTLED_GROWTH_KIB ?? 256 * 1024
process.env.WEWORK_E2E_MEMORY_MAX_SETTLED_GROWTH_KIB ?? 160 * 1024
)
const ARTIFACT_NAME = 'wework-e2e-result.txt'
const ARTIFACT_CONTENT = 'CODEX_EXECUTED_REAL_TOOL'
Expand Down Expand Up @@ -384,7 +384,9 @@ async function verifyMemoryGrowth({ composerSelector, control }) {
assert.equal(process.platform, 'darwin', 'Desktop memory E2E currently requires macOS')
control.setScenario('memory')
const samples = [await captureMemorySample(control, 'baseline')]
await captureVerificationScreenshot(control, 'memory-01-baseline.png')
await sendPromptUntilScenarioRequest(control, composerSelector, MEMORY_PROMPT, 'memory')
await captureVerificationScreenshot(control, 'memory-02-streaming.png')

let completed = false
const startedAt = Date.now()
Expand All @@ -395,6 +397,7 @@ async function verifyMemoryGrowth({ composerSelector, control }) {
completed = snapshot.text.includes(MEMORY_COMPLETION_TEXT)
}
assert.equal(completed, true, 'The memory E2E response did not complete')
await captureVerificationScreenshot(control, 'memory-03-completed.png')

for (let index = 0; index < 5; index += 1) {
await new Promise(resolvePromise => setTimeout(resolvePromise, 1_000))
Expand All @@ -408,6 +411,7 @@ async function verifyMemoryGrowth({ composerSelector, control }) {
const settledSamples = samples.filter(sample => sample.phase === 'settled')
const settled = settledSamples.at(-1)
assert.ok(settled, 'The memory E2E did not capture settled samples')
await captureVerificationScreenshot(control, 'memory-04-settled.png')
const peakGrowthKiB = peak.physicalFootprintKiB - baseline.physicalFootprintKiB
const settledGrowthKiB = settled.physicalFootprintKiB - baseline.physicalFootprintKiB
const settledDriftKiB = settled.physicalFootprintKiB - settledSamples[0].physicalFootprintKiB
Expand Down Expand Up @@ -438,7 +442,7 @@ async function verifyMemoryGrowth({ composerSelector, control }) {
`WebContent settled physical footprint grew by ${settledGrowthKiB} KiB`
)
assert.ok(
settledDriftKiB <= 32 * 1024,
settledDriftKiB <= 16 * 1024,
`WebContent kept growing after completion by ${settledDriftKiB} KiB`
)
}
Expand Down Expand Up @@ -732,11 +736,6 @@ async function selectE2EModel(control, modelId = MODEL_ID, modelLabel = MODEL_LA
await control.command('waitFor', `[data-testid="model-option-${modelId}"]`, {
timeoutMs: UI_TIMEOUT_MS,
})
for (const localModel of LOCAL_MODEL_CASES) {
await control.command('waitFor', `[data-testid="model-option-${localModel.optionId}"]`, {
timeoutMs: UI_TIMEOUT_MS,
})
}
await control.command('click', `[data-testid="model-option-${modelId}"]`)
await control.command('waitFor', '[data-testid="model-selector-button"]', {
text: modelLabel,
Expand Down
42 changes: 42 additions & 0 deletions wework/src/components/chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,48 @@ describe('MessageList', () => {
}
})

test('unmounts distant Tauri message contents and restores them near the viewport', async () => {
tauriCoreMock.isTauri = vi.fn(() => true)
const callbacks: IntersectionObserverCallback[] = []
class IntersectionObserverMock {
constructor(callback: IntersectionObserverCallback) {
callbacks.push(callback)
}
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
takeRecords = vi.fn(() => [])
root = null
rootMargin = '1200px 0px'
thresholds = [0]
}
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)

render(
<MessageList
messages={Array.from({ length: 6 }, (_, index) => ({
id: `assistant-windowed-${index}`,
role: 'assistant' as const,
content: `message ${index}`,
status: 'done' as const,
createdAt: `2026-06-11T10:00:0${index}Z`,
}))}
/>
)

const articles = screen.getAllByTestId('message-assistant')
expect(articles[0]).toBeEmptyDOMElement()
expect(articles[1]).toBeEmptyDOMElement()
expect(articles[2]).toHaveTextContent('message 2')
expect(callbacks).toHaveLength(2)

await act(async () => {
callbacks[0]([{ isIntersecting: true } as IntersectionObserverEntry], {} as never)
})

expect(articles[0]).toHaveTextContent('message 0')
})

test('keeps message row containment during a plain text click', () => {
const getSelectionSpy = vi.spyOn(document, 'getSelection')
getSelectionSpy.mockReturnValue({
Expand Down
97 changes: 83 additions & 14 deletions wework/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ const USER_MESSAGE_COLLAPSE_LINES = 10
const USER_MESSAGE_COLLAPSE_CHARACTERS = 600
const MESSAGE_LAYOUT_RESIZE_SETTLE_MS = 120
const SELECTION_ACTION_GAP = 8
const MESSAGE_WINDOW_ROOT_MARGIN = '1200px 0px'
const ALWAYS_MOUNT_RECENT_MESSAGE_COUNT = 4

interface MessageTextSelection {
text: string
Expand Down Expand Up @@ -198,8 +200,7 @@ export const MessageList = memo(function MessageList({
isWaitingForAssistant &&
waitingForAssistantTurn &&
!messages.some(message => message.role === 'assistant' && message.status === 'streaming')
const disableMessageContentVisibility =
disableContentVisibility || isTextSelectionActive || isTauri
const windowMessages = isTauri && !disableContentVisibility && !isTextSelectionActive
const messageIntrinsicHeights = useMemo(() => {
return new Map(
visibleMessages.map(message => [
Expand Down Expand Up @@ -373,19 +374,19 @@ export const MessageList = memo(function MessageList({
)}
{visibleMessages.map((message, index) => {
const nextMessage = visibleMessages[index + 1]
const forceMounted =
index >= visibleMessages.length - ALWAYS_MOUNT_RECENT_MESSAGE_COUNT ||
message.status === 'streaming' ||
message.id === activeEditingMessageId ||
message.id === activeSubmittingEditMessageId
return (
<Fragment key={message.id}>
<article
className={[
'min-w-0',
disableMessageContentVisibility ? '' : '[content-visibility:auto]',
message.role === 'user' ? 'flex justify-end' : '',
].join(' ')}
style={
disableMessageContentVisibility
? undefined
: getMessageContainmentStyle(messageIntrinsicHeights.get(message.id))
}
<WindowedMessageArticle
enabled={windowMessages}
estimatedHeight={messageIntrinsicHeights.get(message.id)}
forceMounted={forceMounted}
messageRole={message.role}
useContentVisibility={!isTauri && !disableContentVisibility && !isTextSelectionActive}
data-message-id={message.id}
data-testid={`message-${message.role}`}
>
Expand Down Expand Up @@ -436,7 +437,7 @@ export const MessageList = memo(function MessageList({
hiddenRequestUserInputIds={hiddenRequestUserInputIds}
/>
)}
</article>
</WindowedMessageArticle>
{renderGapAfterMessage?.(message, nextMessage)}
</Fragment>
)
Expand All @@ -450,6 +451,74 @@ export const MessageList = memo(function MessageList({
)
}, areMessageListPropsEqual)

function WindowedMessageArticle({
enabled,
estimatedHeight,
forceMounted,
messageRole,
useContentVisibility,
children,
...attributes
}: {
enabled: boolean
estimatedHeight: number | undefined
forceMounted: boolean
messageRole: WorkbenchMessage['role']
useContentVisibility: boolean
children: ReactNode
'data-message-id': string
'data-testid': string
}) {
const articleRef = useRef<HTMLElement>(null)
const canObserve = enabled && typeof IntersectionObserver !== 'undefined'
const [nearViewport, setNearViewport] = useState(!canObserve || forceMounted)
const [retainedHeight, setRetainedHeight] = useState<number | null>(null)
const mounted = forceMounted || !canObserve || nearViewport

useEffect(() => {
if (!canObserve || forceMounted) return

const article = articleRef.current
if (!article) return
const observer = new IntersectionObserver(
entries => {
const entry = entries[0]
if (!entry) return
if (!entry.isIntersecting) {
const height = article.getBoundingClientRect().height
if (height > 0) setRetainedHeight(height)
}
setNearViewport(entry.isIntersecting)
},
{ rootMargin: MESSAGE_WINDOW_ROOT_MARGIN }
)
observer.observe(article)
return () => observer.disconnect()
}, [canObserve, forceMounted])

const placeholderHeight = Math.ceil(retainedHeight ?? estimatedHeight ?? 220)
return (
<article
ref={articleRef}
className={cn(
'min-w-0',
useContentVisibility && '[content-visibility:auto]',
messageRole === 'user' && 'flex justify-end'
)}
style={
mounted
? useContentVisibility
? getMessageContainmentStyle(estimatedHeight)
: undefined
: { minHeight: placeholderHeight }
}
{...attributes}
>
{mounted ? children : null}
</article>
)
}

function getMessageContainmentStyle(estimatedHeight: number | undefined): CSSProperties {
return {
containIntrinsicSize: `0 ${Math.ceil(estimatedHeight ?? 220)}px`,
Expand Down
22 changes: 14 additions & 8 deletions wework/src/components/chat/blocks/processingExpansionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ export function usePersistentProcessingExpansion(
initialValue = false
): readonly [boolean, (update: ExpansionUpdate) => void] {
const [localExpanded, setLocalExpanded] = useState(initialValue)
const subscribe = useCallback((listener: () => void) => {
if (!key) return () => {}
expansionStateListeners.add(listener)
return () => expansionStateListeners.delete(listener)
}, [key])
const getSnapshot = useCallback(
() => readExpansionState(key, initialValue),
[initialValue, key]
const subscribe = useCallback(
(listener: () => void) => {
if (!key) return () => {}
expansionStateListeners.add(listener)
return () => expansionStateListeners.delete(listener)
},
[key]
)
const getSnapshot = useCallback(() => readExpansionState(key, initialValue), [initialValue, key])
const persistedExpanded = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const expanded = key ? persistedExpanded : localExpanded

Expand All @@ -41,6 +41,12 @@ export function usePersistentProcessingExpansion(
return [expanded, setPersistentExpanded]
}

export function clearPersistentProcessingExpansions() {
if (expansionStateByKey.size === 0) return
expansionStateByKey.clear()
emitExpansionStateChange()
}

function readExpansionState(key: string | undefined, initialValue: boolean): boolean {
if (!key) return initialValue
return expansionStateByKey.get(key) ?? initialValue
Expand Down
Loading
Loading