diff --git a/docs/en/wework/developer-guide/wework-chat-state-sources.md b/docs/en/wework/developer-guide/wework-chat-state-sources.md index 0d0af48777a..1c279552132 100644 --- a/docs/en/wework/developer-guide/wework-chat-state-sources.md +++ b/docs/en/wework/developer-guide/wework-chat-state-sources.md @@ -104,6 +104,8 @@ The right workspace **Temporary chat** feature starts a short side conversation - Each temporary chat tab has an independent `chat:` 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. diff --git a/docs/zh/wework/developer-guide/wework-chat-state-sources.md b/docs/zh/wework/developer-guide/wework-chat-state-sources.md index b9f6f303fa0..3eb2b56109b 100644 --- a/docs/zh/wework/developer-guide/wework-chat-state-sources.md +++ b/docs/zh/wework/developer-guide/wework-chat-state-sources.md @@ -102,6 +102,8 @@ Wework 的聊天 UI 不能把持续输出的完整正文长期保存在 React st - 每个临时聊天 tab 都有独立的 `chat:` 实例标识,允许在右侧工作区同时打开多个临时聊天。 - 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,应阻止发送并提示用户先打开已有对话。 diff --git a/wework/scripts/ai-verify.mjs b/wework/scripts/ai-verify.mjs index aef23d5a8c0..07cf1338774 100644 --- a/wework/scripts/ai-verify.mjs +++ b/wework/scripts/ai-verify.mjs @@ -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' @@ -26,7 +26,7 @@ const corsHeaders = { function usage() { console.error(`Usage: pnpm --filter wework ai:verify start - pnpm --filter wework ai:verify --session PATH [options] + pnpm --filter wework ai:verify --session PATH [options] Options: --codex-home-initialization true @@ -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})`) } @@ -325,6 +328,7 @@ async function main() { click: 'click', 'close-to-tray': 'closeMainWindowToTray', drag: 'drag', + 'drop-file': 'dropFile', fill: 'fill', hover: 'hover', navigate: 'navigate', @@ -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') { diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx index 9fb192fc464..70fc1a9ec62 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx @@ -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, @@ -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( diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx index 60de69a6928..23e95de3e7e 100644 --- a/wework/src/components/layout/DesktopWorkbenchMain.tsx +++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx @@ -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 @@ -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 @@ -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 diff --git a/wework/src/components/layout/workspace-panels/TemporaryChatPanel.tsx b/wework/src/components/layout/workspace-panels/TemporaryChatPanel.tsx index 9a7b01c5736..50192146a54 100644 --- a/wework/src/components/layout/workspace-panels/TemporaryChatPanel.tsx +++ b/wework/src/components/layout/workspace-panels/TemporaryChatPanel.tsx @@ -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' @@ -48,6 +49,7 @@ export function TemporaryChatPanel({ initialInput = '', }: TemporaryChatPanelProps) { const { + services, state, projectChat, createTemporaryRuntimeTask, @@ -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(null) const [messages, setMessages] = useState([]) const [input, setInput] = useState(initialInput) @@ -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) => { @@ -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 } @@ -227,7 +249,7 @@ export function TemporaryChatPanel({ { onError: handleError } ) if (sent) { - projectChat.resetAttachments() + sideChatProjectChat.resetAttachments() } else { setSending(false) } @@ -237,7 +259,7 @@ export function TemporaryChatPanel({ createTemporaryRuntimeTask, currentProject, input, - projectChat, + sideChatProjectChat, selectedModelFields, sendRuntimePaneMessage, source, @@ -280,7 +302,7 @@ export function TemporaryChatPanel({ error={error} placeholder="要求后续变更" variant="desktop" - projectChat={projectChat} + projectChat={sideChatProjectChat} showProjectWorkBar={false} isStreaming={sending} onPause={pause} diff --git a/wework/src/components/layout/workspace-panels/useResizableWorkspacePanel.ts b/wework/src/components/layout/workspace-panels/useResizableWorkspacePanel.ts index 625fc2c5445..df35415cc98 100644 --- a/wework/src/components/layout/workspace-panels/useResizableWorkspacePanel.ts +++ b/wework/src/components/layout/workspace-panels/useResizableWorkspacePanel.ts @@ -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) ) @@ -38,11 +40,13 @@ function getRightSplitChatDefaultWidth(containerWidth: number) { interface ResizableRightSplitChatOptions { containerRef?: RefObject 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) @@ -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() @@ -64,7 +70,7 @@ export function useResizableRightSplitChat({ const observer = new ResizeObserver(applyDefaultWidth) observer.observe(container) return () => observer.disconnect() - }, [containerRef]) + }, [containerRef, defaultPanelWidth]) useEffect(() => { return () => { @@ -102,7 +108,7 @@ export function useResizableRightSplitChat({ const applyCollapse = () => { collapseFrameRef.current = null userSizedRef.current = false - setWidth(getRightSplitChatDefaultWidth(containerWidth)) + setWidth(getRightSplitChatDefaultWidth(containerWidth, defaultPanelWidth)) onCollapse?.() } diff --git a/wework/src/features/workbench/useWorkbenchRuntimeMessaging.test.ts b/wework/src/features/workbench/useWorkbenchRuntimeMessaging.test.ts new file mode 100644 index 00000000000..93456e32108 --- /dev/null +++ b/wework/src/features/workbench/useWorkbenchRuntimeMessaging.test.ts @@ -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') + }) +}) diff --git a/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts b/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts index 5a566096081..03de6b6d39b 100644 --- a/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts +++ b/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts @@ -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 @@ -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', diff --git a/wework/src/features/workbench/workbenchContextTypes.ts b/wework/src/features/workbench/workbenchContextTypes.ts index 1dbaa7e3efe..6223f64f1ae 100644 --- a/wework/src/features/workbench/workbenchContextTypes.ts +++ b/wework/src/features/workbench/workbenchContextTypes.ts @@ -87,6 +87,7 @@ export interface SendCurrentInputOptions { export interface CreateTemporaryRuntimeTaskOptions { project?: ProjectWithTasks | null source?: RuntimeTaskAddress | null + attachments?: Attachment[] onError?: (error: string) => void } diff --git a/wework/src/features/workbench/workbenchReducer.test.ts b/wework/src/features/workbench/workbenchReducer.test.ts index 20afa12920d..c9d7b7c627e 100644 --- a/wework/src/features/workbench/workbenchReducer.test.ts +++ b/wework/src/features/workbench/workbenchReducer.test.ts @@ -652,6 +652,7 @@ describe('workbenchReducer', () => { tasks: [ { taskId: 'task-1', + threadId: 'direct-thread-id', workspacePath: '/Users/me/chat', title: 'Chat', runtime: 'codex', @@ -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', diff --git a/wework/src/features/workbench/workbenchReducer.ts b/wework/src/features/workbench/workbenchReducer.ts index a75f5ea2552..88ac382c283 100644 --- a/wework/src/features/workbench/workbenchReducer.ts +++ b/wework/src/features/workbench/workbenchReducer.ts @@ -637,6 +637,7 @@ function findRuntimeTaskAddressByTaskId( taskId, workspacePath: getRuntimeTaskWorkspacePath(workspace, task), ...(task.taskId ? { taskId: task.taskId } : {}), + ...(task.threadId ? { threadId: task.threadId } : {}), ...(task.runtimeHandle ? { runtimeHandle: task.runtimeHandle } : {}), } if (match && match.deviceId !== address.deviceId) { diff --git a/wework/src/features/workbench/workbenchRuntimeHelpers.test.ts b/wework/src/features/workbench/workbenchRuntimeHelpers.test.ts index 8a46457460e..cbeb98f34bd 100644 --- a/wework/src/features/workbench/workbenchRuntimeHelpers.test.ts +++ b/wework/src/features/workbench/workbenchRuntimeHelpers.test.ts @@ -63,6 +63,7 @@ describe('workbenchRuntimeHelpers', () => { tasks: [ { taskId: 'local-visible-task', + threadId: 'direct-thread-id', workspacePath: '/workspace/project-alpha', title: 'Fix guidance', runtime: 'codex', @@ -84,6 +85,7 @@ describe('workbenchRuntimeHelpers', () => { deviceId: 'device-1', workspacePath: '/workspace/project-alpha', taskId: 'local-visible-task', + threadId: 'direct-thread-id', runtimeHandle: { threadId: '019ee7f6-456a-78a1-96b1-66451afc310e', }, diff --git a/wework/src/features/workbench/workbenchRuntimeHelpers.ts b/wework/src/features/workbench/workbenchRuntimeHelpers.ts index 785fac4759e..5df0cc2d083 100644 --- a/wework/src/features/workbench/workbenchRuntimeHelpers.ts +++ b/wework/src/features/workbench/workbenchRuntimeHelpers.ts @@ -138,6 +138,7 @@ function runtimeTaskAddressFromWorkspace( taskId: task.taskId, workspacePath: getRuntimeTaskWorkspacePath(workspace, task), ...(task.taskId ? { taskId: task.taskId } : {}), + ...(task.threadId ? { threadId: task.threadId } : {}), ...(task.runtimeHandle ? { runtimeHandle: task.runtimeHandle } : {}), } }