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
2 changes: 2 additions & 0 deletions docs/en/wework/developer-guide/wework-chat-state-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ The right workspace **Temporary chat** feature starts a short side conversation

- Each temporary chat tab has an independent `chat:<id>` instance id, so the right workspace can hold multiple temporary chats at the same time.
- UI state lives inside `TemporaryChatPanel`, using the instance id as the `conversationKey` before a runtime thread exists. Hidden temporary chat tabs stay mounted so local messages and input state are not lost when switching tabs.
- Attachment selection, upload progress, and errors are also isolated per temporary-chat instance and must not reuse the main composer attachment state. The first message passes that instance's attachments explicitly to `createTemporaryRuntimeTask`.
- When a temporary chat is the only open right-workspace tab, the panel defaults to a compact `420px` width. Opening another workspace tab restores the general split default, while a user-resized width remains authoritative.
- The first message calls `createTemporaryRuntimeTask`, creating an `ephemeral` runtime task with the current main thread as `sideSource`. This task does not enter the left task list and does not navigate the main pane.
- Follow-up messages must continue the already loaded temporary thread. The Codex app-server path uses `direct_thread_id` and calls `turn/start` directly; it must not use the normal `resume_thread_id` / `thread/resume` path, because temporary threads do not have rollout mappings and would otherwise fail with `no rollout found`.
- Temporary chats reuse only the current workspace and current thread context. If no main thread source is available, sending should be blocked and the user should be asked to open an existing conversation first.
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/wework/developer-guide/wework-chat-state-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ Wework 的聊天 UI 不能把持续输出的完整正文长期保存在 React st

- 每个临时聊天 tab 都有独立的 `chat:<id>` 实例标识,允许在右侧工作区同时打开多个临时聊天。
- UI 状态保存在 `TemporaryChatPanel` 内部,并以实例标识作为未创建 runtime 线程前的 `conversationKey`;切换 tab 时面板保持挂载,避免丢失本地消息和输入状态。
- 每个临时聊天的附件选择、上传进度和错误状态也按实例隔离,不能复用主聊天 composer 的附件状态;首条消息必须把该实例的附件显式传给 `createTemporaryRuntimeTask`。
- 右侧工作区只打开一个临时聊天时,默认使用紧凑的 `420px` 面板宽度;打开其他工作区 tab 后恢复通用分栏默认值,用户手动调整的宽度仍然优先。
- 首条消息通过 `createTemporaryRuntimeTask` 创建 `ephemeral` runtime task,并携带当前主线程的 `sideSource`。该任务不写入左侧任务列表,也不触发主 pane 导航。
- 后续消息必须继续使用已加载的临时线程。Codex app-server 路径使用 `direct_thread_id` 直接 `turn/start`,不能走普通 `resume_thread_id` 的 `thread/resume` 路径,否则会因为临时线程没有 rollout 映射而出现 `no rollout found`。
- 临时聊天只复用当前工作区和当前线程上下文;如果没有可用的主线程 source,应阻止发送并提示用户先打开已有对话。
Expand Down
25 changes: 22 additions & 3 deletions wework/scripts/ai-verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { createServer } from 'node:http'
import { randomBytes, randomUUID } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { execFile, spawn } from 'node:child_process'
import { dirname, join, resolve } from 'node:path'
import { basename, dirname, extname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildAiVerifyEnvironment } from './ai-verify-environment.mjs'

Expand All @@ -26,7 +26,7 @@ const corsHeaders = {
function usage() {
console.error(`Usage:
pnpm --filter wework ai:verify start
pnpm --filter wework ai:verify <capture|snapshot|click|close-to-tray|drag|fill|hover|navigate|pointer-move|press|select-text|wait-for|text|status|stop> --session PATH [options]
pnpm --filter wework ai:verify <capture|snapshot|click|close-to-tray|drag|drop-file|fill|hover|navigate|pointer-move|press|select-text|wait-for|text|status|stop> --session PATH [options]

Options:
--codex-home-initialization true
Expand All @@ -35,9 +35,12 @@ Options:
--value TEXT Replacement value for fill
--target SELECTOR Event target selector for pointer-move (default: body)
Required destination selector for drag
--file PATH File to dispatch for drop-file
--key KEY Keyboard key for press
--output PATH PNG output path for capture
--text TEXT Expected text for wait-for
--visible true Require a visible element for wait-for
--stable MS Require the wait-for condition to remain stable
--timeout MS Command timeout (default: ${defaultTimeoutMs})`)
}

Expand Down Expand Up @@ -325,6 +328,7 @@ async function main() {
click: 'click',
'close-to-tray': 'closeMainWindowToTray',
drag: 'drag',
'drop-file': 'dropFile',
fill: 'fill',
hover: 'hover',
navigate: 'navigate',
Expand All @@ -350,13 +354,28 @@ async function main() {
? 'body'
: null)
if (!selector) throw new Error('--selector is required')
const dropFilePath = command === 'drop-file' ? options.file : undefined
if (command === 'drop-file' && !dropFilePath) throw new Error('--file is required')
const dropFileExtension = dropFilePath ? extname(dropFilePath).toLowerCase() : ''
const dropFileMimeType =
dropFileExtension === '.png'
? 'image/png'
: dropFileExtension === '.jpg' || dropFileExtension === '.jpeg'
? 'image/jpeg'
: dropFileExtension === '.txt'
? 'text/plain'
: 'application/octet-stream'
const value = await request(session, session.token, '/command', 'POST', {
action,
selector,
target: options.target,
value: options.value,
value: dropFilePath ? (await readFile(dropFilePath)).toString('base64') : options.value,
filename: dropFilePath ? basename(dropFilePath) : undefined,
mimeType: dropFilePath ? dropFileMimeType : undefined,
key: options.key,
text: options.text,
visible: options.visible === 'true',
stableMs: options.stable ? Number(options.stable) : undefined,
timeoutMs: options.timeout ? Number(options.timeout) : undefined,
})
if (command === 'capture') {
Expand Down
36 changes: 34 additions & 2 deletions wework/src/components/layout/DesktopWorkbenchLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,18 @@ describe('DesktopWorkbenchLayout', () => {
}
const workbenchValue = {
services: {
attachmentApi: {
uploadAttachment: vi.fn().mockImplementation(async (file: File) => ({
id: 91,
filename: file.name,
file_size: file.size,
mime_type: file.type,
status: 'ready',
file_extension: file.name.split('.').pop() ?? '',
created_at: '2026-07-22T00:00:00Z',
})),
deleteAttachment: vi.fn().mockResolvedValue(undefined),
},
workspaceSessionApi: {
startProjectTerminal: startTerminalSessionMock,
startProjectCodeServer: startCodeServerSessionMock,
Expand Down Expand Up @@ -4538,14 +4550,34 @@ describe('DesktopWorkbenchLayout', () => {
})

test('right workspace can open multiple temporary chat tabs', async () => {
renderWorkspacePanelLayout()
renderWorkspacePanelLayout({ mainWidth: 1000 })

await userEvent.click(screen.getByTestId('toggle-right-workspace-panel-button'))
await userEvent.click(screen.getByTestId('right-workspace-chat-option'))

const tabbar = screen.getByTestId('right-workspace-tabbar')
expect(screen.getByTestId('right-workspace-chat-panel')).toBeInTheDocument()
const sideChat = screen.getByTestId('right-workspace-chat-panel')
expect(sideChat).toBeInTheDocument()
expect(within(tabbar).getAllByText('临时聊天')).toHaveLength(1)
await waitFor(() => {
expect(screen.getByTestId('desktop-workbench-content')).toHaveStyle({ width: '580px' })
expect(screen.getByTestId('right-workspace-panel-shell')).toHaveStyle({
width: 'calc(100% - 580px)',
})
})

await userEvent.upload(
within(sideChat).getByTestId('attachment-file-input'),
new File(['side chat'], 'side-chat.txt', { type: 'text/plain' })
)

expect(await within(sideChat).findByTestId('attachment-badge')).toBeInTheDocument()
expect(within(sideChat).getByTestId('attachment-text-preview')).toHaveAttribute(
'title',
'side chat'
)
expect(baseProps.projectChat.handleFileSelect).not.toHaveBeenCalled()
expect(screen.getAllByTestId('attachment-badge')).toHaveLength(1)

await userEvent.click(screen.getByTestId('right-workspace-new-tab-button'))
await userEvent.click(
Expand Down
6 changes: 6 additions & 0 deletions wework/src/components/layout/DesktopWorkbenchMain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ const DOCKED_ENVIRONMENT_INFO_WIDTH = 320
const MIN_CHAT_COLUMN_WIDTH_FOR_DOCKED_ENVIRONMENT_INFO = 680
const MAX_CACHED_DESKTOP_WORKBENCH_TABS = 20
const COLLAPSED_RIGHT_TITLEBAR_ACTIONS_CLEARANCE = '5rem'
const TEMPORARY_CHAT_PANEL_DEFAULT_WIDTH = 420
const MACOS_TRAFFIC_LIGHTS_CLEARANCE_CLASS = 'pl-[92px]'
const BLANK_BROWSER_MIGRATION_TTL_MS = 2 * 60 * 1000

Expand Down Expand Up @@ -495,6 +496,10 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({
reloadDiff: undefined,
})
const closeRightPanel = useCallback(() => setRightPanelOpen(false), [setRightPanelOpen])
const onlyTemporaryChatOpen =
rightPanelTabs.length === 1 &&
rightPanelTabs[0].startsWith('chat:') &&
rightPanelView === rightPanelTabs[0]
useLayoutEffect(() => {
const workbenchMain = workbenchMainRef.current
if (!workbenchMain) return
Expand All @@ -517,6 +522,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({
} = useResizableRightSplitChat({
containerRef: workbenchMainRef,
onCollapse: closeRightPanel,
defaultPanelWidth: onlyTemporaryChatOpen ? TEMPORARY_CHAT_PANEL_DEFAULT_WIDTH : undefined,
})
const chatColumnWidth = rightPanelOpen ? rightSplitChatWidth : '100%'
const availableChatColumnWidth = rightPanelOpen ? rightSplitChatWidth : workbenchContentWidth
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { MessageCircle } from 'lucide-react'
import { ScrollableMessageArea } from '@/components/chat/ScrollableMessageArea'
import { BufferedChatInput } from '@/components/layout/BufferedChatInput'
import { useWorkbenchPaneContext } from '@/features/workbench/useWorkbench'
import { useWorkbenchAttachments } from '@/features/workbench/useWorkbenchAttachments'
import type { RuntimePaneMessageAction } from '@/features/workbench/runtimePaneMessages'
import { selectedModelExecutionFields } from '@/features/workbench/runtimeModelSelection'
import { localRuntimeAttachments, remoteAttachmentIds } from '@/lib/runtime-attachments'
Expand Down Expand Up @@ -48,6 +49,7 @@ export function TemporaryChatPanel({
initialInput = '',
}: TemporaryChatPanelProps) {
const {
services,
state,
projectChat,
createTemporaryRuntimeTask,
Expand All @@ -56,6 +58,25 @@ export function TemporaryChatPanel({
subscribeRuntimeTaskStream,
loadRuntimeTranscriptForPane,
} = useWorkbenchPaneContext()
const attachmentSelection = useWorkbenchAttachments({
uploadAttachment: services.attachmentApi?.uploadAttachment,
deleteAttachment: services.attachmentApi?.deleteAttachment,
scopeKey: instanceId,
})
const sideChatProjectChat = useMemo(
() => ({
...projectChat,
attachments: attachmentSelection.attachments,
uploadingFiles: attachmentSelection.uploadingFiles,
errors: attachmentSelection.errors,
isAttachmentReadyToSend: attachmentSelection.isAttachmentReadyToSend,
handleFileSelect: attachmentSelection.handleFileSelect,
addExistingAttachment: attachmentSelection.addExistingAttachment,
removeAttachment: attachmentSelection.removeAttachment,
resetAttachments: attachmentSelection.resetAttachments,
}),
[attachmentSelection, projectChat]
)
const [address, setAddress] = useState<RuntimeTaskAddress | null>(null)
const [messages, setMessages] = useState<WorkbenchMessage[]>([])
const [input, setInput] = useState(initialInput)
Expand Down Expand Up @@ -192,7 +213,7 @@ export function TemporaryChatPanel({
setMessages(current => [...current, createUserMessage(message)])
setSending(true)

const currentAttachments = projectChat.attachments
const currentAttachments = sideChatProjectChat.attachments
const attachmentIds = remoteAttachmentIds(currentAttachments)
const attachments = localRuntimeAttachments(currentAttachments)
const handleError = (message: string) => {
Expand All @@ -205,13 +226,14 @@ export function TemporaryChatPanel({
(await createTemporaryRuntimeTask(message, {
project: currentProject,
source,
attachments: currentAttachments,
onError: handleError,
}))

if (!targetAddress) return
if (!address) {
setAddress(targetAddress)
projectChat.resetAttachments()
sideChatProjectChat.resetAttachments()
return
}

Expand All @@ -227,7 +249,7 @@ export function TemporaryChatPanel({
{ onError: handleError }
)
if (sent) {
projectChat.resetAttachments()
sideChatProjectChat.resetAttachments()
} else {
setSending(false)
}
Expand All @@ -237,7 +259,7 @@ export function TemporaryChatPanel({
createTemporaryRuntimeTask,
currentProject,
input,
projectChat,
sideChatProjectChat,
selectedModelFields,
sendRuntimePaneMessage,
source,
Expand Down Expand Up @@ -280,7 +302,7 @@ export function TemporaryChatPanel({
error={error}
placeholder="要求后续变更"
variant="desktop"
projectChat={projectChat}
projectChat={sideChatProjectChat}
showProjectWorkBar={false}
isStreaming={sending}
onPause={pause}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ function getRightSplitChatMaxWidth(containerWidth: number) {
return Math.max(RIGHT_SPLIT_CHAT_MIN_WIDTH, containerWidth - RIGHT_SPLIT_PANEL_COLLAPSE_WIDTH)
}

function getRightSplitChatDefaultWidth(containerWidth: number) {
function getRightSplitChatDefaultWidth(containerWidth: number, defaultPanelWidth?: number) {
if (containerWidth <= 0) return RIGHT_SPLIT_CHAT_DEFAULT_WIDTH

return clamp(
RIGHT_SPLIT_CHAT_DEFAULT_WIDTH,
defaultPanelWidth === undefined
? RIGHT_SPLIT_CHAT_DEFAULT_WIDTH
: containerWidth - defaultPanelWidth,
RIGHT_SPLIT_CHAT_MIN_WIDTH,
getRightSplitChatMaxWidth(containerWidth)
)
Expand All @@ -38,11 +40,13 @@ function getRightSplitChatDefaultWidth(containerWidth: number) {
interface ResizableRightSplitChatOptions {
containerRef?: RefObject<HTMLElement | null>
onCollapse?: () => void
defaultPanelWidth?: number
}

export function useResizableRightSplitChat({
containerRef,
onCollapse,
defaultPanelWidth,
}: ResizableRightSplitChatOptions = {}) {
const [width, setWidth] = useState(RIGHT_SPLIT_CHAT_DEFAULT_WIDTH)
const [resizing, setResizing] = useState(false)
Expand All @@ -55,7 +59,9 @@ export function useResizableRightSplitChat({

const applyDefaultWidth = () => {
if (userSizedRef.current) return
setWidth(getRightSplitChatDefaultWidth(container.getBoundingClientRect().width))
setWidth(
getRightSplitChatDefaultWidth(container.getBoundingClientRect().width, defaultPanelWidth)
)
}

applyDefaultWidth()
Expand All @@ -64,7 +70,7 @@ export function useResizableRightSplitChat({
const observer = new ResizeObserver(applyDefaultWidth)
observer.observe(container)
return () => observer.disconnect()
}, [containerRef])
}, [containerRef, defaultPanelWidth])

useEffect(() => {
return () => {
Expand Down Expand Up @@ -102,7 +108,7 @@ export function useResizableRightSplitChat({
const applyCollapse = () => {
collapseFrameRef.current = null
userSizedRef.current = false
setWidth(getRightSplitChatDefaultWidth(containerWidth))
setWidth(getRightSplitChatDefaultWidth(containerWidth, defaultPanelWidth))
onCollapse?.()
}

Expand Down
25 changes: 25 additions & 0 deletions wework/src/features/workbench/useWorkbenchRuntimeMessaging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest'
import { runtimeThreadId } from './useWorkbenchRuntimeMessaging'

describe('runtimeThreadId', () => {
test('uses the direct thread id from a hydrated runtime task address', () => {
expect(
runtimeThreadId({
deviceId: 'local-device',
taskId: 'task-1',
threadId: 'thread-1',
runtimeHandle: { modelSelection: { modelName: 'gpt-5' } },
})
).toBe('thread-1')
})

test('falls back to the runtime handle thread id', () => {
expect(
runtimeThreadId({
deviceId: 'local-device',
taskId: 'task-1',
runtimeHandle: { threadId: 'thread-from-handle' },
})
).toBe('thread-from-handle')
})
})
7 changes: 5 additions & 2 deletions wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ function isLocalDeviceTarget(
return device?.device_type === 'local'
}

function runtimeThreadId(address?: RuntimeTaskAddress | null): string | null {
export function runtimeThreadId(address?: RuntimeTaskAddress | null): string | null {
if (typeof address?.threadId === 'string' && address.threadId.trim()) {
return address.threadId
}
const handle = address?.runtimeHandle
if (!isRecord(handle)) return null
const threadId = handle.sessionId ?? handle.session_id ?? handle.threadId ?? handle.thread_id
Expand Down Expand Up @@ -1012,7 +1015,7 @@ export function useWorkbenchRuntimeMessaging({
return false
}

const prepared = buildSendPayload(message, undefined, options?.project)
const prepared = buildSendPayload(message, options?.attachments, options?.project)
if (!prepared) {
reportSendBlocked(
'Wework default team is not configured',
Expand Down
1 change: 1 addition & 0 deletions wework/src/features/workbench/workbenchContextTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export interface SendCurrentInputOptions {
export interface CreateTemporaryRuntimeTaskOptions {
project?: ProjectWithTasks | null
source?: RuntimeTaskAddress | null
attachments?: Attachment[]
onError?: (error: string) => void
}

Expand Down
2 changes: 2 additions & 0 deletions wework/src/features/workbench/workbenchReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@ describe('workbenchReducer', () => {
tasks: [
{
taskId: 'task-1',
threadId: 'direct-thread-id',
workspacePath: '/Users/me/chat',
title: 'Chat',
runtime: 'codex',
Expand All @@ -669,6 +670,7 @@ describe('workbenchReducer', () => {
expect(refreshed.currentRuntimeTask).toEqual({
deviceId: 'device-uuid',
taskId: 'task-1',
threadId: 'direct-thread-id',
workspacePath: '/Users/me/chat',
runtimeHandle: {
threadId: '019ee7f6-456a-78a1-96b1-66451afc310e',
Expand Down
Loading
Loading