diff --git a/docs/en/wework/settings.md b/docs/en/wework/settings.md index d6a9ddb9ce..c8e832372e 100644 --- a/docs/en/wework/settings.md +++ b/docs/en/wework/settings.md @@ -32,13 +32,30 @@ 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 → General → Task runtime**, 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. The DeepSeek profile uses the native Responses API and exposes `deepseek-v4-flash` and `deepseek-v4-pro`, the models currently available for Codex. Both use a 1,048,576-token context window, `high` default reasoning effort, live Web Search, and freeform `apply_patch`. The connection test also requires a real `apply_patch` custom-tool call. Existing Wework-managed DeepSeek Chat Completions configurations are migrated to the Responses API. The current catalog declares text input and disables image generation, so image generation and image understanding are not presented as supported capabilities for this profile. - A text-only model can reference another model that explicitly declares image input as its vision proxy. Local-model references are maintained in Wework's local model settings. Cloud-model references must be configured in the Wegent web administration UI; Wework only consumes the `visionSidecarModel` reference returned by the Backend and does not provide cloud-model editing. Before the primary request, Wework asks the vision model to describe each image and replaces `input_image` with the description text. Every text model with a configured vision proxy uses the internal generic `wework-vision-sidecar` capability catalog entry, allowing Codex to accept images while sending the original media only to the vision model and never to the text-only primary model. Vision proxies support OpenAI Responses, OpenAI Chat Completions, and Anthropic Messages endpoints. Each turn describes at most eight distinct images, accepts images up to 20 MB, and injects an explicit error message when description fails. Remote vision endpoints must use HTTPS; only `localhost` and loopback IP addresses may use HTTP, and vision requests do not follow redirects so credentials cannot be forwarded to an unauthorized destination. +A text-only model can reference another model that explicitly declares image input as its vision proxy. Local-model references are maintained in Wework's local model settings. Cloud-model references must be configured in the Wegent web administration UI; Wework only consumes the `visionSidecarModel` reference returned by the Backend and does not provide cloud-model editing. Before the primary request, Wework asks the vision model to describe each image and replaces `input_image` with the description text. Every text model with a configured vision proxy uses the internal generic `wework-vision-sidecar` capability catalog entry, allowing Codex to accept images while sending the original media only to the vision model and never to the text-only primary model. Vision proxies support OpenAI Responses, OpenAI Chat Completions, and Anthropic Messages endpoints. Each turn describes at most eight distinct images, accepts images up to 20 MB, and injects an explicit error message when description fails. Remote vision endpoints must use HTTPS; only `localhost` and loopback IP addresses may use HTTP, and vision requests do not follow redirects so credentials cannot be forwarded to an unauthorized destination. Each custom model has an optional **Group** field that controls how it appears in the model picker. Kimi Coding defaults this field to **Kimi**, but users can edit or clear it. Models without a group appear under **Custom models**. diff --git a/docs/zh/wework/settings.md b/docs/zh/wework/settings.md index c2cd2f83ed..4432268275 100644 --- a/docs/zh/wework/settings.md +++ b/docs/zh/wework/settings.md @@ -42,13 +42,27 @@ 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` 推理等级。 DeepSeek profile 使用原生 Responses API,展示当前可用于 Codex 的 `deepseek-v4-flash` 和 `deepseek-v4-pro`。两者均使用 1,048,576 tokens 上下文、默认 `high` 推理等级、实时 Web Search 和 freeform `apply_patch`;连接测试也会要求模型真实返回 `apply_patch` custom tool call。旧版由 Wework 管理的 DeepSeek Chat Completions 配置会自动迁移到 Responses API。当前模型目录声明文本输入且关闭图片生成,因此图片生成和图片理解不会在该 profile 下显示为可用能力。 - 文本模型可以引用另一个明确声明图片输入能力的模型作为“视觉代理模型”。本地模型的引用在 Wework 本地模型设置中维护;云端模型的引用必须在 Wegent Web 管理端配置,Wework 只读取 Backend 下发的 `visionSidecarModel` 引用,不提供云端模型编辑入口。执行时,Wework 在请求主模型前调用视觉模型描述每张图片,再用描述文本替换 `input_image`。任何配置视觉代理的文本模型都会使用内部通用的 `wework-vision-sidecar` 能力目录项,使 Codex 可以接收图片,同时原始图片只发送给视觉模型,不发送给文本主模型。视觉代理支持 OpenAI Responses、OpenAI Chat Completions 和 Anthropic Messages 接口;单轮最多处理 8 个不同图片描述,单张图片最大 20 MB,描述失败时会插入明确的错误文本。远程视觉接口必须使用 HTTPS;仅 `localhost` 和回环 IP 可使用 HTTP,且视觉请求不会跟随重定向,避免凭证被发送到未授权的后续地址。 +文本模型可以引用另一个明确声明图片输入能力的模型作为“视觉代理模型”。本地模型的引用在 Wework 本地模型设置中维护;云端模型的引用必须在 Wegent Web 管理端配置,Wework 只读取 Backend 下发的 `visionSidecarModel` 引用,不提供云端模型编辑入口。执行时,Wework 在请求主模型前调用视觉模型描述每张图片,再用描述文本替换 `input_image`。任何配置视觉代理的文本模型都会使用内部通用的 `wework-vision-sidecar` 能力目录项,使 Codex 可以接收图片,同时原始图片只发送给视觉模型,不发送给文本主模型。视觉代理支持 OpenAI Responses、OpenAI Chat Completions 和 Anthropic Messages 接口;单轮最多处理 8 个不同图片描述,单张图片最大 20 MB,描述失败时会插入明确的错误文本。远程视觉接口必须使用 HTTPS;仅 `localhost` 和回环 IP 可使用 HTTP,且视觉请求不会跟随重定向,避免凭证被发送到未授权的后续地址。 每个自定义模型都可以设置可选的“分组”,模型选择器会使用该名称组织模型。Kimi Coding 默认填写“Kimi”,用户可以修改或清空;未设置分组的模型统一显示在“自定义模型”下。 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/e2e/desktop/task-flow.e2e.mjs b/wework/e2e/desktop/task-flow.e2e.mjs index dd9bc16053..0b770c07b9 100644 --- a/wework/e2e/desktop/task-flow.e2e.mjs +++ b/wework/e2e/desktop/task-flow.e2e.mjs @@ -315,9 +315,9 @@ const PROVIDER_SWITCH_LUNA_OPTION_ID = 'local-model:desktop-e2e-luna-overseas' const PROVIDER_SWITCH_LUNA_LABEL = 'GPT 5.6 Luna (海外)' const PROVIDER_SWITCH_LUNA_MODEL_ID = 'gpt-5.6-luna' // The local E2E Codex catalog is classified as third-party (custom provider), so -// the official option is served from the cloud model catalog with a model id -// that does not collide with the local Codex catalog. -const PROVIDER_SWITCH_OFFICIAL_OPTION_ID = 'codex-gpt-5.5' +// the official option is served from the cloud model catalog with a canonical +// model id that does not collide with the local Codex catalog. +const PROVIDER_SWITCH_OFFICIAL_OPTION_ID = 'gpt-5.5' const PROVIDER_SWITCH_OFFICIAL_LABEL = 'GPT 5.5' const PROVIDER_SWITCH_OFFICIAL_MODEL_ID = 'gpt-5.5' const PROVIDER_SWITCH_OFFICIAL_MODEL_LABEL = 'GPT 5.5' @@ -13520,7 +13520,6 @@ last_updated = "2026-07-30T00:00:00Z"` await control.command('dispatchLocalModelSettingsChanged', '') const canonicalModelOption = `model-option-${DEFAULT_MODEL_ID}` const synthesizedModelOption = `model-option-codex-${DEFAULT_MODEL_ID}` - const legacyGpt55ModelOption = 'model-option-gpt-5.5' const publicModelOption = `model-option-${CLOUD_PUBLIC_MODEL_NAME}` const recoveredModelMenu = await ensureModelOptionVisible(control, canonicalModelOption) assert.equal( @@ -13533,11 +13532,6 @@ last_updated = "2026-07-30T00:00:00Z"` false, 'The Backend-synthesized runtime Codex duplicate remained visible' ) - assert.equal( - recoveredModelMenu.testIds.includes(legacyGpt55ModelOption), - false, - 'The legacy GPT 5.5 Codex model remained visible' - ) assert.equal( (await ensureModelOptionVisible(control, publicModelOption)).testIds.includes( publicModelOption 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..11127c7592 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({ @@ -4400,4 +4462,41 @@ describe('DesktopSidebar', () => { expect(button).toHaveTextContent('hello 20') expect(button).toHaveAttribute('aria-expanded', 'true') }) + + test('does not render a standalone task workspace as a project', () => { + const workspacePath = '/Users/alice/.wework/workspace/chats/standalone-task' + + renderSidebar({ + projects: [], + devices: [localDevice({ device_id: 'device-1', name: 'Local Mac' })], + standaloneDeviceId: 'device-1', + standaloneWorkspacePath: workspacePath, + runtimeWork: { + projects: [], + chats: [ + { + deviceId: 'device-1', + deviceName: 'Local Mac', + deviceStatus: 'online', + available: true, + workspacePath, + workspaceKind: 'chat', + tasks: [ + { + taskId: 'standalone-task', + workspacePath, + workspaceKind: 'chat', + title: '只显示在任务中', + runtime: 'codex', + }, + ], + }, + ], + totalTasks: 1, + }, + }) + + expect(screen.queryByTestId('project-item-button')).not.toBeInTheDocument() + expect(screen.getByTestId('runtime-local-task-row-standalone-task')).toBeInTheDocument() + }) }) diff --git a/wework/src/components/layout/DesktopSidebar.tsx b/wework/src/components/layout/DesktopSidebar.tsx index fbafb205bb..1b4e084387 100644 --- a/wework/src/components/layout/DesktopSidebar.tsx +++ b/wework/src/components/layout/DesktopSidebar.tsx @@ -357,12 +357,14 @@ function runtimeWorkHasWorkspace( workspacePath: string ): boolean { const normalizedPath = normalizeSidebarWorkspacePath(workspacePath) - return (runtimeWork?.projects ?? []).some(projectWork => - projectWork.deviceWorkspaces.some( - workspace => - workspace.deviceId === deviceId && - normalizeSidebarWorkspacePath(workspace.workspacePath) === normalizedPath - ) + const hasMatchingWorkspace = (workspace: RuntimeDeviceWorkspace) => + workspace.deviceId === deviceId && + normalizeSidebarWorkspacePath(workspace.workspacePath) === normalizedPath + + return ( + (runtimeWork?.projects ?? []).some(projectWork => + projectWork.deviceWorkspaces.some(hasMatchingWorkspace) + ) || (runtimeWork?.chats ?? []).some(hasMatchingWorkspace) ) } @@ -1438,6 +1440,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 +1496,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 +1644,20 @@ function RuntimeTaskRow({ > {priorityLayout ? ( - + + {titleShimmering ? ( +