From 3d48485bc7a73921c5286920f43cc361d2356273 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 6 Aug 2026 21:05:02 +0800 Subject: [PATCH 1/7] feat(wework): generate friendly task titles --- docs/en/wework/settings.md | 17 ++ docs/zh/wework/settings.md | 14 ++ executor/src/runtime_work/handler/tasks.rs | 219 +++++++++++++++++- wework/src-tauri/src/lib.rs | 41 +++- wework/src/App.plugins.test.tsx | 7 +- wework/src/api/deliveries.test.ts | 17 ++ wework/src/api/deliveries.ts | 20 ++ wework/src/api/hybrid/hybridServices.ts | 1 + wework/src/api/local/codexOfficialModels.ts | 2 +- wework/src/api/local/localDelivery.test.ts | 35 +++ wework/src/api/local/localDelivery.ts | 16 ++ wework/src/api/local/localServices.test.ts | 19 +- wework/src/api/local/localServices.ts | 33 +++ wework/src/api/runtime/runtimeChatStream.ts | 1 + .../components/layout/DesktopSidebar.test.tsx | 62 +++++ .../src/components/layout/DesktopSidebar.tsx | 48 +++- .../layout/DesktopWorkbenchMain.tsx | 2 + .../components/settings/ModelSettingsPage.tsx | 110 ++++++++- .../codexOfficialModels.test.ts | 27 ++- .../model-settings/codexOfficialModels.ts | 18 +- .../workbench/WorkbenchProvider.test.tsx | 30 ++- .../features/workbench/WorkbenchProvider.tsx | 57 +++-- .../workbench/runtimePaneMessages.test.ts | 29 +++ .../features/workbench/runtimePaneMessages.ts | 12 + .../workbench/useWorkbenchRuntimeMessaging.ts | 15 ++ .../workbench/workbenchReducer.test.ts | 70 ++++++ .../features/workbench/workbenchReducer.ts | 39 ++++ wework/src/stream/chatStream.ts | 2 + wework/src/stream/responseApiStream.test.ts | 23 ++ wework/src/stream/responseApiStream.ts | 12 + wework/src/styles/globals.css | 53 +++++ wework/src/tauri/appPreferences.test.ts | 2 + wework/src/tauri/appPreferences.ts | 58 +++++ wework/src/types/api.ts | 14 ++ 34 files changed, 1075 insertions(+), 50 deletions(-) diff --git a/docs/en/wework/settings.md b/docs/en/wework/settings.md index d6a9ddb9ce..e8d7e3ae8a 100644 --- a/docs/en/wework/settings.md +++ b/docs/en/wework/settings.md @@ -32,6 +32,23 @@ Common macOS shortcuts include: local custom models, and cloud models remain available according to their own configuration. If no source provides a model, the picker displays **No models available**. +## Friendly titles + +Under **Settings → Models**, enable **Use friendly titles** and select an available model. Wework +then generates a concise title asynchronously for each new task. Creation never waits for that +model: the task starts with the original user-provided title, and its display name is replaced +when generation finishes with a brief shimmer. + +Title generation uses a separate lightweight model call. It does not add a message to the task +conversation or change the main task's running, completed, or failed state. A manually renamed +task is not overwritten later. If generation fails, the original title remains and the task still +starts normally. + +Ordinary new tasks are standalone by default. Opening a project task earlier does not +automatically add a new task to a remote project or board. Wework synchronizes only tasks that +were explicitly linked to a project space; after friendly-title generation, those local or cloud +board items receive the updated title. + ## Custom Codex models In **Settings → Models**, click **Add model** and choose a provider first. Wework includes profiles for Kimi Coding, the Kimi API Platform, DeepSeek, and GLM. After entering the corresponding platform API key, Wework discovers available models through the provider's `/models` endpoint. Each profile supplies its connection URL, API protocol, tool mode, and known model context windows; the Kimi API Platform profile uses the China-region `api.moonshot.cn` endpoint. Kimi Coding K3 automatically uses the built-in Codex Catalog profile with a 256K context window and `low` default reasoning effort. diff --git a/docs/zh/wework/settings.md b/docs/zh/wework/settings.md index c2cd2f83ed..5f02a77653 100644 --- a/docs/zh/wework/settings.md +++ b/docs/zh/wework/settings.md @@ -42,6 +42,20 @@ Windows 和 Linux 使用界面中显示的对应组合键。 `auth.json` 时,该分组不会出现在模型选择器中;Provider 模型、本地自定义模型和云端模型 仍按各自配置展示。如果所有来源都没有可用模型,选择器直接显示“暂无可用模型”。 +## 使用友好标题 + +在 **设置 → 模型** 中打开 **使用友好标题**,并选择一个可用模型后,Wework 会在新建任务 +时异步生成简洁的中文标题。创建不会等待标题模型返回:任务先使用用户输入的原始标题创建, +标题生成完成后才替换显示名称并出现短暂扫光效果。 + +标题生成使用独立的轻量模型调用,不会向任务会话写入消息,也不会改变主任务的运行、完成或 +失败状态。手动重命名的任务不会被之后的自动标题覆盖。生成失败时,原始标题会保留,任务仍可 +正常创建和运行。 + +普通新建任务默认是独立会话,不会因为之前打开过项目任务而自动加入远程项目或看板。只有在 +发送前明确选择项目空间,或为任务显式关联项目空间时,Wework 才会同步该任务;已关联的任务 +在友好标题生成后会将更新后的标题同步到对应的本地或云端看板。 + ## 自定义 Codex 模型 在“设置 → 模型”中点击“添加模型”后,先选择提供商。Wework 内置 Kimi Coding、Kimi 开放平台、DeepSeek 和 GLM profile;填写对应平台的 API Key 后,可以从提供商的 `/models` 接口读取可用模型。连接地址、接口协议、工具模式和已知模型的上下文长度由 profile 自动填写,其中 Kimi 开放平台使用中国区 `api.moonshot.cn` 端点。Kimi Coding 的 K3 会自动使用内置的 Codex Catalog profile,包括 256K 上下文和默认 `low` 推理等级。 diff --git a/executor/src/runtime_work/handler/tasks.rs b/executor/src/runtime_work/handler/tasks.rs index f3b04c49fe..9be4fda93e 100644 --- a/executor/src/runtime_work/handler/tasks.rs +++ b/executor/src/runtime_work/handler/tasks.rs @@ -5,6 +5,146 @@ use super::*; impl RuntimeWorkRpcHandler { + pub(super) async fn generate_friendly_title( + &self, + payload: Value, + ) -> Result { + let source_title = string_field(&payload, "sourceTitle") + .or_else(|| string_field(&payload, "source_title")) + .ok_or_else(|| AppIpcError::new("bad_request", "sourceTitle is required"))?; + let link = self.task_link_from_payload(&payload, false).await?; + if link.title != source_title { + log_executor_event( + "friendly task title generation skipped", + &[ + ("local_task_id", link.local_task_id), + ("reason", "title_changed_before_generation".to_owned()), + ], + ); + return Ok(json!({"success": true, "skipped": true})); + } + let local_task_id = link.local_task_id.clone(); + let mut request = execution_request(&payload) + .ok_or_else(|| AppIpcError::new("bad_request", "executionRequest is required"))?; + request.ephemeral = true; + let result = self + .codex_app_server + .run_turn_with_cancel(request.clone(), CodexAppServerTurnOptions::default()) + .await; + let content = match result { + Ok(turn) => match turn.outcome { + ExecutionOutcome::Completed { content } => content, + ExecutionOutcome::Failed { message } => { + log_executor_event( + "friendly task title generation failed", + &[ + ("local_task_id", local_task_id.clone()), + ("reason", "model_request_failed".to_owned()), + ("error", message.clone()), + ], + ); + return Ok(json!({"success": false, "error": message})); + } + outcome => { + let error = format!("friendly title generation did not complete: {outcome:?}"); + log_executor_event( + "friendly task title generation failed", + &[ + ("local_task_id", local_task_id.clone()), + ("reason", "model_request_incomplete".to_owned()), + ("error", error.clone()), + ], + ); + return Ok(json!({"success": false, "error": error})); + } + }, + Err(error) => { + log_executor_event( + "friendly task title generation failed", + &[ + ("local_task_id", local_task_id.clone()), + ("reason", "model_transport_failed".to_owned()), + ("error", error.clone()), + ], + ); + return Ok(json!({"success": false, "error": error})); + } + }; + let Some(title) = normalize_friendly_title(&content) else { + log_executor_event( + "friendly task title generation failed", + &[ + ("local_task_id", local_task_id.clone()), + ("reason", "empty_model_output".to_owned()), + ], + ); + return Ok(json!({"success": false, "error": "friendly title was empty"})); + }; + let mut latest_link = self.task_link_from_payload(&payload, false).await?; + for _ in 0..25 { + if latest_link.title != source_title { + log_executor_event( + "friendly task title generation skipped", + &[ + ("local_task_id", local_task_id.clone()), + ("reason", "title_changed_during_generation".to_owned()), + ], + ); + return Ok(json!({"success": true, "skipped": true})); + } + if latest_link.thread_id.is_some() { + break; + } + sleep(Duration::from_millis(200)).await; + latest_link = self.task_link_from_payload(&payload, false).await?; + } + if latest_link.title != source_title { + log_executor_event( + "friendly task title generation skipped", + &[ + ("local_task_id", local_task_id.clone()), + ("reason", "title_changed_before_update".to_owned()), + ], + ); + return Ok(json!({"success": true, "skipped": true})); + } + if let Some(thread_id) = latest_link.thread_id.as_deref() { + if let Err(error) = self + .call_codex_thread_method( + "thread/name/set", + json!({"threadId": thread_id, "name": title}), + ) + .await + { + log_executor_event( + "friendly task title generation failed", + &[ + ("local_task_id", local_task_id.clone()), + ("reason", "thread_name_update_failed".to_owned()), + ("error", error.clone()), + ], + ); + return Ok(json!({"success": false, "error": error})); + } + } + latest_link.title = title.clone(); + latest_link.updated_at = now_ms(); + self.upsert_local_task(latest_link); + emit_response_event( + &self.event_tx, + &self.device_id, + "runtime.task.title.updated", + &local_task_id, + &request, + json!({"title": title}), + ); + log_executor_event( + "friendly task title generation completed", + &[("local_task_id", local_task_id)], + ); + Ok(json!({"success": true, "title": title})) + } + pub(super) async fn fork_task_at_turn(&self, payload: Value) -> Result { let source = self.task_link_from_payload(&payload, false).await?; let requested_turn_id = string_field(&payload, "lastTurnId") @@ -189,9 +329,49 @@ impl RuntimeWorkRpcHandler { fork_thread_id: side_source.as_ref().map(|source| source.thread_id.clone()), fork_thread_path: side_source.and_then(|source| source.thread_path), resume_thread_id: None, - initial_thread_name: Some(title), + initial_thread_name: Some(title.clone()), initial_thread_goal, }); + match payload.get("friendlyTitleExecutionRequest").cloned() { + Some(value) => match serde_json::from_value::(value) { + Ok(execution_request) => { + log_executor_event( + "friendly task title generation queued", + &[("local_task_id", local_task_id.clone())], + ); + let handler = self.clone(); + let friendly_payload = json!({ + "taskId": local_task_id, + "sourceTitle": title, + "executionRequest": execution_request, + }); + tokio::spawn(async move { + if let Err(error) = handler.generate_friendly_title(friendly_payload).await + { + log_executor_event( + "friendly task title generation failed", + &[("error", error.message)], + ); + } + }); + } + Err(error) => log_executor_event( + "friendly task title generation rejected", + &[ + ("local_task_id", local_task_id.clone()), + ("reason", "invalid_execution_request".to_owned()), + ("error", error.to_string()), + ], + ), + }, + None => log_executor_event( + "friendly task title generation skipped", + &[ + ("local_task_id", local_task_id.clone()), + ("reason", "missing_execution_request".to_owned()), + ], + ), + } Ok(json!({ "success": true, @@ -801,6 +981,25 @@ impl RuntimeWorkRpcHandler { } } +fn normalize_friendly_title(value: &str) -> Option { + let title = value + .lines() + .find_map(|line| { + let trimmed = line + .trim() + .trim_matches(|ch| matches!(ch, '"' | '\'' | '“' | '”' | '‘' | '’' | '`')); + (!trimmed.is_empty()).then_some(trimmed) + })? + .chars() + .take(48) + .collect::() + .trim() + .trim_end_matches(['。', '!', '?', '.', '!', '?']) + .trim() + .to_owned(); + (!title.is_empty()).then_some(title) +} + pub(super) fn runtime_model_selection_changed(link: &RuntimeTaskLink, payload: &Value) -> bool { let previous = model_selection_identity( link.runtime_handle @@ -914,3 +1113,21 @@ fn synthetic_transcript_turn_id_base(value: &str) -> Option<&str> { } Some(turn_id) } + +#[cfg(test)] +mod tests { + use super::normalize_friendly_title; + + #[test] + fn normalizes_model_title_to_one_short_line() { + assert_eq!( + normalize_friendly_title(" “为任务创建友好标题。”\n这是解释"), + Some("为任务创建友好标题".to_owned()) + ); + } + + #[test] + fn rejects_empty_model_title() { + assert_eq!(normalize_friendly_title(" \n\t "), None); + } +} diff --git a/wework/src-tauri/src/lib.rs b/wework/src-tauri/src/lib.rs index 69515551c4..9342bcbdad 100644 --- a/wework/src-tauri/src/lib.rs +++ b/wework/src-tauri/src/lib.rs @@ -614,6 +614,10 @@ struct AppPreferences { popout_window_shortcut: Option, #[serde(default)] popout_window_projectless_default_enabled: bool, + #[serde(default)] + friendly_task_titles_enabled: bool, + #[serde(default)] + friendly_task_title_model: Option, #[serde(default = "default_quick_phrases")] quick_phrases: Vec, } @@ -718,6 +722,8 @@ impl Default for AppPreferences { appshots_play_sound: true, popout_window_shortcut: default_popout_window_shortcut(), popout_window_projectless_default_enabled: false, + friendly_task_titles_enabled: false, + friendly_task_title_model: None, quick_phrases: default_quick_phrases(), } } @@ -773,6 +779,9 @@ struct AppPreferencesPatch { #[serde(default)] popout_window_shortcut: PatchField, popout_window_projectless_default_enabled: Option, + friendly_task_titles_enabled: Option, + #[serde(default)] + friendly_task_title_model: PatchField, quick_phrases: Option>, } @@ -1390,6 +1399,12 @@ fn update_app_preferences( if let Some(value) = patch.popout_window_projectless_default_enabled { preferences.popout_window_projectless_default_enabled = value; } + if let Some(value) = patch.friendly_task_titles_enabled { + preferences.friendly_task_titles_enabled = value; + } + if let PatchField::Value(value) = patch.friendly_task_title_model { + preferences.friendly_task_title_model = value; + } if let Some(value) = patch.quick_phrases { preferences.quick_phrases = value; } @@ -1429,6 +1444,10 @@ struct AppPreferences { browser_download_directory: Option, browser_ask_before_download: bool, appshots_play_sound: bool, + popout_window_shortcut: Option, + popout_window_projectless_default_enabled: bool, + friendly_task_titles_enabled: bool, + friendly_task_title_model: Option, quick_phrases: Vec, } @@ -1458,6 +1477,10 @@ struct AppPreferencesPatch { browser_download_directory: Option, browser_ask_before_download: Option, appshots_play_sound: Option, + popout_window_shortcut: Option, + popout_window_projectless_default_enabled: Option, + friendly_task_titles_enabled: Option, + friendly_task_title_model: Option, quick_phrases: Option>, } @@ -1487,6 +1510,10 @@ fn get_app_preferences(_app: tauri::AppHandle) -> Result browser_download_directory: None, browser_ask_before_download: false, appshots_play_sound: true, + popout_window_shortcut: Some(default_popout_window_shortcut()), + popout_window_projectless_default_enabled: false, + friendly_task_titles_enabled: false, + friendly_task_title_model: None, quick_phrases: default_quick_phrases(), }) } @@ -1535,6 +1562,15 @@ fn update_app_preferences( .and_then(normalized_non_empty), browser_ask_before_download: patch.browser_ask_before_download.unwrap_or(false), appshots_play_sound: patch.appshots_play_sound.unwrap_or(true), + popout_window_shortcut: patch + .popout_window_shortcut + .and_then(normalized_non_empty) + .or_else(|| Some(default_popout_window_shortcut())), + popout_window_projectless_default_enabled: patch + .popout_window_projectless_default_enabled + .unwrap_or(false), + friendly_task_titles_enabled: patch.friendly_task_titles_enabled.unwrap_or(false), + friendly_task_title_model: patch.friendly_task_title_model, quick_phrases: patch.quick_phrases.unwrap_or_else(default_quick_phrases), }) } @@ -4771,8 +4807,9 @@ pub fn run() { #[cfg(desktop)] { let preferences = read_app_preferences_impl(app.handle()); - app.state::() - .configure(preferences.telemetry_consent_asked && preferences.telemetry_enabled); + app.state::().configure( + preferences.telemetry_consent_asked && preferences.telemetry_enabled, + ); } #[cfg(desktop)] system_drag::setup(app.handle().clone()); diff --git a/wework/src/App.plugins.test.tsx b/wework/src/App.plugins.test.tsx index a1c03102fa..75774a0ac1 100644 --- a/wework/src/App.plugins.test.tsx +++ b/wework/src/App.plugins.test.tsx @@ -873,7 +873,7 @@ describe('App plugins route', () => { connectedAt: '2026-07-15T00:00:00.000Z', }) ) - vi.mocked(fetch).mockImplementation(async input => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input) if (url.endsWith('/users/me')) { return { @@ -918,12 +918,13 @@ describe('App plugins route', () => { } throw new Error(`Unexpected request: ${url}`) }) + vi.stubGlobal('fetch', fetchMock) window.history.pushState({}, '', '/sites') renderApp() expect(await screen.findByText('云端站点')).toBeInTheDocument() - expect(fetch).toHaveBeenCalledWith( + expect(fetchMock).toHaveBeenCalledWith( 'http://127.0.0.1:9100/api/sites?app_type=web&offset=0&limit=20', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer cloud-secret' }), @@ -933,7 +934,7 @@ describe('App plugins route', () => { await createSiteFromMenu() await waitFor(() => expect(window.location.pathname).toBe('/')) - expect(fetch).toHaveBeenCalledWith( + expect(fetchMock).toHaveBeenCalledWith( 'http://127.0.0.1:9100/api/plugins/builtin/wegent-sites/ensure-installed', expect.objectContaining({ method: 'POST', diff --git a/wework/src/api/deliveries.test.ts b/wework/src/api/deliveries.test.ts index ed7addf95c..290a320d65 100644 --- a/wework/src/api/deliveries.test.ts +++ b/wework/src/api/deliveries.test.ts @@ -101,4 +101,21 @@ describe('createDeliveryApi task tracking', () => { }) expect(patch).toHaveBeenCalledOnce() }) + + test('synchronizes a friendly runtime title to the bound task', async () => { + const renamedItem = { ...trackedItem, title: '修复登录回调', version: 2 } + const context = { loop_item_id: trackedItem.id } as CloudTaskContext + const get = vi.fn().mockResolvedValueOnce(context).mockResolvedValueOnce(trackedItem) + const patch = vi.fn().mockResolvedValue(renamedItem) + const api = createDeliveryApi(clientWith({ get, patch })) + + await expect( + api.updateTaskTrackingTitle({ deviceId: 'local-device', taskId: 'runtime-1' }, '修复登录回调') + ).resolves.toEqual(renamedItem) + + expect(patch).toHaveBeenCalledWith('/v1/loop-items/WEG-1', { + version: 1, + title: '修复登录回调', + }) + }) }) diff --git a/wework/src/api/deliveries.ts b/wework/src/api/deliveries.ts index f944ae37b1..4a2fe262b3 100644 --- a/wework/src/api/deliveries.ts +++ b/wework/src/api/deliveries.ts @@ -508,6 +508,26 @@ export function createDeliveryApi(client: HttpClient) { }) : item }, + async updateTaskTrackingTitle( + task: RuntimeTaskAddress, + title: string + ): Promise { + let context: CloudTaskContext + try { + context = await api.findCloudContextForTask(task) + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null + throw error + } + if (!context.loop_item_id) return null + const item = await api.getLoopItem(context.loop_item_id) + return item.title === title + ? item + : api.updateLoopItem(item.id, { + version: item.version, + title, + }) + }, unbindCloudContext(task: RuntimeTaskAddress): Promise { return client.delete('/v1/runtime-tasks/cloud-context', task) }, diff --git a/wework/src/api/hybrid/hybridServices.ts b/wework/src/api/hybrid/hybridServices.ts index a54cb597ea..a013bdd6fd 100644 --- a/wework/src/api/hybrid/hybridServices.ts +++ b/wework/src/api/hybrid/hybridServices.ts @@ -1008,6 +1008,7 @@ function filterRuntimeChatStreamHandlers( onBlockCreated: route(handlers.onBlockCreated), onBlockUpdated: route(handlers.onBlockUpdated), onSubagentActivity: route(handlers.onSubagentActivity), + onRuntimeTaskTitleUpdated: route(handlers.onRuntimeTaskTitleUpdated), onRuntimeGoalUpdated: route(handlers.onRuntimeGoalUpdated), onRuntimeGoalCleared: route(handlers.onRuntimeGoalCleared), onRuntimeSupervisorUpdated: route(handlers.onRuntimeSupervisorUpdated), diff --git a/wework/src/api/local/codexOfficialModels.ts b/wework/src/api/local/codexOfficialModels.ts index 2e7d477cdf..9e03b36200 100644 --- a/wework/src/api/local/codexOfficialModels.ts +++ b/wework/src/api/local/codexOfficialModels.ts @@ -10,7 +10,7 @@ export async function requestLocalCodexOfficialModels( request: LocalExecutorRequest = requestLocalExecutor ): Promise { const response = await request('runtime.codex.models.list', { - includeHidden: false, + includeHidden: true, }) return normalizeCodexOfficialModelList(response) } diff --git a/wework/src/api/local/localDelivery.test.ts b/wework/src/api/local/localDelivery.test.ts index bba7ac60aa..ea5edecfbd 100644 --- a/wework/src/api/local/localDelivery.test.ts +++ b/wework/src/api/local/localDelivery.test.ts @@ -303,6 +303,7 @@ describe('local delivery API', () => { linked_at: '2026-07-27T00:00:00Z', } } + if (method === 'projects.list') return [projectRecord] if (method === 'todos.get') return taskRecord throw new Error(`Unexpected method: ${method}`) }) @@ -454,6 +455,40 @@ describe('local delivery API', () => { }) }) + test('synchronizes a friendly runtime title through executor IPC', async () => { + const renamedTask = { ...taskRecord, title: '修复登录回调', version: 2 } + const request = vi.fn(async (method: string) => { + if (method === 'runtime_tasks.context') { + return { + id: 'binding-1', + cloud_project_id: 'project-1', + loop_item_id: 'LOCAL-1', + task_user_id: 0, + device_id: 'local-device', + task_id: 'runtime-1', + task_title: 'Runtime task', + backend_task_id: null, + linked_at: '2026-07-27T00:00:00Z', + } + } + if (method === 'projects.list') return [projectRecord] + if (method === 'todos.get') return taskRecord + if (method === 'todos.update') return renamedTask + throw new Error(`Unexpected method: ${method}`) + }) + const api = createLocalDeliveryApi(request) + + await expect( + api.updateTaskTrackingTitle({ deviceId: 'local-device', taskId: 'runtime-1' }, '修复登录回调') + ).resolves.toMatchObject({ id: 'LOCAL-1', title: '修复登录回调' }) + + expect(request).toHaveBeenCalledWith('todos.update', { + project_id: 'project-1', + task_id: 'LOCAL-1', + todo: { version: 1, title: '修复登录回调' }, + }) + }) + test('routes local files, attachments, and deliveries through executor IPC', async () => { const delivery = { id: 'delivery-1', diff --git a/wework/src/api/local/localDelivery.ts b/wework/src/api/local/localDelivery.ts index 659fc9ac79..728337ae98 100644 --- a/wework/src/api/local/localDelivery.ts +++ b/wework/src/api/local/localDelivery.ts @@ -574,6 +574,22 @@ export function createLocalDeliveryApi( ? api.updateLoopItem(item.id, { version: item.version, status: nextStatus }) : item }, + async updateTaskTrackingTitle(task: RuntimeTaskAddress, title: string) { + let binding: LocalTaskBindingRecord + try { + binding = await request('runtime_tasks.context', { + device_id: task.deviceId, + task_id: task.taskId, + }) + } catch { + return null + } + if (!binding.loop_item_id) return null + const item = await api.getLoopItem(binding.loop_item_id) + return item.title === title + ? item + : api.updateLoopItem(item.id, { version: item.version, title }) + }, async unbindCloudContext(task: RuntimeTaskAddress) { await request('runtime_tasks.unbind', { device_id: task.deviceId, diff --git a/wework/src/api/local/localServices.test.ts b/wework/src/api/local/localServices.test.ts index 7ac6f87277..5611fb53c0 100644 --- a/wework/src/api/local/localServices.test.ts +++ b/wework/src/api/local/localServices.test.ts @@ -90,6 +90,9 @@ describe('createLocalAppServices', () => { }) const models = await services.modelApi.listModels() + expect(request).toHaveBeenCalledWith('runtime.codex.models.list', { + includeHidden: true, + }) expect(models).toEqual({ data: expect.arrayContaining([ expect.objectContaining({ @@ -114,10 +117,18 @@ describe('createLocalAppServices', () => { ]), }) const modelIds = models.data.map(model => model.modelId) - expect(modelIds).not.toContain('gpt-5.5') - expect(modelIds).not.toContain('Sol') - expect(modelIds).not.toContain('Terra') - expect(modelIds).not.toContain('Luna') + expect(modelIds).toEqual( + expect.arrayContaining([ + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.3-codex-spark', + ]) + ) + expect(modelIds).not.toContain('gpt-5.2') await expect(services.deviceApi.listDevices()).resolves.toEqual([ expect.objectContaining({ device_id: 'local-device', diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts index 7a1883ba9b..0fb754d87b 100644 --- a/wework/src/api/local/localServices.ts +++ b/wework/src/api/local/localServices.ts @@ -1594,10 +1594,37 @@ async function createLocalRuntimeTaskPayload( const collaborationMode = runtimeCollaborationMode(normalizedData.modelOptions) const turnSeed = createRuntimeTurnSeed() const payload = { ...normalizedData } as Record + const friendlyTitleExecutionRequest = normalizedData.friendlyTitle + ? buildLocalRuntimeExecutionRequest({ + taskId: `friendly-title-${normalizedData.taskId ?? turnSeed}-${createRuntimeTurnSeed()}`, + runtime: 'codex', + teamId: normalizedData.teamId, + title: 'Generate friendly task title', + message: [ + '为下面的用户请求生成一个简洁、具体、适合作为任务标题的中文标题。', + '只输出标题本身,不要引号、标点、解释或换行;最多 24 个汉字。', + '', + `用户请求:${normalizedData.message}`, + ].join('\n'), + turnSeed: createRuntimeTurnSeed(), + modelId: normalizedData.friendlyTitle.modelId, + modelType: normalizedData.friendlyTitle.modelType, + modelOptions: normalizedData.friendlyTitle.modelOptions, + cloudModelGateway, + localDeviceId, + workspacePath: runtimeWorkspace.workspacePath, + workspaceSource: runtimeWorkspace.workspaceSource, + branch: runtimeWorkspace.branch, + newSession: true, + ephemeral: true, + user, + }) + : null return { ...payload, ...(collaborationMode ? { collaborationMode } : {}), + ...(friendlyTitleExecutionRequest ? { friendlyTitleExecutionRequest } : {}), title: runtimeTaskTitle(normalizedData), executionRequest: buildLocalRuntimeExecutionRequest({ taskId: normalizedData.taskId, @@ -2505,6 +2532,12 @@ export function createRuntimeWorkApiFromIpc( ) debugLocalRuntimeCreatePayload(data, payload) const executionRequest = recordValue(payload.executionRequest) + console.info('[Wework] Friendly task title request', { + taskId: data.taskId, + enabled: Boolean(data.friendlyTitle), + executionRequestIncluded: Boolean(payload.friendlyTitleExecutionRequest), + modelId: data.friendlyTitle?.modelId ?? null, + }) console.info('[Wework] Local runtime execution identity', { taskId: stringValue(executionRequest.task_id), userId: executionRequest.user_id ?? null, diff --git a/wework/src/api/runtime/runtimeChatStream.ts b/wework/src/api/runtime/runtimeChatStream.ts index 09e46c3b4d..29a7e5ba2b 100644 --- a/wework/src/api/runtime/runtimeChatStream.ts +++ b/wework/src/api/runtime/runtimeChatStream.ts @@ -186,6 +186,7 @@ function hasLocalExecutorResponseHandlers(handlers: ChatStreamHandlers): boolean handlers.onBlockCreated || handlers.onBlockUpdated || handlers.onSubagentActivity || + handlers.onRuntimeTaskTitleUpdated || handlers.onRuntimeGoalUpdated || handlers.onRuntimeGoalCleared || handlers.onRuntimeSupervisorUpdated || diff --git a/wework/src/components/layout/DesktopSidebar.test.tsx b/wework/src/components/layout/DesktopSidebar.test.tsx index 22ef0bd522..6ff477b3c2 100644 --- a/wework/src/components/layout/DesktopSidebar.test.tsx +++ b/wework/src/components/layout/DesktopSidebar.test.tsx @@ -1781,6 +1781,68 @@ describe('DesktopSidebar', () => { }) }) + test('sweeps a runtime task title after it is updated', async () => { + const chatPath = '/Users/alice/.wework/workspace/chats/2026-08-06/title-update' + const runtimeWork = (title: string) => ({ + projects: [], + chats: [ + { + deviceId: 'local-device', + deviceName: 'Local Mac', + deviceStatus: 'online' as const, + available: true, + workspacePath: chatPath, + workspaceKind: 'chat' as const, + tasks: [ + { + taskId: 'friendly-title-task', + workspacePath: chatPath, + workspaceKind: 'chat' as const, + title, + runtime: 'codex' as const, + }, + ], + }, + ], + totalTasks: 1, + }) + const lifecycleStore = new RuntimeTaskLifecycleStore('friendly-title-sheen-test') + const initialProps = createSidebarProps({ + projects: [], + runtimeWork: runtimeWork('原始标题'), + }) + lifecycleStore.syncRuntimeWork(initialProps.runtimeWork) + const { rerender } = render( + + + + ) + + expect(screen.getByTestId('runtime-local-task-title-friendly-title-task')).not.toHaveClass( + 'is-updated' + ) + + const updatedProps = createSidebarProps({ + projects: [], + runtimeWork: runtimeWork('AI 优化后的标题'), + }) + lifecycleStore.syncRuntimeWork(updatedProps.runtimeWork) + rerender( + + + + ) + + await waitFor(() => { + expect(screen.getByTestId('runtime-local-task-title-friendly-title-task')).toHaveClass( + 'is-updated' + ) + }) + expect( + screen.getByTestId('runtime-local-task-title-shimmer-friendly-title-task') + ).toBeInTheDocument() + }) + test('removes pinned chat tasks from the task section without highlighted styling', () => { const chatPath = '/Users/alice/Documents/Codex/2026-07-12/pinned' renderSidebar({ diff --git a/wework/src/components/layout/DesktopSidebar.tsx b/wework/src/components/layout/DesktopSidebar.tsx index fbafb205bb..dc15ebf191 100644 --- a/wework/src/components/layout/DesktopSidebar.tsx +++ b/wework/src/components/layout/DesktopSidebar.tsx @@ -1438,6 +1438,9 @@ function RuntimeTaskRow({ const [renameOpen, setRenameOpen] = useState(false) const [taskMenuPosition, setTaskMenuPosition] = useState(null) const archiveDelayRef = useRef(null) + const titleShimmerDelayRef = useRef(null) + const previousTitleRef = useRef(task.title) + const [titleShimmering, setTitleShimmering] = useState(false) const worktreeTask = isRuntimeWorktreeTask(task) const workspaceTitle = getRuntimeTaskWorkspaceTitle(workspace) const projectLabel = projectName?.trim() || t('workbench.task') @@ -1491,8 +1494,23 @@ function RuntimeTaskRow({ if (archiveDelayRef.current !== null) { window.clearTimeout(archiveDelayRef.current) } + if (titleShimmerDelayRef.current !== null) { + window.clearTimeout(titleShimmerDelayRef.current) + } } }, []) + useEffect(() => { + if (previousTitleRef.current === task.title) return + previousTitleRef.current = task.title + setTitleShimmering(true) + if (titleShimmerDelayRef.current !== null) { + window.clearTimeout(titleShimmerDelayRef.current) + } + titleShimmerDelayRef.current = window.setTimeout(() => { + titleShimmerDelayRef.current = null + setTitleShimmering(false) + }, 760) + }, [task.title]) const runArchive = async (options?: ArchiveRuntimeTaskOptions) => { setArchiving(true) try { @@ -1624,7 +1642,20 @@ function RuntimeTaskRow({ > {priorityLayout ? ( - + + {titleShimmering ? ( +