Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
63 changes: 60 additions & 3 deletions executor/src/runtime_work/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ use super::{
util::{
apply_runtime_payload_metadata, bool_field, execution_request, id_field,
infer_workspace_kind, integer_field, normalize_device_id, normalize_workspace_path, now_ms,
prompt_text, runtime_task_id, string_field, workspace_group_path, workspace_path,
prompt_text, runtime_task_id, string_field, timestamp_ms_field, workspace_group_path,
workspace_path,
},
worktrees::{WorktreeManager, WorktreeSettingsPatch},
};
Expand Down Expand Up @@ -1326,6 +1327,7 @@ impl RuntimeWorkRpcHandler {
.await
.map_err(|error| AppIpcError::new("codex_error", error))?;
let thread = response.get("thread").unwrap_or(&response).clone();
self.repair_legacy_task_activity_time(&local_task_id, &thread);
let workspace_path = string_field(&thread, "cwd")
.or_else(|| string_field(&payload, "workspacePath"))
.or_else(|| string_field(&payload, "workspace_path"))
Expand Down Expand Up @@ -3117,8 +3119,9 @@ impl RuntimeWorkRpcHandler {
return;
}
self.store.update_task(&local_task_id, |link| {
link.thread_id = Some(thread_id.to_owned());
link.updated_at = now_ms();
if link.thread_id.as_deref() != Some(thread_id) {
link.thread_id = Some(thread_id.to_owned());
}
});
let pending_id = pending_thread_event_route_id(&local_task_id);
let mut routes = self
Expand All @@ -3140,6 +3143,21 @@ impl RuntimeWorkRpcHandler {
routes.insert(thread_id.to_owned(), route);
}

fn repair_legacy_task_activity_time(&self, local_task_id: &str, thread: &Value) {
if self.is_active_local_task(local_task_id) {
return;
}
let Some(thread_updated_at) = timestamp_ms_field(thread, "updatedAt") else {
return;
};
self.store.update_task(local_task_id, |link| {
if !link.running && link.completed_at.is_none() && link.updated_at > thread_updated_at {
link.updated_at = thread_updated_at;
link.completed_at = Some(thread_updated_at);
}
});
Comment on lines +3146 to +3158

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Restrict timestamp repair to terminal tasks.

running == false is not sufficient: this file also creates/restores non-terminal tasks with status = "active" and running = false (Lines 1576-1577 and 3763-3765). Since this helper runs during transcript reads (Line 1330), it can persist completed_at on an idle active task and leave stale completion data when the task is later resumed. Gate the repair on a terminal status or an explicit legacy marker, and add an active-task regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@executor/src/runtime_work/handler.rs` around lines 3146 - 3158, Update
repair_legacy_task_activity_time to repair timestamps only for terminal tasks or
tasks explicitly marked as legacy, rather than relying solely on running ==
false; preserve active non-running tasks without setting completed_at. Add a
regression test covering an idle active task during transcript reads and
verifying it remains resumable without stale completion data.

}

#[cfg(test)]
fn register_pending_thread_event_route(
&self,
Expand Down Expand Up @@ -6141,10 +6159,20 @@ mod tests {
"Task".to_owned(),
);
link.thread_id = Some("thread-1".to_owned());
link.updated_at = 1_780_000_000_000;
handler.upsert_local_task(link);
handler.mark_active_local_task(local_task_id);
handler.register_thread_event_route("thread-1", local_task_id.to_owned(), request, true);

assert_eq!(
handler
.store
.get_task(local_task_id)
.expect("registered task should remain stored")
.updated_at,
1_780_000_000_000
);

handler.route_codex_notification(json!({
"method": "item/agentMessage/delta",
"params": {
Expand Down Expand Up @@ -6179,6 +6207,35 @@ mod tests {
let _ = fs::remove_file(index_path);
}

#[test]
fn thread_read_repairs_legacy_activity_time_pollution() {
let index_path = temp_runtime_work_index_path("repair-legacy-activity-time");
let mut handler = RuntimeWorkRpcHandler::new("device-1", "/bin/false");
handler.store = RuntimeWorkStore::new(index_path.clone());
let local_task_id = "runtime-task-1";
let mut link = RuntimeTaskLink::new_pending(
local_task_id.to_owned(),
"/tmp/project".to_owned(),
"Task".to_owned(),
);
link.status = "done".to_owned();
link.running = false;
link.updated_at = 1_790_000_000_000;
link.completed_at = None;
handler.upsert_local_task(link);

handler
.repair_legacy_task_activity_time(local_task_id, &json!({"updatedAt": 1_780_000_000}));

let repaired = handler
.store
.get_task(local_task_id)
.expect("repaired task should remain stored");
assert_eq!(repaired.updated_at, 1_780_000_000_000);
assert_eq!(repaired.completed_at, Some(1_780_000_000_000));
let _ = fs::remove_file(index_path);
}

#[test]
fn archived_cleanup_targets_include_managed_worktree_and_local_attachment() {
let mut link = RuntimeTaskLink::new_pending(
Expand Down
85 changes: 84 additions & 1 deletion wework/src/components/chat/ChatInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,22 @@ import type { GuidanceWorkbenchMessage, QueuedWorkbenchMessage } from '@/types/w

vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, options?: string | { action?: string; count?: number; device?: string }) => {
t: (
key: string,
options?: string | { action?: string; count?: number; device?: string; location?: string }
) => {
if (typeof options === 'string') return options
if (key === 'workbench.code_comment_count') {
return `${options?.count ?? 0} 个评论`
}
if (key === 'workbench.project_work_trigger_device_aria') {
return `${options?.action ?? ''},当前设备 ${options?.device ?? ''}`
}
if (key === 'workbench.composer_execution_device_label') {
return `运行于${options?.location ?? ''} · ${options?.device ?? ''}`
}
if (key === 'workbench.environment_cloud_device') return '云设备'
if (key === 'workbench.environment_local') return '本机'
if (key === 'workbench.remove_code_comments') {
return '移除代码评论'
}
Expand Down Expand Up @@ -3275,6 +3283,81 @@ describe('ChatInput', () => {
).not.toBeInTheDocument()
})

test('shows a subtle cloud-device control and opens device selection for a new task', async () => {
const devices: DeviceInfo[] = [
{
id: 1,
device_id: 'cloud-online',
name: 'Cloud Online',
status: 'online',
is_default: false,
device_type: 'cloud',
},
]

render(
<ChatInput
value=""
onChange={vi.fn()}
onSubmit={vi.fn()}
disabled={false}
variant="desktop"
projectWork={projectWorkControls({
devices,
currentStandaloneDeviceId: 'cloud-online',
})}
/>
)

const deviceButton = screen.getByTestId('composer-execution-device-button')
expect(deviceButton).toHaveAccessibleName('运行于云设备 · Cloud Online')
expect(deviceButton).toBeEnabled()

await userEvent.click(deviceButton)
expect(screen.getByTestId('project-work-menu')).toBeInTheDocument()
})

test('shows the actual runtime device without allowing an active task to switch it', () => {
const devices: DeviceInfo[] = [
{
id: 1,
device_id: 'local-online',
name: 'Local Online',
status: 'online',
is_default: false,
device_type: 'local',
},
{
id: 2,
device_id: 'cloud-online',
name: 'Cloud Online',
status: 'online',
is_default: false,
device_type: 'cloud',
},
]

render(
<ChatInput
value=""
onChange={vi.fn()}
onSubmit={vi.fn()}
disabled={false}
variant="desktop"
showProjectWorkBar={false}
projectWork={projectWorkControls({
devices,
currentStandaloneDeviceId: 'local-online',
currentRuntimeDeviceId: 'cloud-online',
})}
/>
)

const deviceButton = screen.getByTestId('composer-execution-device-button')
expect(deviceButton).toHaveAccessibleName('运行于云设备 · Cloud Online')
expect(deviceButton).toHaveAttribute('aria-disabled', 'true')
})
Comment on lines +3286 to +3359

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Add real-Tauri execution-device launch coverage.

These tests only verify the control and menu. Add a real-backend E2E that selects local/cloud execution, launches a task, verifies the runtime target, then verifies switching is blocked while active. Include failure/recovery and cleanup evidence in the QA plan.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wework/src/components/chat/ChatInput.test.tsx` around lines 3286 - 3359,
Extend the execution-device coverage around the existing ChatInput tests with a
real-Tauri backend E2E: select both local and cloud devices, launch a task,
verify the actual runtime target, and confirm device switching is blocked while
the task is active. Add assertions for launch failure and subsequent recovery,
and document cleanup evidence in the QA plan; keep the current control and menu
unit-test coverage intact.

Source: Coding guidelines


test('uses the project work action as the standalone trigger accessible name', () => {
const devices: DeviceInfo[] = [
{
Expand Down
1 change: 1 addition & 0 deletions wework/src/components/chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface ProjectWorkControls {
currentProject?: ProjectWithTasks | null
currentProjectId?: number
currentStandaloneDeviceId?: string | null
currentRuntimeDeviceId?: string | null
selectedDeviceWorkspaceId?: number | null
pendingProjectWorkspaceProjectId?: number | null
executionMode: ProjectExecutionMode
Expand Down
35 changes: 34 additions & 1 deletion wework/src/components/chat/composer/ComposerToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { ArrowUp, ChevronDown, ClipboardList, Clock3, CornerDownRight, Zap } from 'lucide-react'
import {
ArrowUp,
ChevronDown,
ClipboardList,
Clock3,
Cloud,
CornerDownRight,
Laptop,
Zap,
} from 'lucide-react'
import { useLayoutEffect, useRef, useState } from 'react'
import { ActionMenu } from '@/components/common/ActionMenu'
import type { ComposerSubmitOptions } from './ComposerTextarea'
Expand Down Expand Up @@ -36,6 +45,12 @@ interface ComposerToolbarProps {
onPause?: () => void
onQuickPhraseSelect: (phrase: QuickPhrase) => void
onSubmit: (options?: ComposerSubmitOptions) => void
executionDevice?: {
kind: 'cloud' | 'local'
label: string
selectable: boolean
onSelect?: () => void
}
}

const COMPACT_TOOLBAR_WIDTH = 475
Expand Down Expand Up @@ -66,6 +81,7 @@ export function ComposerToolbar({
onPause,
onQuickPhraseSelect,
onSubmit,
executionDevice,
}: ComposerToolbarProps) {
const { t } = useTranslation('common')
const toolbarRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -115,6 +131,23 @@ export function ComposerToolbar({
) : null}
</div>
<div className="flex min-w-0 items-center gap-1.5">
{executionDevice && (
<button
type="button"
data-testid="composer-execution-device-button"
onClick={executionDevice.onSelect}
aria-disabled={!executionDevice.selectable}
title={executionDevice.label}
aria-label={executionDevice.label}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-text-muted transition-colors hover:bg-muted hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus/30 aria-disabled:cursor-default aria-disabled:hover:bg-transparent aria-disabled:hover:text-text-muted"
>
{executionDevice.kind === 'cloud' ? (
<Cloud className="h-4 w-4" aria-hidden="true" />
) : (
<Laptop className="h-4 w-4" aria-hidden="true" />
)}
</button>
)}
<ContextUsageIndicator
usage={contextUsage}
disabled={disabled}
Expand Down
41 changes: 40 additions & 1 deletion wework/src/components/chat/composer/ProjectChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ import type {
} from '@/types/api'
import type { CodeCommentContext, WorkspaceFileApi, WorkspaceTarget } from '@/types/workspace-files'
import type { DragEventHandler } from 'react'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { cn } from '@/lib/utils'
import { isCloudDevice } from '@/lib/device-selection'
import { findWorkbenchDevice, getProjectDeviceId } from '@/lib/workbench-device'
import { findProjectDeviceWorkspace } from '@/features/workbench/workbenchRuntimeHelpers'
import { useTranslation } from '@/hooks/useTranslation'
import type { ProjectWorkControls } from '../ChatInput'
import { AttachmentBadges } from './AttachmentBadges'
import { ComposerToolbar } from './ComposerToolbar'
Expand Down Expand Up @@ -109,12 +113,45 @@ export function ProjectChatComposer({
isStreaming = false,
onPause,
}: ProjectChatComposerProps) {
const { t } = useTranslation('common')
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const [deviceMenuOpenSignal, setDeviceMenuOpenSignal] = useState(0)
const textareaRef = useAutoResizeTextarea(value, 168)
const canSend =
(value.trim().length > 0 || attachments.length > 0 || codeComments.length > 0) &&
!disabled &&
!submitDisabled
const executionDevice = useMemo(() => {
const selectedProjectWorkspace = findProjectDeviceWorkspace(
projectWork.runtimeWork,
projectWork.currentProject?.id,
projectWork.selectedDeviceWorkspaceId
)
const deviceId =
projectWork.currentRuntimeDeviceId ??
selectedProjectWorkspace?.deviceId ??
getProjectDeviceId(projectWork.currentProject) ??
projectWork.currentStandaloneDeviceId
const device = findWorkbenchDevice(projectWork.devices, deviceId)
if (!deviceId || !device) return undefined
const cloud = isCloudDevice(device)
const location = cloud
? t('workbench.environment_cloud_device', '云设备')
: t('workbench.environment_local', '本机')
const deviceName = device.name?.trim() || device.device_id
return {
kind: cloud ? ('cloud' as const) : ('local' as const),
label: t('workbench.composer_execution_device_label', {
location,
device: deviceName,
}),
selectable: showProjectWorkBar && !projectWork.currentRuntimeDeviceId,
onSelect:
showProjectWorkBar && !projectWork.currentRuntimeDeviceId
? () => setDeviceMenuOpenSignal(signal => signal + 1)
: undefined,
}
}, [projectWork, showProjectWorkBar, t])
const handleDragOver: DragEventHandler<HTMLFormElement> = event => {
if (!hasDraggedFiles(event.dataTransfer)) return

Expand Down Expand Up @@ -185,6 +222,7 @@ export function ProjectChatComposer({
worktreeBranch={projectWork.worktreeBranch}
onWorktreeBranchChange={projectWork.onWorktreeBranchChange}
projectMenuOpenSignal={projectWork.projectMenuOpenSignal}
externalMenuOpenSignal={deviceMenuOpenSignal}
projectMenuAnchorElement={projectWork.projectMenuAnchorElement}
className="min-h-10 rounded-t-[26px] bg-surface px-4"
buttonClassName="text-sm leading-[18px] text-text-secondary hover:bg-background/70 hover:text-text-primary"
Expand Down Expand Up @@ -287,6 +325,7 @@ export function ProjectChatComposer({
onPause={onPause}
onQuickPhraseSelect={handleQuickPhraseSelect}
onSubmit={options => onSubmit(value, options)}
executionDevice={executionDevice}
/>
</form>
</div>
Expand Down
13 changes: 13 additions & 0 deletions wework/src/components/chat/composer/ProjectWorkBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ interface ProjectWorkBarProps {
menuClassName?: string
emptyLabel?: string
projectMenuOpenSignal?: number
externalMenuOpenSignal?: number
projectMenuAnchorElement?: HTMLElement | null
}

Expand Down Expand Up @@ -115,6 +116,7 @@ export function ProjectWorkBar({
menuClassName,
emptyLabel,
projectMenuOpenSignal,
externalMenuOpenSignal,
projectMenuAnchorElement = null,
}: ProjectWorkBarProps) {
const { t } = useTranslation('common')
Expand All @@ -124,6 +126,7 @@ export function ProjectWorkBar({
const triggerButtonRef = useRef<HTMLButtonElement>(null)
const executionModeButtonRef = useRef<HTMLButtonElement>(null)
const handledProjectMenuOpenSignalRef = useRef<number | undefined>(undefined)
const handledExternalMenuOpenSignalRef = useRef<number | undefined>(undefined)

const searchInputRef = useRef<HTMLInputElement>(null)
const [open, setOpen] = useState(false)
Expand Down Expand Up @@ -405,6 +408,16 @@ export function ProjectWorkBar({
setOpen(true)
}, [closeExecutionModeMenu, isMobile, projectMenuAnchorElement, projectMenuOpenSignal])

useEffect(() => {
if (!externalMenuOpenSignal) return
if (handledExternalMenuOpenSignalRef.current === externalMenuOpenSignal) return

handledExternalMenuOpenSignalRef.current = externalMenuOpenSignal
closeExecutionModeMenu()
setExternalMenuAnchorElement(null)
setOpen(true)
}, [closeExecutionModeMenu, externalMenuOpenSignal])

const handleSelectProject = (projectId: number) => {
const option = projectWorkspaceOptionByProjectId.get(projectId)
if (hasRuntimeWork && option?.kind === 'multi') {
Expand Down
Loading
Loading