diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index 6e371a7387..7946d259f0 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -78,6 +78,24 @@ source scripts/ohos-env.sh ## Responsive interaction semantics +- Every HarmonyOS change that can affect presentation or interaction — including + state, actions, ViewModels, and components — must treat compact phones, wide + screens, and foldables in both folded and unfolded postures as first-class + targets. Do not implement or review a feature against only the currently + visible layout. +- Before editing, search for every responsive host and presentation branch that + exposes the affected action or state (for example compact overlays, wide + master/detail hosts, sidebars, routed destinations, sheets, and popovers). + Keep their behavior and capability gating aligned; a fix is incomplete while + an equivalent wide or compact entry point still uses legacy behavior. +- Do not infer device class from a single width captured at startup. Layout must + remain correct when a foldable changes posture or window size while the app is + running, preserving state, selection, and interaction meaning across the + transition. +- For UI changes, verify at minimum one compact layout and one wide layout. When + a foldable or resizable emulator/device is available, also exercise a live + folded/unfolded or resize transition. Record any unverified posture explicitly + in the handoff instead of treating a phone-only check as sufficient evidence. - Wide and compact layouts must keep the same interaction meaning. Responsive presentation may change spacing and available width, but it must not turn a lightweight anchored action menu into a bottom sheet by default. - Conversation-header overflow actions open from the top-right trigger as an anchored popover on both compact and wide layouts. Use a bottom sheet only when the content is a genuinely large or multi-step mobile workflow and the design explicitly calls for it. - Anchor popovers to their actual trigger with `bindPopup` or the equivalent platform API. Do not emulate the anchor with unrelated page-level absolute positioning. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index dc078b5a75..e46138d457 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -197,6 +197,10 @@ export const EN_US_MESSAGES: [string, string][] = [ ['remote.pickSession', 'Choose a session'], ['remote.pickSessionText', 'Open a session from the sidebar, or create one.'], ['remote.startSession', 'New session'], + ['remote.harness.title', 'Choose execution mode'], + ['remote.harness.minimal', 'Minimal'], + ['remote.harness.standard', 'Standard'], + ['remote.harness.ultimate', 'Ultimate'], ['remote.connectTitle', 'Connect desktop'], ['remote.connectText', 'Scan the QR code on the desktop to start remote work.'], ['remote.actions', 'Remote settings'], @@ -456,6 +460,10 @@ export const EN_US_MESSAGES: [string, string][] = [ ['chat.approve', 'Approve'], ['chat.reject', 'Reject'], ['chat.cancelTool', 'Cancel tool'], + ['chat.planView', 'View plan'], + ['chat.planBuild', 'Build Plan'], + ['chat.planBuildUnavailable', 'Update the desktop app to build plans from mobile.'], + ['chat.planBuildWaitForTurn', 'Wait for the current task to finish before building this plan.'], ['chat.toolInput', 'Tool input'], ['chat.reset', 'Reset'], ['chat.editJsonInput', 'Edit JSON input'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index 5d80e7a6a0..6f17c24526 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -197,6 +197,10 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['remote.pickSession', '选择一个会话'], ['remote.pickSessionText', '从侧边栏打开会话,或新建一个。'], ['remote.startSession', '新建会话'], + ['remote.harness.title', '选择执行模式'], + ['remote.harness.minimal', '极简'], + ['remote.harness.standard', '标准'], + ['remote.harness.ultimate', '极致'], ['remote.connectTitle', '连接桌面端'], ['remote.connectText', '扫描桌面端显示的二维码,开始远程处理任务。'], ['remote.actions', '远程设置'], @@ -456,6 +460,10 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['chat.approve', '批准'], ['chat.reject', '拒绝'], ['chat.cancelTool', '取消工具'], + ['chat.planView', '查看计划'], + ['chat.planBuild', '构建 Plan'], + ['chat.planBuildUnavailable', '请更新桌面端后再从手机构建 Plan。'], + ['chat.planBuildWaitForTurn', '请等待当前任务结束后再构建这个 Plan。'], ['chat.toolInput', '工具输入'], ['chat.reset', '重置'], ['chat.editJsonInput', '编辑 JSON 输入'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 8f9ddf2c7f..253d2ffee2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -126,6 +126,9 @@ export interface RemoteCommand { known_msg_count?: number; known_model_catalog_version?: number; turn_id?: string; + display_content?: string; + plan_file_path?: string; + plan_name?: string; tool_id?: string; model_id?: string; reason?: string; @@ -290,6 +293,22 @@ export interface SendMessageResponse extends CommandStatusResponse { turn_id?: string; } +export interface SteerTurnResponse extends CommandStatusResponse { + session_id?: string; + turn_id?: string; + steering_id?: string; +} + +export interface SteerTurnResult { + sessionId: string; + turnId: string; + steeringId: string; +} + +export const REMOTE_CAPABILITY_DIALOG_STEER_V1: string = 'dialog_steer_v1'; +export const REMOTE_CAPABILITY_PLAN_BUILD_V1: string = 'plan_build_v1'; +export const REMOTE_CAPABILITY_HARNESS_PROFILES_V1: string = 'harness_profiles_v1'; + export interface RemoteModelConfig { id: string; name: string; @@ -360,6 +379,13 @@ export interface RemoteToolStatusResponse { result_preview?: string; error_preview?: string; exit_code?: number; + plan?: RemotePlanToolResponse; +} + +export interface RemotePlanToolResponse { + file_path: string; + name: string; + overview?: string; } export interface RemoteQuestionAnswerPayload { @@ -372,10 +398,13 @@ export interface RemoteQuestionAnswerPayload { export interface ChatMessageItemResponse { type?: string; + steering_id?: string; + round_index?: number; content?: string; tool?: RemoteToolStatusResponse; is_subagent?: boolean; subItems?: ChatMessageItemResponse[]; + images?: ImageAttachment[]; } export interface ImageAttachment { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets index b0cf70479a..c58e54c028 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets @@ -23,11 +23,22 @@ export enum ConversationIntentType { RemoveImage = 'remove_image', OpenFilePreview = 'open_file_preview', DownloadFile = 'download_file', + BuildPlan = 'build_plan', Send = 'send', VoiceInput = 'voice_input', ChatInputChanged = 'chat_input_changed' } +export class PlanBuildRequest { + readonly path: string; + readonly name: string; + + constructor(path: string, name: string) { + this.path = path; + this.name = name; + } +} + export class ConversationIntent { readonly type: ConversationIntentType; readonly value: string; @@ -35,6 +46,7 @@ export class ConversationIntent { readonly updatedInput?: Object; readonly answers?: ConversationUiQuestionAnswer; readonly filePreviewRequest?: FilePreviewRequest; + readonly planBuildRequest?: PlanBuildRequest; constructor( type: ConversationIntentType, @@ -42,7 +54,8 @@ export class ConversationIntent { toolId: string = '', updatedInput?: Object, answers?: ConversationUiQuestionAnswer, - filePreviewRequest?: FilePreviewRequest + filePreviewRequest?: FilePreviewRequest, + planBuildRequest?: PlanBuildRequest ) { this.type = type; this.value = value; @@ -50,6 +63,7 @@ export class ConversationIntent { this.updatedInput = updatedInput; this.answers = answers; this.filePreviewRequest = filePreviewRequest; + this.planBuildRequest = planBuildRequest; } } @@ -84,4 +98,16 @@ export class ConversationIntents { new FilePreviewRequest(reference, label) ); } + + static buildPlan(path: string, name: string): ConversationIntent { + return new ConversationIntent( + ConversationIntentType.BuildPlan, + '', + '', + undefined, + undefined, + undefined, + new PlanBuildRequest(path, name) + ); + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index 9ce3a59e6a..c9f8dfd93e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -32,6 +32,7 @@ export interface ConversationIntentDispatcherHooks { readonly removeImage: (id: string) => void; readonly openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void; readonly downloadFile: (path: string) => void; + readonly buildPlan: (path: string, name: string) => Promise; readonly send: () => Promise; readonly voiceInput: () => Promise; readonly inputChanged: (route: AppRoute, value: string) => void; @@ -81,6 +82,11 @@ export class ConversationIntentDispatcher { case ConversationIntentType.OpenFilePreview: if (intent.filePreviewRequest) this.hooks.openFilePreview(route, intent.filePreviewRequest); return; case ConversationIntentType.DownloadFile: this.hooks.downloadFile(intent.value); return; + case ConversationIntentType.BuildPlan: + if (route === AppRoute.RemoteChat && intent.planBuildRequest) { + void this.hooks.buildPlan(intent.planBuildRequest.path, intent.planBuildRequest.name); + } + return; case ConversationIntentType.Send: void this.hooks.send(); return; case ConversationIntentType.VoiceInput: void this.hooks.voiceInput(); return; case ConversationIntentType.ChatInputChanged: this.hooks.inputChanged(route, intent.value); return; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets index 371e3c4e4e..eed17c9867 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -12,6 +12,7 @@ import { import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { DeviceDirectoryState } from '../state/DeviceDirectoryState'; import { RemotePageState } from '../state/RemotePageState'; +import { REMOTE_CAPABILITY_HARNESS_PROFILES_V1 } from '../../model/RemoteModels'; import { AppSidebar } from './AppSidebar'; import { ConnectView } from './ConnectView'; import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; @@ -79,11 +80,13 @@ export struct AppSidebarSurface { this.remotePageState.desktopName, selectedSessionId: this.remoteSelectedSessionId(), runningSessionId: this.remoteRunningSessionId(), + supportsHarnessProfiles: this.remotePageState.supportsHostCapability( + REMOTE_CAPABILITY_HARNESS_PROFILES_V1), workspacePickerPlacement: this.sessionDetailsPlacement, onOpenSession: this.actions.onSidebar.openSession, - onCreateInWorkspace: (deviceId: string, path: string) => { + onCreateInWorkspace: (deviceId: string, path: string, agentType: string) => { this.actions.onSidebar.close(); - this.actions.onRemoteHome.createInWorkspace(path, 'code', deviceId); + this.actions.onRemoteHome.createInWorkspace(path, agentType, deviceId); }, onOpenWorkspace: (deviceId: string, path: string) => { this.actions.onRemoteHome.selectWorkspace(path, deviceId); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index bb77bee144..5bb66e6383 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -72,6 +72,7 @@ export struct ChatMessageBubble { @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; private readonly fileReferenceCache: MessageFileReferenceProjectionCache = new MessageFileReferenceProjectionCache(); @@ -302,7 +303,12 @@ export struct ChatMessageBubble { if (this.isSubagentEntry(entry)) { this.SubagentGroup(entry, path, childActiveScope) } else { - if (this.isThinkingEntry(entry)) { + if (this.isUserSteeringEntry(entry)) { + ChatUserMessageBubble({ + item: this.steeringMessage(entry, path), + showRetryAction: false + }) + } else if (this.isThinkingEntry(entry)) { this.Thinking( entry.content || '', statusOverride || this.item.status, @@ -339,7 +345,8 @@ export struct ChatMessageBubble { onRejectTool: this.onRejectTool, onCancelTool: this.onCancelTool, onAnswerQuestion: this.onAnswerQuestion, - onOpenFilePreview: this.onOpenFilePreview + onOpenFilePreview: this.onOpenFilePreview, + onBuildPlan: this.onBuildPlan }) } @@ -387,6 +394,9 @@ export struct ChatMessageBubble { }, onOpenFilePreview: (path: string, label: string) => { this.onOpenFilePreview(path, label); + }, + onBuildPlan: (path: string, name: string) => { + this.onBuildPlan(path, name); } }) } @@ -657,6 +667,21 @@ export struct ChatMessageBubble { return ChatMessageStructurePolicy.isThinkingEntry(entry); } + private isUserSteeringEntry(entry: ConversationUiMessageItem): boolean { + return ChatMessageStructurePolicy.isUserSteeringEntry(entry); + } + + private steeringMessage(entry: ConversationUiMessageItem, path: string): ConversationUiMessage { + return { + id: entry.steering_id || `${this.item.id}-${path}-steering`, + role: 'user', + text: entry.content || '', + status: 'sent', + detail: '', + images: entry.images + }; + } + private isTextEntry(entry: ConversationUiMessageItem): boolean { return ChatMessageStructurePolicy.isTextEntry(entry); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index eb8104bcfa..90fb96ab47 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -26,9 +26,9 @@ import { RemoteLogger } from '../../services/RemoteLogger'; /** * Repeat retains this adapter by row identity while the observed row mutates. - * Mapping the protocol message inside a normal component build keeps the - * presentation leaf independent from transport DTOs without snapshotting a - * temporary mapped object in Repeat's item builder. + * Keep one observable presentation message for the whole reusable lifetime: + * aboutToAppear covers a new instance, aboutToReuse covers an instance thawed + * from Repeat's pool, and the monitor covers an already-visible row update. */ @ReusableV2 @ComponentV2 @@ -56,41 +56,49 @@ struct ChatAssistantTimelineRow { @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; aboutToAppear(): void { this.synchronizeMessage(); } - @Monitor('row.message', 'renderRevision') + aboutToReuse(): void { + this.synchronizeMessage(); + } + + @Monitor('row.id', 'row.message', 'renderRevision') private onMessageChanged(): void { this.synchronizeMessage(); } build() { - if (this.row.message) { - ChatMessageBubble({ - item: this.uiMessage, - renderRevision: this.renderRevision, - isStreaming: this.row.isStreaming, - isFinalizing: this.row.isFinalizing, - showRetryAction: this.row.showRetryAction, - isBusy: this.isBusy, - downloadingFilePath: this.downloadingFilePath, - downloadedFilePath: this.downloadedFilePath, - fileDownloadStatus: this.fileDownloadStatus, - activeFilePreviewPath: this.activeFilePreviewPath, - activeFilePreviewLoading: this.activeFilePreviewLoading, - onApproveTool: this.onApproveTool, - onRejectTool: this.onRejectTool, - onCancelTool: this.onCancelTool, - onAnswerQuestion: this.onAnswerQuestion, - onCopyMessage: this.onCopyMessage, - onRetryMessage: this.onRetryMessage, - onOpenFilePreview: this.onOpenFilePreview, - onDownloadFile: this.onDownloadFile - }) - } + // The parent only creates this row for a renderable message. Do not put a + // second structural condition around the reusable subtree: a V2 component + // can be thawed before its retained @Local state has been synchronized, + // and omitting the child at that point leaves a permanently hollow row. + ChatMessageBubble({ + item: this.uiMessage, + renderRevision: this.renderRevision, + isStreaming: this.row.isStreaming, + isFinalizing: this.row.isFinalizing, + showRetryAction: this.row.showRetryAction, + isBusy: this.isBusy, + downloadingFilePath: this.downloadingFilePath, + downloadedFilePath: this.downloadedFilePath, + fileDownloadStatus: this.fileDownloadStatus, + activeFilePreviewPath: this.activeFilePreviewPath, + activeFilePreviewLoading: this.activeFilePreviewLoading, + onApproveTool: this.onApproveTool, + onRejectTool: this.onRejectTool, + onCancelTool: this.onCancelTool, + onAnswerQuestion: this.onAnswerQuestion, + onCopyMessage: this.onCopyMessage, + onRetryMessage: this.onRetryMessage, + onOpenFilePreview: this.onOpenFilePreview, + onBuildPlan: this.onBuildPlan, + onDownloadFile: this.onDownloadFile + }) } private synchronizeMessage(): void { @@ -134,6 +142,7 @@ export struct ChatTimeline { @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; // Says whether an open reuses this subtree or rebuilds it. A rebuild means @@ -210,6 +219,7 @@ export struct ChatTimeline { } else { ChatAssistantTimelineRow({ row: repeatItem.item, + renderRevision: this.timelineRevision, isBusy: this.isBusy, downloadingFilePath: this.downloadingFilePath, downloadedFilePath: this.downloadedFilePath, @@ -237,6 +247,9 @@ export struct ChatTimeline { onOpenFilePreview: (path: string, label: string) => { this.onOpenFilePreview(path, label); }, + onBuildPlan: (path: string, name: string) => { + this.onBuildPlan(path, name); + }, onDownloadFile: (path: string) => { this.onDownloadFile(path); } @@ -274,6 +287,7 @@ export struct ChatTimeline { onCopyMessage: this.onCopyMessage, onRetryMessage: this.onRetryMessage, onOpenFilePreview: this.onOpenFilePreview, + onBuildPlan: this.onBuildPlan, onDownloadFile: this.onDownloadFile }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 13ef786a02..efce0fb9ec 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -424,7 +424,7 @@ export struct ComposerBar { src: $r('app.media.gpt_composer_send_arrow'), iconWidth: 16, iconHeight: 18, - tint: CARD + tint: this.primaryActionForegroundColor() }) } } @@ -755,14 +755,18 @@ export struct ComposerBar { private shouldShowActiveActionSurface(): boolean { const action = this.primaryAction(); - return this.sendTransitionActive || action === ComposerPrimaryAction.Send || action === ComposerPrimaryAction.Stop; + return this.sendTransitionActive || action === ComposerPrimaryAction.Send || + action === ComposerPrimaryAction.SendBlocked || action === ComposerPrimaryAction.Stop; } private shouldShowStopGlyph(): boolean { - return this.sendTransitionActive || this.primaryAction() === ComposerPrimaryAction.Stop; + return this.primaryAction() === ComposerPrimaryAction.Stop; } private activeActionSurfaceColor(): ResourceColor { + if (this.primaryAction() === ComposerPrimaryAction.SendBlocked) { + return SOFT; + } return this.primaryActionPressed || this.sendTransitionActive ? MUTED : INK; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 52e48aa0a5..c80872080c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -231,6 +231,9 @@ export struct ConversationView { onOpenFilePreview: (path: string, label: string) => { this.onOpenFilePreview(path, label); }, + onBuildPlan: (path: string, name: string) => { + this.onBuildPlan(path, name); + }, onDownloadFile: (path: string) => { this.onDownloadFile(path); } @@ -518,6 +521,9 @@ export struct ConversationView { private onOpenFilePreview(path: string, label: string): void { this.onIntent(ConversationIntents.openFilePreview(path, label)); } + private onBuildPlan(path: string, name: string): void { + this.onIntent(ConversationIntents.buildPlan(path, name)); + } private onSelectModel(modelId: string): void { this.dispatchValue(ConversationIntentType.SelectModel, modelId); } private onPickImages(): void { this.dispatchSimple(ConversationIntentType.PickImages); } private onRemoveImage(imageId: string): void { this.dispatchValue(ConversationIntentType.RemoveImage, imageId); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/HarnessProfileMenu.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/HarnessProfileMenu.ets new file mode 100644 index 0000000000..c764ed3a73 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/HarnessProfileMenu.ets @@ -0,0 +1,97 @@ +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { HarnessProfilePolicy } from '../policy/HarnessProfilePolicy'; +import { CARD, INK, LINE, MUTED, SHADOW_SUBTLE, TRANSPARENT } from './Theme'; + +/** Compact creation-time selector for the three supported Harness profiles. */ +@ComponentV2 +export struct HarnessProfileMenu { + @Param showTitle: boolean = true; + @Param includeCowork: boolean = false; + @Param menuWidth: number = 190; + @Event onSelect: (agentType: string) => void = (_agentType: string) => {}; + + build() { + Column({ space: 2 }) { + if (this.showTitle) { + Text(RemoteI18n.t('remote.harness.title')) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .width('100%') + .height(30) + .padding({ left: 14, right: 14 }) + } + this.ProfileRow('minimal', RemoteI18n.t('remote.harness.minimal'), 1) + this.ProfileRow('standard', RemoteI18n.t('remote.harness.standard'), 2) + this.ProfileRow('ultimate', RemoteI18n.t('remote.harness.ultimate'), 3) + if (this.includeCowork) { + Divider().strokeWidth(0.5).color(LINE).margin({ top: 4, bottom: 4 }) + this.AgentRow('Cowork', 'Cowork') + } + } + .width(this.menuWidth) + .padding({ top: 8, bottom: 8 }) + .backgroundColor(CARD) + .borderRadius(16) + .border({ width: 0.5, color: LINE }) + .shadow({ radius: 20, color: SHADOW_SUBTLE, offsetY: 8 }) + } + + @Builder + private ProfileRow(profileId: string, label: string, density: number) { + Row({ space: 12 }) { + this.DensityMark(density) + Text(label) + .fontSize(MobileDesignTypography.titleSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + } + .width('100%') + .height(46) + .padding({ left: 14, right: 14 }) + .borderRadius(10) + .backgroundColor(TRANSPARENT) + .onClick(() => { + this.onSelect(HarnessProfilePolicy.agentType(profileId)); + }) + } + + @Builder + private AgentRow(label: string, agentType: string) { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.person_2')) + .fontSize(17) + .fontColor([INK]) + .width(22) + .height(22) + Text(label) + .fontSize(MobileDesignTypography.titleSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + } + .width('100%') + .height(46) + .padding({ left: 14, right: 14 }) + .borderRadius(10) + .backgroundColor(TRANSPARENT) + .onClick(() => this.onSelect(agentType)) + } + + @Builder + private DensityMark(density: number) { + Row({ space: 2 }) { + Text('').width(4).height(8).backgroundColor(INK).borderRadius(2) + if (density >= 2) { + Text('').width(4).height(13).backgroundColor(INK).borderRadius(2) + } + if (density >= 3) { + Text('').width(4).height(18).backgroundColor(INK).borderRadius(2) + } + } + .width(22) + .height(22) + .alignItems(VerticalAlign.Center) + .justifyContent(FlexAlign.Center) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index 1ec089c62f..c6bee9e3ab 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -21,6 +21,7 @@ import { import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy'; import { SessionDetailsView } from './SessionDetailsView'; import { RemoteSessionRow } from './RemoteSessionRow'; +import { HarnessProfileMenu } from './HarnessProfileMenu'; import { SessionListInputs, SessionListProjection, @@ -49,6 +50,7 @@ export struct RemoteSessionList { @Param showWorkspaceMetadata: boolean = false; @Param showUpdatedMetadata: boolean = false; @Param showStatusMetadata: boolean = false; + @Param supportsHarnessProfiles: boolean = false; @Event onCreate: () => void = () => {}; @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @@ -326,16 +328,26 @@ export struct RemoteSessionList { @Builder private ProjectCreateMenu(path: string) { - Column({ space: 2 }) { - this.ProjectCreateMenuItem('Code', 'code', path) - this.ProjectCreateMenuItem('Cowork', 'Cowork', path) + if (this.supportsHarnessProfiles) { + HarnessProfileMenu({ + includeCowork: true, + onSelect: (agentType: string) => { + this.createMenuPath = ''; + this.onCreateInWorkspace(path, agentType); + } + }) + } else { + Column({ space: 2 }) { + this.ProjectCreateMenuItem('Code', 'code', path) + this.ProjectCreateMenuItem('Cowork', 'Cowork', path) + } + .width(150) + .padding({ top: 8, bottom: 8 }) + .backgroundColor(CARD) + .borderRadius(14) + .border({ width: 0.5, color: LINE }) + .shadow({ radius: 20, color: SHADOW_SUBTLE, offsetY: 8 }) } - .width(150) - .padding({ top: 8, bottom: 8 }) - .backgroundColor(CARD) - .borderRadius(14) - .border({ width: 0.5, color: LINE }) - .shadow({ radius: 20, color: SHADOW_SUBTLE, offsetY: 8 }) } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets index d88d1d1cbd..5303d4d976 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets @@ -13,6 +13,7 @@ import { } from '../policy/SessionListProjection'; import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; import { SidebarDirectoryPreviewPolicy } from '../policy/SidebarDirectoryPreviewPolicy'; +import { HarnessProfileMenu } from './HarnessProfileMenu'; import { INK, MUTED, @@ -40,16 +41,19 @@ struct SidebarWorkspaceGroup { @Param bodyIndent: number = 10; @Param expanded: boolean = false; @Param loadStatus: WorkspaceDirectoryStatus = 'idle'; + @Param supportsHarnessProfiles: boolean = false; @Event onOpenWorkspace: (path: string) => void = (_path: string) => {}; @Event onExpandWorkspace: (path: string) => Promise = async (_path: string) => {}; @Event onExpandedChange: (path: string, expanded: boolean) => void = (_path: string, _expanded: boolean) => {}; @Event onLoadStatusChange: (path: string, status: WorkspaceDirectoryStatus) => void = (_path: string, _status: WorkspaceDirectoryStatus) => {}; - @Event onCreateInWorkspace: (path: string) => void = (_path: string) => {}; + @Event onCreateInWorkspace: (path: string, agentType: string) => void = + (_path: string, _agentType: string) => {}; @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onSessionActions: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Local visibleSessionCount: number = SidebarDirectoryPreviewPolicy.PREVIEW_COUNT; + @Local showCreateMenu: boolean = false; @Monitor('sessions') onSessionsChanged(): void { @@ -133,8 +137,28 @@ struct SidebarWorkspaceGroup { .width(30) .height(40) .accessibilityText(RemoteI18n.t('sidebar.newInWorkspace')) + .bindPopup(this.showCreateMenu, { + builder: () => { + this.CreateModeMenu() + }, + placement: Placement.Top, + popupColor: TRANSPARENT, + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 6, + onStateChange: (event) => { + if (!event.isVisible) { + this.showCreateMenu = false; + } + } + }) .onClick(() => { - this.onCreateInWorkspace(this.path); + if (this.supportsHarnessProfiles) { + this.showCreateMenu = !this.showCreateMenu; + } else { + this.onCreateInWorkspace(this.path, 'code'); + } }) } .width('100%') @@ -144,6 +168,16 @@ struct SidebarWorkspaceGroup { .borderRadius(10) } + @Builder + private CreateModeMenu() { + HarnessProfileMenu({ + onSelect: (agentType: string) => { + this.showCreateMenu = false; + this.onCreateInWorkspace(this.path, agentType); + } + }) + } + @Builder private SessionLoadingRow() { Row({ space: 8 }) { @@ -316,11 +350,13 @@ export struct SidebarDeviceGroup { @Param query: string = ''; @Param showHeader: boolean = false; @Param isActive: boolean = false; + @Param supportsHarnessProfiles: boolean = false; @Event onToggle: () => void = () => {}; @Event onRetry: () => void = () => {}; @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onSessionActions: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @Event onCreateInWorkspace: (path: string) => void = (_path: string) => {}; + @Event onCreateInWorkspace: (path: string, agentType: string) => void = + (_path: string, _agentType: string) => {}; @Event onOpenWorkspace: (path: string) => void = (_path: string) => {}; @Event onExpandWorkspace: (path: string) => Promise = async (_path: string) => {}; @Event onWorkspaceExpandedChange: (path: string, expanded: boolean) => void = @@ -357,6 +393,7 @@ export struct SidebarDeviceGroup { runningSessionId: this.runningSessionId, currentWorkspace: this.isCurrentWorkspace(entry.path), activeDevice: this.isActive, + supportsHarnessProfiles: this.supportsHarnessProfiles, bodyIndent: this.bodyIndent(), expanded: this.directoryState.workspaceExpanded(this.device.deviceId, entry.path), loadStatus: this.sessionsFor(entry.path).length > 0 ? diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarNavigationIcons.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarNavigationIcons.ets index ebffd803a0..29d960975e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarNavigationIcons.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarNavigationIcons.ets @@ -16,6 +16,10 @@ export struct SidebarDeviceIcon { iconHeight: this.iconSize, tint: this.tint }) + .translate({ + x: this.deviceOpticalOffsetX(), + y: this.deviceOpticalOffsetY() + }) } .width(this.iconSize + 8) .height(this.iconSize + 4) @@ -42,6 +46,27 @@ export struct SidebarDeviceIcon { } return this.iconSize + 4; } + + // The laptop and server artwork has asymmetric transparent padding. Keep the + // row's layout slot stable and compensate only the visible glyph's center. + private deviceOpticalOffsetX(): number { + const normalizedName = this.deviceName.trim().toLowerCase(); + if (normalizedName.includes('server') || normalizedName.includes('ecs') || + normalizedName.includes('host')) { + return -3; + } + return 0; + } + + private deviceOpticalOffsetY(): number { + const normalizedName = this.deviceName.trim().toLowerCase(); + if (normalizedName.includes('macbook') || normalizedName.includes('laptop') || + normalizedName.includes('notebook') || normalizedName.includes('server') || + normalizedName.includes('ecs') || normalizedName.includes('host')) { + return 2; + } + return 0; + } } /** HarmonyOS disclosure symbol kept in a stable slot by the caller. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets index 500c000db5..967d0a8d76 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets @@ -46,12 +46,13 @@ export struct SidebarWorkspaceSection { @Param selectedSessionId: string = ''; @Param runningSessionId: string = ''; @Param query: string = ''; + @Param supportsHarnessProfiles: boolean = false; @Param workspacePickerPlacement: SettingsPlacement = SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SessionDetails); @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onSessionActions: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @Event onCreateInWorkspace: (deviceId: string, path: string) => void = - (_deviceId: string, _path: string) => {}; + @Event onCreateInWorkspace: (deviceId: string, path: string, agentType: string) => void = + (_deviceId: string, _path: string, _agentType: string) => {}; @Event onOpenWorkspace: (deviceId: string, path: string) => void = (_deviceId: string, _path: string) => {}; @Event onExpandWorkspace: (deviceId: string, path: string) => Promise = @@ -238,6 +239,7 @@ export struct SidebarWorkspaceSection { query: this.query, showHeader: false, isActive: this.isActiveDevice(entry.deviceId), + supportsHarnessProfiles: this.supportsHarnessProfiles, onRetry: () => { if (this.isDirectoryEntry(entry)) { this.onRetryDevice(entry.deviceId); @@ -245,8 +247,8 @@ export struct SidebarWorkspaceSection { }, onOpenSession: this.onOpenSession, onSessionActions: this.onSessionActions, - onCreateInWorkspace: (path: string) => { - this.onCreateInWorkspace(entry.deviceId, path); + onCreateInWorkspace: (path: string, agentType: string) => { + this.onCreateInWorkspace(entry.deviceId, path, agentType); }, onOpenWorkspace: (path: string) => { this.onOpenWorkspace(entry.deviceId, path); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets index 396ebcd3e8..f0c8eacb8b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets @@ -29,6 +29,7 @@ export struct SubagentTaskCard { (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; @Local expanded: boolean = false; build() { @@ -122,7 +123,8 @@ export struct SubagentTaskCard { onRejectTool: this.onRejectTool, onCancelTool: this.onCancelTool, onAnswerQuestion: this.onAnswerQuestion, - onOpenFilePreview: this.onOpenFilePreview + onOpenFilePreview: this.onOpenFilePreview, + onBuildPlan: this.onBuildPlan }) } else if ((entry.content || '').trim().length > 0) { Column({ space: 3 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index bc081ce77b..7296285db5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -1,16 +1,22 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; -import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from '../state/ConversationUiModels'; +import { + ConversationUiPlanTool, + ConversationUiQuestionAnswer, + ConversationUiToolStatus +} from '../state/ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ToolFileReference, ToolFileReferenceResolver } from '../policy/ToolFileReferenceResolver'; import { ActivityGroupPolicy, ActivityRowPlan, ActivityThinkingPart } from '../policy/ActivityGroupPolicy'; import { ToolCollapseGroup, ToolCollapsePolicy } from '../policy/ToolCollapsePolicy'; import { ToolStatusPresentationPolicy } from '../policy/ToolStatusPresentationPolicy'; +import { PlanToolPolicy } from '../policy/PlanToolPolicy'; import { CARD, FILE_LINK, INK, LINE, MUTED, + SHADOW_SUBTLE, SOFT, STATUS_DANGER, STATUS_SUCCESS, @@ -73,6 +79,7 @@ export struct ToolStatusList { (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; @Local expanded: boolean = false; @Local expandedToolKey: string = ''; @@ -84,13 +91,94 @@ export struct ToolStatusList { } else if (entry.thinking) { this.ThinkingRow(entry.thinking) } else if (entry.tool) { - this.ToolRow(entry.tool, index) + if (PlanToolPolicy.isPlanTool(entry.tool)) { + this.PlanCard(entry.tool) + } else { + this.ToolRow(entry.tool, index) + } } }, (entry: ToolRenderEntry) => entry.key) } .width('100%') } + @Builder + PlanCard(tool: ConversationUiToolStatus) { + Column({ space: 12 }) { + Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + ToolGlyph({ kind: 'document', color: INK }) + } + .width(32) + .height(32) + .backgroundColor(SOFT) + .borderRadius(9) + Column({ space: 2 }) { + Text(this.planDescriptor(tool).name) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.planSubtitle(tool)) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + Row({ space: 8 }) { + Text(RemoteI18n.t('chat.planView')) + .fontSize(MobileDesignTypography.labelMedium.size) + .fontColor(this.planDescriptor(tool).file_path.length > 0 ? INK : MUTED) + .textAlign(TextAlign.Center) + .height(36) + .layoutWeight(1) + .padding({ left: 12, right: 12 }) + .backgroundColor(SOFT) + .borderRadius(18) + .border({ width: 1, color: LINE }) + .enabled(this.planDescriptor(tool).file_path.length > 0) + .onClick(() => { + const plan = this.planDescriptor(tool); + if (plan.file_path.length > 0) { + this.onOpenFilePreview(plan.file_path, plan.name); + } + }) + Text(RemoteI18n.t('chat.planBuild')) + .fontSize(MobileDesignTypography.labelMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(this.canStartPlanBuild(tool) ? CARD : MUTED) + .textAlign(TextAlign.Center) + .height(36) + .layoutWeight(1) + .padding({ left: 12, right: 12 }) + .backgroundColor(this.canStartPlanBuild(tool) ? INK : SOFT) + .borderRadius(18) + .border({ width: 1, color: this.canStartPlanBuild(tool) ? INK : LINE }) + .enabled(this.canStartPlanBuild(tool)) + .onClick(() => { + const plan = this.planDescriptor(tool); + if (this.canStartPlanBuild(tool)) { + this.onBuildPlan(plan.file_path, plan.name); + } + }) + } + .width('100%') + } + .width('100%') + .padding(14) + .backgroundColor(CARD) + .borderRadius(18) + .border({ width: 1, color: LINE }) + .shadow({ radius: 12, color: SHADOW_SUBTLE, offsetY: 4 }) + } + @Builder CollapsedToolSummary(entry: ToolRenderEntry) { Row({ space: 8 }) { @@ -832,6 +920,25 @@ export struct ToolStatusList { return ToolStatusPresentationPolicy.questionInputJson(tool); } + private planDescriptor(tool: ConversationUiToolStatus): ConversationUiPlanTool { + return PlanToolPolicy.descriptor(tool) || { file_path: '', name: 'Plan' }; + } + + private planSubtitle(tool: ConversationUiToolStatus): string { + const plan = this.planDescriptor(tool); + if ((plan.overview || '').trim().length > 0) { + return plan.overview || ''; + } + if (plan.file_path.length > 0) { + return this.compactText(plan.file_path, 54); + } + return this.displayStatus(tool.status || 'pending'); + } + + private canStartPlanBuild(tool: ConversationUiToolStatus): boolean { + return !this.isBusy && PlanToolPolicy.canBuild(tool); + } + private toolKey(tool: ConversationUiToolStatus, index: number): string { return ToolStatusPresentationPolicy.key(tool, index); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index b501f3610b..0fe3683599 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -1,4 +1,5 @@ import { RemoteUiState } from '../../services/RemoteUiState'; +import { REMOTE_CAPABILITY_HARNESS_PROFILES_V1 } from '../../model/RemoteModels'; import { AppRootPresentationActions, emptyAppRootPresentationActions @@ -202,10 +203,12 @@ export struct WideConversationHost { selectedSessionId: this.wideSelectedSessionId(), runningSessionId: this.remotePageState.hasRunningActiveTurn() ? this.remotePageState.activeSession.sessionId : '', + supportsHarnessProfiles: this.remotePageState.supportsHostCapability( + REMOTE_CAPABILITY_HARNESS_PROFILES_V1), workspacePickerPlacement: this.sessionDetailsPlacement, onOpenSession: this.actions.onRemoteHome.openSessionInPlace, - onCreateInWorkspace: (deviceId: string, path: string) => { - this.actions.onRemoteHome.createInWorkspaceInPlace(path, 'code', deviceId); + onCreateInWorkspace: (deviceId: string, path: string, agentType: string) => { + this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType, deviceId); }, onOpenWorkspace: (deviceId: string, path: string) => { this.actions.onRemoteHome.selectWorkspace(path, deviceId); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index cb610ea4d2..042f30d732 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -1,6 +1,6 @@ import { MobileDesignTypography } from '../../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../../i18n/RemoteI18n'; -import { RemoteSession } from '../../../model/RemoteModels'; +import { REMOTE_CAPABILITY_HARNESS_PROFILES_V1, RemoteSession } from '../../../model/RemoteModels'; import { RemoteLogger } from '../../../services/RemoteLogger'; import { RemoteUiState } from '../../../services/RemoteUiState'; import { RemotePageState } from '../../state/RemotePageState'; @@ -15,6 +15,7 @@ import { RemoteSessionList } from '../RemoteSessionList'; import { RemoteSessionLoadingView } from '../RemoteSessionLoadingView'; import { SidebarToggleButton } from '../SidebarToggleButton'; import { SessionActionPresentation } from '../SessionActionSurface'; +import { HarnessProfileMenu } from '../HarnessProfileMenu'; import { SettingsPlacement, SettingsPlacementPolicy, @@ -63,6 +64,7 @@ export struct RemoteSurfaceHost { @Event onOpenSidebar: () => void = () => {}; @Event onRestoreSidebar: () => void = () => {}; @Event onCloseSettings: () => void = () => {}; + @Local showCreateModeMenu: boolean = false; // The wide layout keeps the master pane inside a routed destination, so a // mount here while switching sessions means the navigation stack was torn @@ -109,6 +111,7 @@ export struct RemoteSurfaceHost { showWorkspaceMetadata: this.presentationState.showWorkspaceMetadata, showUpdatedMetadata: this.presentationState.showUpdatedMetadata, showStatusMetadata: this.presentationState.showStatusMetadata, + supportsHarnessProfiles: this.supportsHarnessProfiles(), hasMoreSessions: this.remotePageState.hasMoreSessions, isBusy: this.remotePageState.conversation.isBusy || this.remotePageState.isLoadingSessions, // A pending id means a row was tapped, whether or not the open is slow @@ -252,7 +255,23 @@ export struct RemoteSurfaceHost { .width(148).height(46).fontSize(MobileDesignTypography.titleSmall.size).fontWeight(FontWeight.Medium) .fontColor(CONTENT_ON_ACTION).backgroundColor(PRIMARY_ACTION) .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) - .onClick(() => this.createSession('code')) + .bindPopup(this.showCreateModeMenu, { + builder: () => { + this.CreateModeMenu() + }, + placement: Placement.Top, + popupColor: CARD, + enableArrow: false, + autoCancel: true, + mask: true, + targetSpace: 8, + onStateChange: (event) => { + if (!event.isVisible) { + this.showCreateModeMenu = false; + } + } + }) + .onClick(() => this.requestCreateSession()) } } .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) @@ -349,6 +368,28 @@ export struct RemoteSurfaceHost { this.createSession('code'); } + @Builder + private CreateModeMenu() { + HarnessProfileMenu({ + onSelect: (agentType: string) => { + this.showCreateModeMenu = false; + this.createSession(agentType); + } + }) + } + + private requestCreateSession(): void { + if (this.supportsHarnessProfiles()) { + this.showCreateModeMenu = true; + return; + } + this.createSession('code'); + } + + private supportsHarnessProfiles(): boolean { + return this.remotePageState.supportsHostCapability(REMOTE_CAPABILITY_HARNESS_PROFILES_V1); + } + /** * Whether the surface still owes the user a way back onto a desktop. * diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets index ba95f4e0d6..008d9f32b8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets @@ -300,7 +300,8 @@ export class ChatMessageStructurePolicy { static isRenderableStructuredEntry(entry: ConversationUiMessageItem): boolean { if (ChatMessageStructurePolicy.isSubagentInternalTool(entry)) return false; - if (ChatMessageStructurePolicy.isThinkingEntry(entry) || ChatMessageStructurePolicy.isTextEntry(entry) || + if (ChatMessageStructurePolicy.isUserSteeringEntry(entry) || + ChatMessageStructurePolicy.isThinkingEntry(entry) || ChatMessageStructurePolicy.isTextEntry(entry) || ChatMessageStructurePolicy.isSubagentEntry(entry) || !!entry.tool) return true; return !!entry.subItems && entry.subItems.some((child: ConversationUiMessageItem) => ChatMessageStructurePolicy.isRenderableStructuredEntry(child)); @@ -310,6 +311,11 @@ export class ChatMessageStructurePolicy { return (entry.type || '').toLowerCase() === 'thinking' && (entry.content || '').trim().length > 0; } + static isUserSteeringEntry(entry: ConversationUiMessageItem): boolean { + return (entry.type || '').toLowerCase() === 'user-steering' && + ((entry.content || '').trim().length > 0 || (entry.images || []).length > 0); + } + static isTextEntry(entry: ConversationUiMessageItem): boolean { const type = (entry.type || '').toLowerCase(); if ((entry.content || '').trim().length === 0 || entry.tool || @@ -345,10 +351,13 @@ export class ChatMessageStructurePolicy { private static copyItem(entry: ConversationUiMessageItem): ConversationUiMessageItem { return { type: entry.type, + steering_id: entry.steering_id, + round_index: entry.round_index, content: entry.content, tool: entry.tool, is_subagent: entry.is_subagent, - subItems: entry.subItems ? entry.subItems.slice() : [] + subItems: entry.subItems ? entry.subItems.slice() : [], + images: entry.images ? entry.images.slice() : undefined }; } @@ -530,6 +539,9 @@ export class ChatMessageStructurePolicy { static structuredItemKey(entry: ConversationUiMessageItem, path: string): string { if (ChatMessageStructurePolicy.isThinkingEntry(entry)) return `${path}-thinking`; + if (ChatMessageStructurePolicy.isUserSteeringEntry(entry) && entry.steering_id) { + return `${path}-steering-${entry.steering_id}`; + } if (entry.tool && entry.tool.id) return `${path}-tool-${entry.tool.id}`; const content = entry.content || ''; const childCount = entry.subItems ? entry.subItems.length : 0; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/HarnessProfilePolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/HarnessProfilePolicy.ets new file mode 100644 index 0000000000..0c1ef847b3 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/HarnessProfilePolicy.ets @@ -0,0 +1,10 @@ +/** Maps the three product execution levels onto the host's canonical Agents. */ +export class HarnessProfilePolicy { + static agentType(profileId: string): string { + switch (profileId.trim().toLowerCase()) { + case 'minimal': return 'minimal'; + case 'ultimate': return 'Ultra'; + default: return 'agentic'; + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/PlanToolPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/PlanToolPolicy.ets new file mode 100644 index 0000000000..5a7bb58d51 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/PlanToolPolicy.ets @@ -0,0 +1,83 @@ +import { ConversationUiPlanTool, ConversationUiToolStatus } from '../state/ConversationUiModels'; + +interface PlanToolInput { + plan_file_path?: string; + file_path?: string; + filePath?: string; + path?: string; + name?: string; + title?: string; + overview?: string; +} + +/** Pure recognition/projection for legacy CreatePlan and modern .plan.md writes. */ +export class PlanToolPolicy { + static descriptor(tool: ConversationUiToolStatus): ConversationUiPlanTool | undefined { + if (tool.plan) { + return { + file_path: tool.plan.file_path || '', + name: PlanToolPolicy.planName(tool.plan.name, tool.plan.file_path), + overview: tool.plan.overview + }; + } + const input = PlanToolPolicy.input(tool); + const path = input.plan_file_path || input.file_path || input.filePath || input.path || ''; + const normalizedName = PlanToolPolicy.normalizedName(tool.name || ''); + const isCreatePlan = normalizedName === 'createplan'; + const isPlanWrite = (normalizedName === 'write' || normalizedName === 'writefile' || + normalizedName === 'createfile') && path.toLowerCase().endsWith('.plan.md'); + if (!isCreatePlan && !isPlanWrite) { + return undefined; + } + return { + file_path: path, + name: PlanToolPolicy.planName(input.name || input.title || '', path), + overview: input.overview + }; + } + + static isPlanTool(tool: ConversationUiToolStatus): boolean { + return PlanToolPolicy.descriptor(tool) !== undefined; + } + + static canBuild(tool: ConversationUiToolStatus): boolean { + const plan = PlanToolPolicy.descriptor(tool); + const status = (tool.status || '').toLowerCase(); + return !!plan && plan.file_path.length > 0 && + (status === 'completed' || status === 'done' || status === 'success'); + } + + private static normalizedName(name: string): string { + return name.replace(/[\s_-]/g, '').toLowerCase(); + } + + private static input(tool: ConversationUiToolStatus): PlanToolInput { + if (tool.tool_input) { + try { + return JSON.parse(JSON.stringify(tool.tool_input)) as PlanToolInput; + } catch (_err) { + } + } + const preview = tool.input_preview || ''; + if (preview.trim().length > 0) { + try { + return JSON.parse(preview) as PlanToolInput; + } catch (_err) { + } + } + return {}; + } + + private static planName(explicitName: string, path: string): string { + if (explicitName.trim().length > 0) { + return explicitName.trim(); + } + const normalizedPath = path.replace(/\\/g, '/'); + const parts = normalizedPath.split('/'); + const fileName = parts[parts.length - 1] || ''; + if (fileName.toLowerCase().endsWith('.plan.md')) { + return fileName.slice(0, fileName.length - '.plan.md'.length); + } + return fileName || 'Plan'; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolCollapsePolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolCollapsePolicy.ets index 5bf47eb5ed..c89ab5cdf1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolCollapsePolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolCollapsePolicy.ets @@ -1,4 +1,5 @@ import { ConversationUiToolStatus } from '../state/ConversationUiModels'; +import { PlanToolPolicy } from './PlanToolPolicy'; export class ToolCollapseGroup { readonly type: string; @@ -16,7 +17,7 @@ export class ToolCollapsePolicy { static readonly MIN_SUMMARY_COUNT: number = 2; static shouldFoldIntoSummary(tool: ConversationUiToolStatus): boolean { - if (ToolCollapsePolicy.hasError(tool) || + if (PlanToolPolicy.isPlanTool(tool) || ToolCollapsePolicy.hasError(tool) || ToolCollapsePolicy.isPending(tool) || ToolCollapsePolicy.isQuestion(tool) || ToolCollapsePolicy.isRunning(tool)) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index af3afdeda4..64fc446c7e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -524,6 +524,16 @@ export abstract class AppRootRuntimeComposition { }); this.remoteChatPollingLifecycleController.nudge(); }, + onSteerSucceeded: ( + steeringId: string, + turnId: string, + displayText: string, + images: SelectedImageAttachment[] + ) => { + this.chatTimelineStore.appendSteeringItem(turnId, steeringId, displayText, images); + this.conversationController.syncRemoteTimeline(); + this.remoteChatPollingLifecycleController.nudge(); + }, onSendFailed: ( rawText: string, images: SelectedImageAttachment[], @@ -896,6 +906,9 @@ export abstract class AppRootRuntimeComposition { openFilePreview: (route: AppRoute, request: FilePreviewRequest): void => this.filePreviewController.open(route, request), downloadFile: (path: string): void => this.conversationController.downloadVisibleFile(path), + buildPlan: async (path: string, name: string): Promise => { + await this.conversationController.buildRemotePlan(path, name); + }, send: async (): Promise => { await this.conversationController.sendVisibleMessage(); }, voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, inputChanged: (route: AppRoute, value: string): void => diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets index 870a563475..f15d2072e4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets @@ -13,6 +13,12 @@ import { RemoteToolStatusResponse } from '../../model/RemoteModels'; +export interface ConversationUiPlanTool { + file_path: string; + name: string; + overview?: string; +} + export interface ConversationUiSession { sessionId: string; title: string; @@ -60,14 +66,18 @@ export interface ConversationUiToolStatus { result_preview?: string; error_preview?: string; exit_code?: number; + plan?: ConversationUiPlanTool; } export interface ConversationUiMessageItem { type?: string; + steering_id?: string; + round_index?: number; content?: string; tool?: ConversationUiToolStatus; is_subagent?: boolean; subItems?: ConversationUiMessageItem[]; + images?: ConversationUiImage[]; } export interface ConversationUiImage { @@ -207,17 +217,25 @@ export function toConversationUiToolStatus(source: RemoteToolStatusResponse): Co tool_output: source.tool_output, result_preview: source.result_preview, error_preview: source.error_preview, - exit_code: source.exit_code + exit_code: source.exit_code, + plan: source.plan ? { + file_path: source.plan.file_path, + name: source.plan.name, + overview: source.plan.overview + } : undefined }; } export function toConversationUiMessageItem(source: ChatMessageItemResponse): ConversationUiMessageItem { return { type: source.type, + steering_id: source.steering_id, + round_index: source.round_index, content: source.content, tool: source.tool ? toConversationUiToolStatus(source.tool) : undefined, is_subagent: source.is_subagent, - subItems: source.subItems ? source.subItems.map((item: ChatMessageItemResponse) => toConversationUiMessageItem(item)) : undefined + subItems: source.subItems ? source.subItems.map((item: ChatMessageItemResponse) => toConversationUiMessageItem(item)) : undefined, + images: source.images ? source.images.map((image: ImageAttachment) => toConversationUiImage(image)) : undefined }; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets index 0d6d9d291d..896ca38433 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -134,6 +134,9 @@ export class ConversationController { async selectRemoteModel(modelId: string): Promise { await this.transcript.selectRemoteModel(modelId); } async loadOlderRemoteMessages(): Promise { await this.transcript.loadOlderRemoteMessages(); } async sendRemoteMessage(): Promise { await this.transcript.sendRemoteMessage(); } + async buildRemotePlan(path: string, name: string): Promise { + await this.transcript.buildRemotePlan(path, name); + } async stopRemoteTask(): Promise { await this.transcript.stopRemoteTask(); } async renameRemoteSession(title: string): Promise { await this.transcript.renameRemoteSession(title); } async copyRemoteMessage(text: string): Promise { await this.transcript.copyRemoteMessage(text); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets index f72c5f4221..fbf0ed6639 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets @@ -1,4 +1,6 @@ import { + REMOTE_CAPABILITY_DIALOG_STEER_V1, + REMOTE_CAPABILITY_PLAN_BUILD_V1, RemoteImageContext, RemoteQuestionAnswerPayload } from '../../model/RemoteModels'; @@ -98,11 +100,33 @@ export class RemoteTranscriptController { !runtime.connection.ensureAvailable()) { return; } - // Sending into a turn that is still running is allowed, but the desktop - // queues it rather than interrupting, so say so once — otherwise the - // message just sits in the transcript with nothing appearing to happen. const queuedBehindRunningTurn = this.hasRunningRemoteTurn(); + const activeTurnId = this.remoteActiveTurnId(); + const canSteerRunningTurn = queuedBehindRunningTurn && activeTurnId.length > 0 && + this.remote.supportsHostCapability(REMOTE_CAPABILITY_DIALOG_STEER_V1); this.remote.clearComposer(); + const imageContexts: RemoteImageContext[] = images.length > 0 ? + runtime.imagePicker.toRemoteContexts(images) : []; + if (canSteerRunningTurn) { + RemoteLogger.info( + `chat steer requested session=${shortSessionId(sessionId)} turn=${shortSessionId(activeTurnId)}` + ); + this.startRemotePolling(); + runtime.polling.nudge(); + await runtime.chat.steerPreparedMessage( + sessionId, + activeTurnId, + text, + rawText, + images, + imageContexts, + this.remote.isBusy, + true + ); + return; + } + // Older desktop hosts do not advertise steering. Keep their established + // queue behavior so mixed-version Remote Connect sessions remain usable. const localMessage = RemoteUiState.localUserMessage(text, images); runtime.timeline.appendOptimisticMessage(localMessage); const pendingActiveId = runtime.timeline.setPendingActiveTurn(localMessage.id); @@ -113,8 +137,6 @@ export class RemoteTranscriptController { } this.startRemotePolling(); runtime.polling.nudge(); - const imageContexts: RemoteImageContext[] = images.length > 0 ? - runtime.imagePicker.toRemoteContexts(images) : []; await runtime.chat.sendPreparedMessage( sessionId, text, @@ -129,6 +151,40 @@ export class RemoteTranscriptController { ); } + async buildRemotePlan(planFilePath: string, planName: string): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + const sessionId = this.remote.activeSession.sessionId || ''; + if (!this.remote.supportsHostCapability(REMOTE_CAPABILITY_PLAN_BUILD_V1)) { + this.notify(RemoteI18n.t('chat.planBuildUnavailable')); + return; + } + if (this.hasRunningRemoteTurn()) { + this.notify(RemoteI18n.t('chat.planBuildWaitForTurn')); + return; + } + if (planFilePath.trim().length === 0 || sessionId.length === 0 || this.remote.isBusy || + !runtime.connection.ensureAvailable()) { + return; + } + const displayName = planName.trim().length > 0 ? planName.trim() : 'Plan'; + const localMessage = RemoteUiState.localUserMessage(`Build Plan: ${displayName}`); + runtime.timeline.appendOptimisticMessage(localMessage); + const pendingActiveId = runtime.timeline.setPendingActiveTurn(localMessage.id); + this.syncRemoteTimeline(); + this.startRemotePolling(); + runtime.polling.nudge(); + await runtime.chat.buildPlan( + sessionId, + planFilePath.trim(), + displayName, + this.remote.activeSession.agentType, + localMessage.id, + pendingActiveId, + this.remote.isBusy, + true + ); + } + async stopRemoteTask(): Promise { const runtime = requireRemoteRuntime(this.remoteRuntime); const sessionId = this.remote.activeSession.sessionId || ''; @@ -255,8 +311,10 @@ export class RemoteTranscriptController { } runtime.timeline.applySnapshot(snapshot); this.syncRemoteTimeline(); - if (snapshot.newMessages.length > 0 || snapshot.messageSnapshot !== undefined) { - this.cacheRemoteTranscript(snapshot.sessionId); + if (snapshot.messageSnapshot !== undefined) { + this.cacheRemoteTranscript(snapshot.sessionId, true); + } else if (snapshot.newMessages.length > 0) { + this.cacheRemoteTranscript(snapshot.sessionId, false); } this.knownPollVersionValue = snapshot.cursor.pollVersion; this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; @@ -287,10 +345,14 @@ export class RemoteTranscriptController { * that cannot be written only costs the next open a fetch it used to pay for * anyway. */ - private cacheRemoteTranscript(sessionId: string): void { + private cacheRemoteTranscript(sessionId: string, authoritativeSnapshot: boolean): void { const runtime = requireRemoteRuntime(this.remoteRuntime); const state: ChatTimelineState = runtime.timeline.snapshotState(); - runtime.chatCache.sync(sessionId, state.persistedMessages); + if (authoritativeSnapshot) { + runtime.chatCache.replaceSnapshot(sessionId, state.persistedMessages); + } else { + runtime.chatCache.syncIncremental(sessionId, state.persistedMessages); + } } /** diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets index f2cec22491..ef68dca26d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets @@ -211,6 +211,7 @@ export class ChatTimelineProjector { !item.tool && item.is_subagent !== true && type !== 'thinking' && + type !== 'user-steering' && type !== 'tool' && type !== 'subagent' && type !== 'agent') { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets index 367736bf31..2782f54da1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineStore.ets @@ -126,7 +126,10 @@ export class ChatTimelineStore { } setPersistedMessages(messages: ChatMessage[]): void { - const persistedMessages = ChatTimelineStore.realMessages(messages); + const persistedMessages = ChatTimelineStore.carrySteeringItems( + this.state.activeTurn, + ChatTimelineStore.realMessages(messages) + ); const previousIds = new Set(); this.state.persistedMessages.forEach((message: ChatMessage) => previousIds.add(message.id)); const newlyPersistedMessages = persistedMessages.filter((message: ChatMessage) => !previousIds.has(message.id)); @@ -154,7 +157,10 @@ export class ChatTimelineStore { // Only this delivery can acknowledge an optimistic message. Replayed // history with the same text must not consume a message the user just sent. const newlyPersistedMessages = incomingMessages.filter((message: ChatMessage) => !previousIds.has(message.id)); - const persistedMessages = RemoteUiState.mergeMessages(this.state.persistedMessages, incomingMessages); + const persistedMessages = ChatTimelineStore.carrySteeringItems( + this.state.activeTurn, + RemoteUiState.mergeMessages(this.state.persistedMessages, incomingMessages) + ); const activeTurnCovered = this.coveredByPersistedMessage(this.state.activeTurn, persistedMessages) || this.coveredByNewAssistantContent(this.state.activeTurn, newlyPersistedMessages); this.state = { @@ -185,6 +191,49 @@ export class ChatTimelineStore { }; } + appendSteeringItem( + turnId: string, + steeringId: string, + content: string, + images: ImageAttachment[] + ): boolean { + const active = this.state.activeTurn; + if (!active || ChatTimelineStore.cancelableTurnId(active) !== turnId || steeringId.length === 0) { + return false; + } + const items = active.items ? active.items.slice() : []; + const existingIndex = items.findIndex((item: ChatMessageItemResponse) => { + return (item.type || '').toLowerCase() === 'user-steering' && item.steering_id === steeringId; + }); + const steeringItem: ChatMessageItemResponse = { + type: 'user-steering', + steering_id: steeringId, + content, + images: images.slice() + }; + if (existingIndex >= 0) { + items[existingIndex] = steeringItem; + } else { + items.push(steeringItem); + } + this.setActiveTurn({ + id: active.id, + turnId: active.turnId, + role: active.role, + text: active.text, + status: active.status, + detail: active.detail, + error: active.error, + timestamp: active.timestamp, + thinking: active.thinking, + tools: active.tools, + items, + images: active.images, + renderVersion: (active.renderVersion || 0) + 1 + }); + return true; + } + markOptimisticMessageFailed(messageId: string): void { this.state = { sessionId: this.state.sessionId, @@ -613,6 +662,40 @@ export class ChatTimelineStore { }); } + private static carrySteeringItems( + activeTurn: ChatMessage | undefined, + messages: ChatMessage[] + ): ChatMessage[] { + if (!activeTurn || !(activeTurn.items || []).some((item: ChatMessageItemResponse) => + (item.type || '').toLowerCase() === 'user-steering')) { + return messages; + } + const activeTurnId = ChatTimelineStore.cancelableTurnId(activeTurn); + if (activeTurnId.length === 0) { + return messages; + } + return messages.map((message: ChatMessage) => { + if (message.role !== 'assistant' || (message.turnId || '') !== activeTurnId) { + return message; + } + return { + id: message.id, + turnId: message.turnId, + role: message.role, + text: message.text, + status: message.status, + renderVersion: (message.renderVersion || 0) + 1, + detail: message.detail, + error: message.error, + timestamp: message.timestamp, + thinking: message.thinking, + tools: message.tools, + items: ChatTimelineStore.mergeActiveItems(activeTurn.items || [], message.items || []), + images: message.images + }; + }); + } + private static phaseForActiveTurn(activeTurn: ChatMessage | undefined, fallback: ChatSyncPhase): ChatSyncPhase { if (!activeTurn || activeTurn.id.length === 0) { return fallback === 'sending' ? 'sending' : 'idle'; @@ -687,7 +770,12 @@ export class ChatTimelineStore { incomingItems.forEach((incoming: ChatMessageItemResponse, incomingIndex: number) => { let matchIndex = -1; const incomingToolId = incoming.tool ? (incoming.tool.id || '') : ''; - if (incomingToolId.length > 0) { + const incomingSteeringId = incoming.steering_id || ''; + if (incomingSteeringId.length > 0) { + matchIndex = previousItems.findIndex((previous: ChatMessageItemResponse, index: number) => { + return !matched.has(index) && previous.steering_id === incomingSteeringId; + }); + } else if (incomingToolId.length > 0) { matchIndex = previousItems.findIndex((previous: ChatMessageItemResponse, index: number) => { return !matched.has(index) && previous.tool !== undefined && (previous.tool.id || '') === incomingToolId; @@ -717,12 +805,15 @@ export class ChatTimelineStore { const subItems = ChatTimelineStore.mergeActiveItems(previous.subItems || [], incoming.subItems || []); const item: ChatMessageItemResponse = { type: incoming.type !== undefined ? incoming.type : previous.type, + steering_id: incoming.steering_id !== undefined ? incoming.steering_id : previous.steering_id, + round_index: incoming.round_index !== undefined ? incoming.round_index : previous.round_index, content, tool: incoming.tool !== undefined ? ChatTimelineStore.mergeTool(previous.tool, incoming.tool) : previous.tool, is_subagent: incoming.is_subagent !== undefined ? incoming.is_subagent : previous.is_subagent, - subItems + subItems, + images: incoming.images !== undefined ? incoming.images : previous.images }; return item; } @@ -733,6 +824,11 @@ export class ChatTimelineStore { if (previousType !== incomingType) { return false; } + const previousSteeringId = previous.steering_id || ''; + const incomingSteeringId = incoming.steering_id || ''; + if (previousSteeringId.length > 0 || incomingSteeringId.length > 0) { + return previousSteeringId === incomingSteeringId; + } const previousToolId = previous.tool ? (previous.tool.id || '') : ''; const incomingToolId = incoming.tool ? (incoming.tool.id || '') : ''; if (previousToolId.length > 0 || incomingToolId.length > 0) { @@ -813,7 +909,8 @@ export class ChatTimelineStore { tool_output: incoming.tool_output !== undefined ? incoming.tool_output : previous.tool_output, result_preview: (incoming.result_preview || '').length > 0 ? incoming.result_preview : previous.result_preview, error_preview: (incoming.error_preview || '').length > 0 ? incoming.error_preview : previous.error_preview, - exit_code: incoming.exit_code !== undefined ? incoming.exit_code : previous.exit_code + exit_code: incoming.exit_code !== undefined ? incoming.exit_code : previous.exit_code, + plan: incoming.plan !== undefined ? incoming.plan : previous.plan }; } @@ -859,6 +956,7 @@ export class ChatTimelineStore { !item.tool && item.is_subagent !== true && type !== 'thinking' && + type !== 'user-steering' && type !== 'tool' && type !== 'subagent' && type !== 'agent') { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCache.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCache.ets index dac8913ddc..e76b30ae7b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCache.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCache.ets @@ -132,14 +132,49 @@ export class RemoteChatCache { } /** - * Brings the stored transcript in line with what the timeline now holds. + * Persists an authoritative transcript returned by `get_session_messages` or + * `message_snapshot`. * - * Appends when the stored rows are still a prefix of [persistedMessages] and - * rewrites the session otherwise — a poll tick adds a handful of messages to - * a transcript that can hold hundreds, and rewriting all of them every few - * hundred milliseconds is the one way this cache could cost more than it saves. + * Message ids and counts are ordering facts, not content revisions: a turn + * keeps the same assistant id while its final text, status and tool results + * become durable. Therefore a full snapshot always replaces the cached rows, + * even when its ids and length match what is already stored. */ - async sync(sessionId: string, persistedMessages: ChatMessage[]): Promise { + async replaceSnapshot(sessionId: string, persistedMessages: ChatMessage[]): Promise { + const scope = this.scope(sessionId); + if (scope.length === 0) { + return; + } + const lastMessageId = persistedMessages.length > 0 + ? persistedMessages[persistedMessages.length - 1].id + : ''; + try { + await this.store.replaceMessages( + this.deviceKeyProvider(), + sessionId, + persistedMessages, + lastMessageId + ); + await this.store.pruneSessions(this.deviceKeyProvider(), CACHED_SESSIONS_PER_DEVICE); + this.track(scope, persistedMessages.length, lastMessageId); + if (persistedMessages.length > 0) { + this.remember(scope, persistedMessages, lastMessageId); + } else { + this.evictResident(scope); + } + } catch (err) { + this.handleWriteFailure(scope, err); + } + } + + /** + * Persists the timeline after an additive poll delta was merged into it. + * + * This path may append because the caller did not receive an authoritative + * replacement. A prefix mismatch still rewrites the session to avoid splicing + * two different histories together. + */ + async syncIncremental(sessionId: string, persistedMessages: ChatMessage[]): Promise { const scope = this.scope(sessionId); if (scope.length === 0 || persistedMessages.length === 0) { return; @@ -173,13 +208,7 @@ export class RemoteChatCache { this.track(scope, persistedMessages.length, lastMessageId); this.remember(scope, persistedMessages, lastMessageId); } catch (err) { - // Dropping the cursor turns the next sync into a rewrite, which is the - // only safe assumption once a write outcome is unknown. The resident copy - // goes with it: what it holds was true of a store this write may have - // moved out from under it. - this.forgetCursor(); - this.evictResident(scope); - RemoteLogger.error(`remote chat cache write failed: ${RemoteChatCache.reason(err)}`); + this.handleWriteFailure(scope, err); } } @@ -261,6 +290,15 @@ export class RemoteChatCache { this.residentOrder = this.residentOrder.filter((entry: string): boolean => entry !== scope); } + private handleWriteFailure(scope: string, err: Object): void { + // Dropping the cursor turns the next incremental sync into a rewrite, which + // is the only safe assumption once a write outcome is unknown. The resident + // copy goes with it because the store may have changed underneath it. + this.forgetCursor(); + this.evictResident(scope); + RemoteLogger.error(`remote chat cache write failed: ${RemoteChatCache.reason(err)}`); + } + /** Cache key for a session, or '' when there is nothing safe to key on. */ private scope(sessionId: string): string { const deviceKey = this.deviceKeyProvider().trim(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets index e616f47324..da538c7a04 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets @@ -2,6 +2,7 @@ import { ChatMessage, RemoteImageContext, SelectedImageAttachment, + SteerTurnResult, SessionMessagesResult, SessionSummary } from '../model/RemoteModels'; @@ -20,6 +21,14 @@ export interface RemoteChatCommandClient { agentType: string, imageContexts: RemoteImageContext[] ): Promise; + steerTurn( + sessionId: string, + turnId: string, + text: string, + displayContent: string, + imageContexts: RemoteImageContext[] + ): Promise; + buildPlan(sessionId: string, planFilePath: string, planName: string, agentType: string): Promise; cancelTask(sessionId: string, turnId?: string): Promise; renameSession(sessionId: string, title: string): Promise; } @@ -32,6 +41,12 @@ export interface RemoteChatCommandCallbacks { // outlive the messages it is standing in for. onTimelineReady: () => void; onSendSucceeded: (turnId: string, pendingActiveId: string) => void; + onSteerSucceeded?: ( + steeringId: string, + turnId: string, + displayText: string, + images: SelectedImageAttachment[] + ) => void; onSendFailed: ( rawText: string, images: SelectedImageAttachment[], @@ -217,7 +232,7 @@ export class RemoteChatCommandController { // Writing another session's transcript moves the cache's append cursor // off the visible one, which costs that session a single full rewrite on // its next sync before appends resume. - await this.cache.sync(sessionId, result.messages); + await this.cache.replaceSnapshot(sessionId, result.messages); } catch (err) { // A failure belongs to the session it was fetched for. Unguarded, a slow // fetch the user switched away from would land its error text and its @@ -287,7 +302,7 @@ export class RemoteChatCommandController { this.callbacks.onMessagesLoaded(result.messages, false); this.callbacks.onMessageCountKnown(currentPollVersion, result.messages.length); this.callbacks.onStatusText(RemoteI18n.t('status.messagesSynced')); - await this.cache.sync(sessionId, result.messages); + await this.cache.replaceSnapshot(sessionId, result.messages); } catch (err) { this.callbacks.onStatusText(ConnectionErrorPolicy.errorText(err)); } finally { @@ -326,6 +341,73 @@ export class RemoteChatCommandController { } } + async steerPreparedMessage( + sessionId: string, + turnId: string, + text: string, + displayText: string, + images: SelectedImageAttachment[], + imageContexts: RemoteImageContext[], + isBusy: boolean, + remoteAvailable: boolean + ): Promise { + if ((text.length === 0 && images.length === 0) || sessionId.length === 0 || turnId.length === 0 || + isBusy || !remoteAvailable) { + return; + } + try { + this.callbacks.onBusy(true); + const result = await this.client.steerTurn( + sessionId, + turnId, + text, + displayText, + imageContexts + ); + if (this.callbacks.onSteerSucceeded) { + this.callbacks.onSteerSucceeded(result.steeringId, result.turnId, displayText, images); + } + this.callbacks.onStatusText(RemoteI18n.t('status.sentWaiting')); + this.callbacks.onPollRequested(); + } catch (err) { + this.callbacks.onSendFailed(displayText, images, '', ''); + const errorText = ConnectionErrorPolicy.errorText(err); + this.callbacks.onStatusText(errorText); + this.callbacks.onToast(errorText); + } finally { + this.callbacks.onBusy(false); + } + } + + async buildPlan( + sessionId: string, + planFilePath: string, + planName: string, + agentType: string, + localMessageId: string, + pendingActiveId: string, + isBusy: boolean, + remoteAvailable: boolean + ): Promise { + if (sessionId.length === 0 || planFilePath.length === 0 || isBusy || !remoteAvailable) { + return; + } + try { + this.callbacks.onBusy(true); + const turnId = await this.client.buildPlan(sessionId, planFilePath, planName, agentType); + this.callbacks.onSendSucceeded(turnId, pendingActiveId); + this.callbacks.onStatusText(RemoteI18n.t('status.sentWaiting')); + this.callbacks.onPollRequested(); + } catch (err) { + this.callbacks.onSendFailed('', [], localMessageId, pendingActiveId); + const errorText = ConnectionErrorPolicy.errorText(err); + this.callbacks.onStatusText(errorText); + this.callbacks.onToast(errorText); + } finally { + this.callbacks.onBusy(false); + } + } + async stopTask( sessionId: string, activeTurnMessageId: string, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets index ff15eb58b4..5a9a5d0279 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets @@ -17,7 +17,10 @@ const STORE_CONFIG: relationalStore.StoreConfig = { * A cache is reconstructible by definition, so a mismatch drops the tables and * refills them from the desktop instead of paying for a migration. */ -const SCHEMA_VERSION: number = 1; +// Version 1 could retain an assistant row whose id was stable while its final +// body became durable. The cache is reconstructible, so invalidate those rows +// once instead of carrying potentially hollow completed replies forward. +const SCHEMA_VERSION: number = 2; const SCHEMA_VERSION_KEY: string = 'schema_version'; const CREATE_META_SQL = diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets index 466bbdf424..9642f02763 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets @@ -67,6 +67,39 @@ export class RemoteCommandFactory { return command; } + static steerTurn( + sessionId: string, + turnId: string, + text: string, + displayContent: string, + imageContexts?: RemoteImageContext[] + ): RemoteCommand { + const command: RemoteCommand = { + cmd: 'steer_turn', + session_id: sessionId, + turn_id: turnId, + content: text, + display_content: displayContent + }; + if (imageContexts && imageContexts.length > 0) { + command.image_contexts = imageContexts; + } + return command; + } + + static buildPlan(sessionId: string, planFilePath: string, planName: string, agentType?: string): RemoteCommand { + const command: RemoteCommand = { + cmd: 'build_plan', + session_id: sessionId, + plan_file_path: planFilePath, + plan_name: planName + }; + if (agentType && agentType.length > 0) { + command.agent_type = agentType; + } + return command; + } + static getSessionMessages(sessionId: string): RemoteCommand { return { cmd: 'get_session_messages', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets index ada8581f01..560b21b741 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets @@ -216,7 +216,8 @@ export class RemoteResponseMapper { if ((item.content || '').trim().length === 0 || item.tool || item.is_subagent === true) { return false; } - if (type === 'thinking' || type === 'tool' || type === 'subagent' || type === 'agent') { + if (type === 'thinking' || type === 'user-steering' || type === 'tool' || + type === 'subagent' || type === 'agent') { return false; } return type === 'text' || type === 'message' || type.length === 0; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 0e318f074d..99babc09c5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -1,4 +1,4 @@ -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { PairIdentity, PeerDeviceProvisionOutcome, RelayHttpClient } from './RelayHttpClient'; import { CloudAccountClient, CloudAccountRequestError, CloudAccountSession } from './CloudAccountClient'; @@ -295,6 +295,51 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile return response.turn_id || ''; } + async steerTurn( + sessionId: string, + turnId: string, + text: string, + displayContent: string, + imageContexts: RemoteImageContext[] + ): Promise { + const startedAt = Date.now(); + RemoteLogger.info( + `steer_turn start session=${RemoteSessionManager.shortId(sessionId)} ` + + `turn=${RemoteSessionManager.shortId(turnId)} len=${text.length} images=${imageContexts.length}` + ); + const response = await this.send( + RemoteCommandFactory.steerTurn(sessionId, turnId, text, displayContent, imageContexts) + ); + const result: SteerTurnResult = { + sessionId: response.session_id || sessionId, + turnId: response.turn_id || turnId, + steeringId: response.steering_id || '' + }; + if (result.steeringId.length === 0) { + throw new Error('Remote host accepted steering without a steering id.'); + } + RemoteLogger.info( + `steer_turn done session=${RemoteSessionManager.shortId(result.sessionId)} ` + + `turn=${RemoteSessionManager.shortId(result.turnId)} ms=${Date.now() - startedAt}` + ); + return result; + } + + async buildPlan(sessionId: string, planFilePath: string, planName: string, agentType: string): Promise { + const startedAt = Date.now(); + RemoteLogger.info( + `build_plan start session=${RemoteSessionManager.shortId(sessionId)} path_len=${planFilePath.length}` + ); + const response = await this.send( + RemoteCommandFactory.buildPlan(sessionId, planFilePath, planName, agentType) + ); + RemoteLogger.info( + `build_plan done session=${RemoteSessionManager.shortId(sessionId)} ` + + `turn=${response.turn_id || ''} ms=${Date.now() - startedAt}` + ); + return response.turn_id || ''; + } + async getSessionMessages(sessionId: string): Promise { const startedAt = Date.now(); const response = await this.send(RemoteCommandFactory.getSessionMessages(sessionId)); diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/sidebar_chevron_down.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/sidebar_chevron_down.png new file mode 100644 index 0000000000..c4d33bb048 Binary files /dev/null and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/sidebar_chevron_down.png differ diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/sidebar_chevron_right.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/sidebar_chevron_right.png new file mode 100644 index 0000000000..42acfcb141 Binary files /dev/null and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/sidebar_chevron_right.png differ diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets index 75101ddc15..c279a4c580 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -25,6 +25,7 @@ import { ConversationMessageRenderPolicy } from '../main/ets/pages/policy/Conver import { ToolCollapsePolicy } from '../main/ets/pages/policy/ToolCollapsePolicy'; import { ChatMessageStructurePolicy } from '../main/ets/pages/policy/ChatMessageStructurePolicy'; import { ToolStatusPresentationPolicy } from '../main/ets/pages/policy/ToolStatusPresentationPolicy'; +import { PlanToolPolicy } from '../main/ets/pages/policy/PlanToolPolicy'; import { ConversationUiMessage, ConversationUiMessageItem, @@ -43,6 +44,7 @@ import { RemoteCompactHomePolicy } from '../main/ets/pages/policy/RemoteCompactH import { AccountDeviceSelectionPolicy } from '../main/ets/pages/policy/AccountDeviceSelectionPolicy'; import { SidebarConnectionActionPolicy } from '../main/ets/pages/policy/SidebarConnectionActionPolicy'; import { SidebarDeviceProjectionPolicy } from '../main/ets/pages/policy/SidebarDeviceProjectionPolicy'; +import { HarnessProfilePolicy } from '../main/ets/pages/policy/HarnessProfilePolicy'; import { CONNECT_INTENT_AUTO, CONNECT_INTENT_SCAN } from '../main/ets/pages/state/AppShellState'; import { ConnectScanDecisionPolicy, @@ -50,6 +52,14 @@ import { } from '../main/ets/services/ConnectScanDecisionPolicy'; export default function conversationPresentationUnitTest() { + describe('HarnessProfilePolicy', () => { + it('maps mobile execution levels to canonical host Agents', 0, () => { + expect(HarnessProfilePolicy.agentType('minimal')).assertEqual('minimal'); + expect(HarnessProfilePolicy.agentType('standard')).assertEqual('agentic'); + expect(HarnessProfilePolicy.agentType('ultimate')).assertEqual('Ultra'); + }); + }); + describe('Local conversation empty home', () => { it('keeps local chat on a blank timeline instead of suggestion chips', 0, () => { const projection = ConversationViewState.project( @@ -315,6 +325,19 @@ export default function conversationPresentationUnitTest() { ])).assertTrue(); }); + it('renders steering as a stable inline user entry instead of assistant text', 0, () => { + const steering: ConversationUiMessageItem = { + type: 'user-steering', + steering_id: 'steering-1', + content: 'Use the second approach' + }; + expect(ChatMessageStructurePolicy.isUserSteeringEntry(steering)).assertTrue(); + expect(ChatMessageStructurePolicy.isTextEntry(steering)).assertFalse(); + expect(ChatMessageStructurePolicy.requiresStructuredRendering([steering])).assertTrue(); + expect(ChatMessageStructurePolicy.structuredItemKey(steering, 'item-1')) + .assertEqual('item-1-steering-steering-1'); + }); + it('peels a trailing composite reply onto a stable streaming markdown leaf', 0, () => { const items: ConversationUiMessageItem[] = [ { type: 'thinking', content: 'Inspect' }, @@ -444,6 +467,24 @@ export default function conversationPresentationUnitTest() { expect(groups[0].type).assertEqual('tool'); }); + it('keeps plan cards out of collapsed tool summaries', 0, () => { + const planTool: ConversationUiToolStatus = { + id: 'plan-1', + name: 'Write', + status: 'completed', + input_preview: '{"file_path":"/repo/.bitfun/plans/mobile.plan.md"}' + }; + const groups = ToolCollapsePolicy.collapsedGroups([ + toolStatus('1', 'Read', 'completed'), + planTool, + toolStatus('2', 'Grep', 'completed') + ]); + expect(groups.length).assertEqual(3); + expect(groups[1].tools[0].name).assertEqual('Write'); + expect(PlanToolPolicy.descriptor(planTool)?.file_path || '').assertEqual('/repo/.bitfun/plans/mobile.plan.md'); + expect(PlanToolPolicy.canBuild(planTool)).assertTrue(); + }); + it('does not fold running, failed, or blocking tools into the summary', 0, () => { const groups = ToolCollapsePolicy.collapsedGroups([ toolStatus('1', 'Grep', 'completed'), diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets index 4611362c18..097c126c00 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -327,6 +327,37 @@ export default function conversationStateUnitTest() { }); describe('ChatTimelineStore', () => { + it('keeps accepted steering inline when the active turn becomes persisted', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-steering'); + const active = activeChatMessage('turn-steering', 'Before steering'); + active.items = [{ type: 'text', content: 'Before steering' }]; + store.setActiveTurn(active); + + const appended = store.appendSteeringItem( + 'turn-steering', + 'steering-1', + 'Use the second approach', + [{ name: 'reference.png', data_url: 'data:image/png;base64,AA==', mime_type: 'image/png' }] + ); + expect(appended).assertTrue(); + expect(store.snapshot().activeTurn!.items![1].type || '').assertEqual('user-steering'); + + const finalMessage = chatMessage('assistant-final', 'assistant', 'After steering'); + finalMessage.turnId = 'turn-steering'; + finalMessage.items = [ + { type: 'text', content: 'Before steering' }, + { type: 'user-steering', steering_id: 'steering-1', content: 'Use the second approach' }, + { type: 'text', content: 'After steering' } + ]; + store.mergePersistedMessages([finalMessage]); + + const state = store.snapshot(); + expect(state.activeTurn === undefined).assertTrue(); + expect(state.persistedMessages[0].items![1].steering_id || '').assertEqual('steering-1'); + expect((state.persistedMessages[0].items![1].images || []).length).assertEqual(1); + }); + it('keeps persisted messages with different ids even when their text matches', 0, () => { const merged = RemoteUiState.mergeMessages([ chatMessage('msg-historical-1', 'user', '你是谁') diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets index 93a17e7478..887b052df1 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets @@ -123,6 +123,7 @@ import { ReadFileResult, RemoteSession, SelectedImageAttachment, + SteerTurnResult, SessionListResult, SessionMessagesResult, SessionSummary, @@ -888,6 +889,8 @@ export class FakeRemoteChatCommandClient implements RemoteChatCommandClient { messageRequests: string[] = []; messageResolvers: Array<(result: SessionMessagesResult) => void> = []; sendRequests: string[] = []; + buildPlanRequests: string[] = []; + steerRequests: string[] = []; cancelRequests: string[] = []; renameRequests: string[] = []; turnId: string = 'turn-1'; @@ -943,6 +946,32 @@ export class FakeRemoteChatCommandClient implements RemoteChatCommandClient { return this.turnId; } + async steerTurn( + sessionId: string, + turnId: string, + text: string, + displayContent: string, + imageContexts: RemoteImageContext[] + ): Promise { + this.steerRequests.push(`${sessionId}:${turnId}:${text}:${displayContent}:${imageContexts.length}`); + if (this.shouldFailSend) { + throw new Error('Expected steer failure.'); + } + return { + sessionId, + turnId, + steeringId: 'steering-1' + }; + } + + async buildPlan(sessionId: string, planFilePath: string, planName: string, agentType: string): Promise { + this.buildPlanRequests.push(`${sessionId}:${planFilePath}:${planName}:${agentType}`); + if (this.shouldFailSend) { + throw new Error('Expected plan build failure.'); + } + return this.turnId; + } + async cancelTask(sessionId: string, turnId?: string): Promise { this.cancelRequests.push(`${sessionId}:${turnId || ''}`); if (this.shouldFailCancel) { diff --git a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets index 2a845cc4c8..d282111cfe 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -1794,7 +1794,7 @@ export default function remoteControllersUnitTest() { hollow.thinking = ''; hollow.tools = []; hollow.items = []; - await cache.sync('session-1', [ + await cache.syncIncremental('session-1', [ chatMessage('message-1', 'user', 'Hello'), hollow ]); @@ -1923,12 +1923,12 @@ export default function remoteControllersUnitTest() { }, cache); await controller.loadMessages('session-1', (_sessionId: string) => true); - await cache.sync('session-1', [ + await cache.syncIncremental('session-1', [ chatMessage('message-1', 'user', 'Hello'), chatMessage('message-2', 'assistant', 'Hi') ]); // Nothing new arrived, so the second sync must not touch the store. - await cache.sync('session-1', [ + await cache.syncIncremental('session-1', [ chatMessage('message-1', 'user', 'Hello'), chatMessage('message-2', 'assistant', 'Hi') ]); @@ -1938,14 +1938,44 @@ export default function remoteControllersUnitTest() { expect((await cache.load('session-1')).length).assertEqual(2); }); + it('persists an authoritative body update when message ids and count stay unchanged', 0, async () => { + const store = new FakeRemoteChatCacheStore(); + const cache = await readyRemoteChatCache(store); + const partialAssistant = chatMessage('message-2', 'assistant', ''); + partialAssistant.status = 'done'; + partialAssistant.thinking = 'Finished the work.'; + + await cache.syncIncremental('session-1', [ + chatMessage('message-1', 'user', 'Open the PR'), + partialAssistant + ]); + + const completedAssistant = chatMessage('message-2', 'assistant', 'PR created successfully.'); + completedAssistant.status = 'done'; + completedAssistant.thinking = 'Finished the work.'; + await cache.replaceSnapshot('session-1', [ + chatMessage('message-1', 'user', 'Open the PR'), + completedAssistant + ]); + + // A fresh cache instance models an app restart: the completed body must + // come from RDB rather than from the in-memory resident transcript. + const restarted = await readyRemoteChatCache(store); + const restored = await restarted.load('session-1'); + expect(store.replaceCount).assertEqual(2); + expect(restored.length).assertEqual(2); + expect(restored[1].id).assertEqual('message-2'); + expect(restored[1].text).assertEqual('PR created successfully.'); + }); + it('rewrites the cache when the stored prefix stops matching', 0, async () => { const store = new FakeRemoteChatCacheStore(); const cache = await readyRemoteChatCache(store); - await cache.sync('session-1', [chatMessage('message-1', 'user', 'Hello')]); + await cache.syncIncremental('session-1', [chatMessage('message-1', 'user', 'Hello')]); // Same length, different first message: an append here would splice two // different transcripts together. - await cache.sync('session-1', [chatMessage('message-9', 'user', 'Rewritten')]); + await cache.syncIncremental('session-1', [chatMessage('message-9', 'user', 'Rewritten')]); const restored = await cache.load('session-1'); expect(store.appendCount).assertEqual(0); @@ -1958,8 +1988,8 @@ export default function remoteControllersUnitTest() { const store = new FakeRemoteChatCacheStore(); const cache = await readyRemoteChatCache(store); - await cache.sync('session-1', [chatMessage('message-1', 'user', 'Hello')]); - await cache.sync('session-1', [ + await cache.syncIncremental('session-1', [chatMessage('message-1', 'user', 'Hello')]); + await cache.syncIncremental('session-1', [ chatMessage('message-1', 'user', 'Hello'), chatMessage('message-2', 'assistant', 'Hi') ]); @@ -1975,7 +2005,7 @@ export default function remoteControllersUnitTest() { // One past the budget the prune above reported. for (let index = 0; index <= 20; index++) { - await cache.sync(`session-${index}`, [chatMessage(`message-${index}`, 'user', 'Hello')]); + await cache.syncIncremental(`session-${index}`, [chatMessage(`message-${index}`, 'user', 'Hello')]); } expect((await cache.load('session-0')).length).assertEqual(0); @@ -1988,12 +2018,12 @@ export default function remoteControllersUnitTest() { const cache = await readyRemoteChatCache(store); for (let index = 0; index < 20; index++) { - await cache.sync(`session-${index}`, [chatMessage(`message-${index}`, 'user', 'Hello')]); + await cache.syncIncremental(`session-${index}`, [chatMessage(`message-${index}`, 'user', 'Hello')]); } // Reopening is what recency is supposed to be about: this read has to // outrank the nineteen sessions written after it. expect((await cache.load('session-0')).length).assertEqual(1); - await cache.sync('session-20', [chatMessage('message-20', 'user', 'Hello')]); + await cache.syncIncremental('session-20', [chatMessage('message-20', 'user', 'Hello')]); expect((await cache.load('session-0')).length).assertEqual(1); expect((await cache.load('session-1')).length).assertEqual(0); @@ -2003,7 +2033,7 @@ export default function remoteControllersUnitTest() { const store = new FakeRemoteChatCacheStore(); const cache = await readyRemoteChatCache(store); - await cache.sync('session-1', [chatMessage('message-1', 'user', 'Hello')]); + await cache.syncIncremental('session-1', [chatMessage('message-1', 'user', 'Hello')]); await cache.forget('session-1'); expect((await cache.load('session-1')).length).assertEqual(0); @@ -2170,6 +2200,51 @@ export default function remoteControllersUnitTest() { expect(busyEvents[3]).assertFalse(); }); + it('steers the active turn and projects the accepted steering identity', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + const accepted: string[] = []; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, + onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, + onTimelineReady: () => {}, + onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, + onSteerSucceeded: ( + steeringId: string, + turnId: string, + displayText: string, + images: SelectedImageAttachment[] + ) => accepted.push(`${steeringId}:${turnId}:${displayText}:${images.length}`), + onSendFailed: ( + _rawText: string, + _images: SelectedImageAttachment[], + _localMessageId: string, + _pendingActiveId: string + ) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onSessionTitleChanged: (_sessionId: string, _title: string) => {}, + onStatusText: (_statusText: string) => {}, + onToast: (_message: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onPollRequested: () => {} + }, inertRemoteChatCache()); + + await controller.steerPreparedMessage( + 'session-1', + 'turn-1', + 'Continue with this', + 'Continue with this', + [selectedImage('image-1')], + [{ id: 'image-1', data_url: 'data:image/png;base64,AA==', mime_type: 'image/png' }], + false, + true + ); + + expect(client.steerRequests[0]) + .assertEqual('session-1:turn-1:Continue with this:Continue with this:1'); + expect(accepted[0]).assertEqual('steering-1:turn-1:Continue with this:1'); + expect(client.sendRequests.length).assertEqual(0); + }); + it('stops active tasks and reports guard states', 0, async () => { const client = new FakeRemoteChatCommandClient(); const statuses: string[] = []; diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index 2ea1096d94..fab3b05f89 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -1876,6 +1876,15 @@ export default function transportAndGeneralChatUnitTest() { data_url: 'data:image/png;base64,abc', mime_type: 'image/png' }]), '{"cmd":"send_message","session_id":"s1","content":"look","agent_type":"code","image_contexts":[{"id":"img1","data_url":"data:image/png;base64,abc","mime_type":"image/png"}]}'); + expectCommandJson(RemoteCommandFactory.steerTurn('s1', 'turn-1', 'continue', 'continue', [{ + id: 'img1', + data_url: 'data:image/png;base64,abc', + mime_type: 'image/png' + }]), '{"cmd":"steer_turn","session_id":"s1","turn_id":"turn-1","content":"continue","display_content":"continue","image_contexts":[{"id":"img1","data_url":"data:image/png;base64,abc","mime_type":"image/png"}]}'); + expectCommandJson( + RemoteCommandFactory.buildPlan('s1', '/repo/.bitfun/plans/mobile.plan.md', 'Mobile plan', 'code'), + '{"cmd":"build_plan","session_id":"s1","plan_file_path":"/repo/.bitfun/plans/mobile.plan.md","plan_name":"Mobile plan","agent_type":"code"}' + ); expectCommandJson(RemoteCommandFactory.getSessionMessages('s1'), '{"cmd":"get_session_messages","session_id":"s1"}'); expectCommandJson(RemoteCommandFactory.pollSession('s1', 7, 3, 2), '{"cmd":"poll_session","session_id":"s1","since_version":7,"known_msg_count":3,"known_model_catalog_version":2}'); }); diff --git a/src/crates/assembly/core/src/service/remote_connect/remote_server.rs b/src/crates/assembly/core/src/service/remote_connect/remote_server.rs index 5269aae117..b1cf0a89d4 100644 --- a/src/crates/assembly/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/assembly/core/src/service/remote_connect/remote_server.rs @@ -19,9 +19,9 @@ use bitfun_services_integrations::remote_connect::{ handle_remote_command, handle_remote_interaction_command, handle_remote_poll_command, handle_remote_session_command, handle_remote_workspace_command, handle_remote_workspace_file_command, submit_remote_dialog, RemoteCancelTaskRequest, - RemoteCommandRuntimeHost, RemoteConnectSubmissionSource, RemoteDialogSubmissionPolicy, - RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteImageContext, - RemoteSessionTrackerRegistry, + RemoteCommandRuntimeHost, RemoteConnectSubmissionSource, RemoteDialogSteerOutcome, + RemoteDialogSteerRequest, RemoteDialogSubmissionPolicy, RemoteDialogSubmissionRequest, + RemoteDialogSubmitOutcome, RemoteImageContext, RemoteSessionTrackerRegistry, }; pub use bitfun_services_integrations::remote_connect::{ ActiveTurnSnapshot, AssistantEntry, ChatImageAttachment, ChatMessage, ChatMessageItem, @@ -129,6 +129,7 @@ impl RemoteExecutionDispatcher { RemoteDialogSubmissionRequest { session_id: session_id.to_string(), content, + display_content: None, agent_type: agent_type.map(ToOwned::to_owned), image_contexts, policy: RemoteDialogSubmissionPolicy::for_source(source), @@ -276,6 +277,14 @@ impl RemoteCommandRuntimeHost for CoreRemoteCommandRuntimeHost<'_> { submit_remote_dialog(&host, request).await } + async fn steer_dialog( + &self, + request: RemoteDialogSteerRequest, + ) -> std::result::Result { + let host = CoreServiceAgentRuntime::remote_dialog_host(self.dispatcher)?; + host.steer_dialog(request).await + } + async fn cancel_task( &self, request: RemoteCancelTaskRequest, @@ -550,6 +559,7 @@ mod tests { let command = RemoteCommand::SendMessage { session_id: "session-1".to_string(), content: "hello".to_string(), + display_content: None, agent_type: Some("code".to_string()), images: Some(vec![ImageAttachment { name: "clip.png".to_string(), @@ -622,10 +632,13 @@ mod tests { start_ms: Some(42), input_preview: Some("{\"path\":\"README.md\"}".to_string()), tool_input: None, + plan: None, }], round_index: 2, items: Some(vec![ChatMessageItem { item_type: "tool".to_string(), + steering_id: None, + round_index: None, content: None, tool: None, is_subagent: None, diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 4bd4dfbcda..fed5bb81d2 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -18,6 +18,14 @@ use bitfun_agent_runtime::sdk::{ AgentSessionModelUpdateRequest, }; use bitfun_events::AgenticEvent; +#[cfg(feature = "remote-connect")] +use bitfun_runtime_ports::{ + AgentDialogSteerRequest, AgentInputAttachment, AgentSubmissionSource, + AgentTurnCancellationRequest, DialogSteerOutcome, PermissionPolicyPreset, + RemoteControlStatePort, RemoteControlStateRequest, RemoteControlStateSnapshot, + RemoteSessionWorkspaceIdentity, RuntimeServiceCapability, RuntimeServicePort, + ToolPermissionConfig, +}; use bitfun_runtime_ports::{ AgentDialogTurnPort, AgentDialogTurnRequest, AgentLifecycleDeliveryPort, AgentLocalCommandTurnPort, AgentSessionClosePort, AgentSessionCreateRequest, @@ -29,13 +37,6 @@ use bitfun_runtime_ports::{ SessionStorePort, }; #[cfg(feature = "remote-connect")] -use bitfun_runtime_ports::{ - AgentInputAttachment, AgentSubmissionSource, AgentTurnCancellationRequest, - PermissionPolicyPreset, RemoteControlStatePort, RemoteControlStateRequest, - RemoteControlStateSnapshot, RemoteSessionWorkspaceIdentity, RuntimeServiceCapability, - RuntimeServicePort, ToolPermissionConfig, -}; -#[cfg(feature = "remote-connect")] use bitfun_services_integrations::remote_connect::{ agent_input_attachment_from_remote_image_context, build_remote_chat_messages, build_remote_model_catalog, @@ -46,15 +47,16 @@ use bitfun_services_integrations::remote_connect::{ RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, - RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSubmissionPolicy, - RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, RemoteImageContext, - RemoteInitialSyncRuntimeHost, RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, - RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelFacts, RemotePermissionMode, - RemotePollRuntimeHost, RemoteRecentWorkspaceFacts, RemoteSessionMetadata, - RemoteSessionModelSelection, RemoteSessionRuntimeHost, RemoteSessionStateTracker, - RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, - RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, - RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, + RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSteerOutcome, + RemoteDialogSteerRequest, RemoteDialogSubmissionPolicy, RemoteDialogSubmitOutcome, + RemoteDialogWorkspaceBinding, RemoteImageContext, RemoteInitialSyncRuntimeHost, + RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, RemoteModelCatalog, + RemoteModelCatalogFacts, RemoteModelFacts, RemotePermissionMode, RemotePollRuntimeHost, + RemoteRecentWorkspaceFacts, RemoteSessionMetadata, RemoteSessionModelSelection, + RemoteSessionRuntimeHost, RemoteSessionStateTracker, RemoteSessionTrackerHost, + RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, RemoteWorkspaceFileRuntimeHost, + RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceRuntimeHost, + RemoteWorkspaceUpdate, }; #[cfg(feature = "remote-connect")] use log::{debug, info}; @@ -617,6 +619,10 @@ fn remote_chat_history_turn_from_core_turn(turn: &DialogTurnData) -> RemoteChatH id: item.tool_call.id.clone(), input: item.effective_input().clone(), }, + result: item + .tool_result + .as_ref() + .map(|result| result.result.clone()), has_result: item.tool_result.is_some(), status: item.status.clone(), duration_ms: item.duration_ms, @@ -2220,6 +2226,39 @@ impl<'a> CoreRemoteDialogRuntimeHost<'a> { runtime, }) } + + pub(crate) async fn steer_dialog( + &self, + request: RemoteDialogSteerRequest, + ) -> Result { + let attachments = request + .image_contexts + .into_iter() + .map(agent_input_attachment_from_image_context) + .collect(); + self.runtime + .steer_dialog_turn(AgentDialogSteerRequest { + session_id: request.session_id, + turn_id: request.turn_id, + content: request.content, + display_content: request.display_content, + attachments, + metadata: request.metadata, + }) + .await + .map(|outcome| match outcome { + DialogSteerOutcome::Buffered { + session_id, + turn_id, + steering_id, + } => RemoteDialogSteerOutcome { + session_id, + turn_id, + steering_id, + }, + }) + .map_err(CoreServiceAgentRuntime::runtime_error_message) + } } #[cfg(feature = "remote-connect")] @@ -2454,7 +2493,7 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { session_id: submission.session_id, message: submission.content, output_schema: None, - original_message: None, + original_message: submission.display_content, turn_id: Some(submission.turn_id), execution: Default::default(), agent_type: submission.resolved_agent_type, diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index e33167c556..854149af36 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -368,6 +368,7 @@ impl RemoteDialogSubmissionPolicy { pub struct RemoteDialogSubmissionRequest { pub session_id: String, pub content: String, + pub display_content: Option, pub agent_type: Option, pub image_contexts: Vec, pub policy: RemoteDialogSubmissionPolicy, @@ -401,6 +402,7 @@ impl RemoteDialogWorkspaceBinding { pub struct RemoteDialogResolvedSubmission { pub session_id: String, pub content: String, + pub display_content: Option, pub resolved_agent_type: String, pub binding_workspace: Option, pub image_contexts: Vec, @@ -408,6 +410,23 @@ pub struct RemoteDialogResolvedSubmission { pub turn_id: String, } +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteDialogSteerRequest { + pub session_id: String, + pub turn_id: String, + pub content: String, + pub display_content: Option, + pub image_contexts: Vec, + pub metadata: serde_json::Map, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteDialogSteerOutcome { + pub session_id: String, + pub turn_id: String, + pub steering_id: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum RemoteDialogSubmitOutcome { Started { session_id: String, turn_id: String }, @@ -485,6 +504,7 @@ where let RemoteDialogSubmissionRequest { session_id, content, + display_content, agent_type, image_contexts, policy, @@ -515,6 +535,7 @@ where host.submit_dialog(RemoteDialogResolvedSubmission { session_id, content, + display_content, resolved_agent_type, binding_workspace, image_contexts, @@ -527,9 +548,15 @@ where pub const REMOTE_FILE_MAX_READ_BYTES: u64 = 30 * 1024 * 1024; pub const REMOTE_FILE_MAX_CHUNK_BYTES: u64 = 3 * 1024 * 1024; pub const REMOTE_CAPABILITY_HARNESS_PROFILES_V1: &str = "harness_profiles_v1"; +pub const REMOTE_CAPABILITY_DIALOG_STEER_V1: &str = "dialog_steer_v1"; +pub const REMOTE_CAPABILITY_PLAN_BUILD_V1: &str = "plan_build_v1"; fn remote_host_capabilities() -> Vec { - vec![REMOTE_CAPABILITY_HARNESS_PROFILES_V1.to_string()] + vec![ + REMOTE_CAPABILITY_HARNESS_PROFILES_V1.to_string(), + REMOTE_CAPABILITY_DIALOG_STEER_V1.to_string(), + REMOTE_CAPABILITY_PLAN_BUILD_V1.to_string(), + ] } pub fn resolve_remote_file_chunk_range( @@ -861,6 +888,19 @@ pub fn remote_dialog_submit_response( } } +pub fn remote_dialog_steer_response( + result: Result, +) -> RemoteResponse { + match result { + Ok(outcome) => RemoteResponse::SteeringAccepted { + session_id: outcome.session_id, + turn_id: outcome.turn_id, + steering_id: outcome.steering_id, + }, + Err(message) => RemoteResponse::Error { message }, + } +} + pub fn remote_task_cancel_response( session_id: impl Into, result: Result<(), String>, @@ -1896,6 +1936,10 @@ pub struct ChatMessage { pub struct ChatMessageItem { #[serde(rename = "type")] pub item_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub steering_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub round_index: Option, #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1946,6 +1990,7 @@ pub struct RemoteChatHistoryToolItem { pub id: String, pub name: String, pub call: RemoteChatHistoryToolCall, + pub result: Option, pub has_result: bool, pub status: Option, pub duration_ms: Option, @@ -2013,6 +2058,8 @@ pub fn build_remote_chat_messages(turns: Vec) -> Vec) -> Vec) -> Vec) -> Vec, #[serde(skip_serializing_if = "Option::is_none")] pub tool_input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemotePlanTool { + pub file_path: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overview: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -2256,10 +2322,33 @@ pub enum RemoteCommand { SendMessage { session_id: String, content: String, + #[serde(default)] + display_content: Option, agent_type: Option, images: Option>, image_contexts: Option>, }, + BuildPlan { + session_id: String, + plan_file_path: String, + #[serde(default)] + plan_name: Option, + #[serde(default)] + agent_type: Option, + }, + SteerTurn { + session_id: String, + turn_id: String, + content: String, + #[serde(default)] + display_content: Option, + #[serde(default)] + images: Option>, + #[serde(default)] + image_contexts: Option>, + #[serde(default)] + metadata: serde_json::Map, + }, CancelTask { session_id: String, turn_id: Option, @@ -2449,6 +2538,11 @@ pub enum RemoteResponse { session_id: String, turn_id: String, }, + SteeringAccepted { + session_id: String, + turn_id: String, + steering_id: String, + }, TaskCancelled { session_id: String, }, @@ -2605,6 +2699,11 @@ pub trait RemoteCommandRuntimeHost: Send + Sync { request: RemoteDialogSubmissionRequest, ) -> Result; + async fn steer_dialog( + &self, + request: RemoteDialogSteerRequest, + ) -> Result; + async fn cancel_task(&self, request: RemoteCancelTaskRequest) -> Result<(), String>; fn legacy_image_contexts(&self, images: Option<&[ImageAttachment]>) -> Vec; @@ -2654,6 +2753,7 @@ where RemoteCommand::SendMessage { session_id, content, + display_content, agent_type, images, image_contexts, @@ -2674,6 +2774,7 @@ where host.submit_dialog(RemoteDialogSubmissionRequest { session_id: session_id.clone(), content: content.clone(), + display_content: display_content.clone(), agent_type: agent_type.clone(), image_contexts: resolved_contexts, policy: RemoteDialogSubmissionPolicy::for_source(source), @@ -2683,6 +2784,72 @@ where ) } + RemoteCommand::BuildPlan { + session_id, + plan_file_path, + plan_name, + agent_type, + } => { + let plan_file_path = plan_file_path.trim(); + if session_id.trim().is_empty() + || !plan_file_path.to_ascii_lowercase().ends_with(".plan.md") + || plan_file_path.contains(['\n', '\r', '`']) + { + return RemoteResponse::Error { + message: "Invalid plan file path.".to_string(), + }; + } + info!("Remote build_plan: session={session_id}"); + remote_dialog_submit_response( + host.submit_dialog(RemoteDialogSubmissionRequest { + session_id: session_id.clone(), + content: remote_plan_build_content(plan_file_path), + display_content: Some(remote_plan_build_display( + plan_name.as_deref(), + plan_file_path, + )), + agent_type: agent_type.clone(), + image_contexts: Vec::new(), + policy: RemoteDialogSubmissionPolicy::for_source(source), + turn_id: None, + }) + .await, + ) + } + + RemoteCommand::SteerTurn { + session_id, + turn_id, + content, + display_content, + images, + image_contexts, + metadata, + } => { + let resolved_contexts = resolve_remote_execution_image_contexts( + images.as_ref().map(Vec::as_slice), + image_contexts + .clone() + .map(|contexts| host.explicit_image_contexts(contexts)), + |images| host.legacy_image_contexts(images), + ); + info!( + "Remote steer_turn: session={session_id}, turn={turn_id}, image_contexts={}", + resolved_contexts.len() + ); + remote_dialog_steer_response( + host.steer_dialog(RemoteDialogSteerRequest { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + content: content.clone(), + display_content: display_content.clone(), + image_contexts: resolved_contexts, + metadata: metadata.clone(), + }) + .await, + ) + } + RemoteCommand::CancelTask { session_id, turn_id, @@ -2742,6 +2909,81 @@ pub fn make_slim_tool_params(params: &serde_json::Value) -> Option { } } +pub fn project_remote_plan_tool( + tool_name: &str, + input: Option<&serde_json::Value>, + result: Option<&serde_json::Value>, +) -> Option { + let normalized_name = tool_name + .chars() + .filter(|character| !matches!(character, '_' | '-' | ' ')) + .flat_map(char::to_lowercase) + .collect::(); + let create_plan = normalized_name == "createplan"; + let input_path = input.and_then(remote_plan_path_from_value); + let result_path = result.and_then(remote_plan_path_from_value); + let file_path = result_path.or(input_path).unwrap_or_default(); + let write_plan = matches!( + normalized_name.as_str(), + "write" | "writefile" | "createfile" + ) && file_path.to_ascii_lowercase().ends_with(".plan.md"); + if !create_plan && !write_plan { + return None; + } + + let name = result + .and_then(|value| remote_json_string(value, &["name"])) + .or_else(|| input.and_then(|value| remote_json_string(value, &["name", "title"]))) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| remote_plan_name_from_path(&file_path)); + let overview = result + .and_then(|value| remote_json_string(value, &["overview"])) + .or_else(|| input.and_then(|value| remote_json_string(value, &["overview"]))) + .filter(|value| !value.trim().is_empty()); + Some(RemotePlanTool { + file_path, + name, + overview, + }) +} + +fn remote_plan_path_from_value(value: &serde_json::Value) -> Option { + remote_json_string(value, &["plan_file_path", "file_path", "filePath", "path"]) + .filter(|value| !value.trim().is_empty()) +} + +fn remote_json_string(value: &serde_json::Value, keys: &[&str]) -> Option { + let object = value.as_object()?; + keys.iter() + .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str)) + .map(ToOwned::to_owned) +} + +fn remote_plan_name_from_path(path: &str) -> String { + let normalized = path.replace('\\', "/"); + let file_name = normalized.rsplit('/').next().unwrap_or_default(); + file_name + .strip_suffix(".plan.md") + .or_else(|| file_name.strip_suffix(".md")) + .unwrap_or(file_name) + .to_string() +} + +pub fn remote_plan_build_content(plan_file_path: &str) -> String { + format!( + "Implement the plan at `{plan_file_path}`.\n\nRead the plan file before making changes and treat it as the source of truth. Do not edit the plan file directly. Track progress with TodoWrite using the existing todo IDs from the plan frontmatter; do not rename or invent IDs. Start with the first pending todo and continue until all todos are completed." + ) +} + +pub fn remote_plan_build_display(plan_name: Option<&str>, plan_file_path: &str) -> String { + let name = plan_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| remote_plan_name_from_path(plan_file_path)); + format!("Build Plan: {name}") +} + #[derive(Debug)] struct TrackerState { session_state: String, @@ -2919,6 +3161,7 @@ impl RemoteSessionStateTracker { if already_pending { return; } + let plan = project_remote_plan_tool(&tool_name, tool_input.as_ref(), None); Self::upsert_active_tool( &mut state, &tool_id, @@ -2926,6 +3169,7 @@ impl RemoteSessionStateTracker { "pending_confirmation", input_preview, tool_input, + plan, false, ); state.session_state = "running".to_string(); @@ -3015,7 +3259,7 @@ impl RemoteSessionStateTracker { ) -> Option { for index in (0..items.len()).rev() { let item = &items[index]; - if item.item_type == "tool" { + if item.item_type == "tool" || item.item_type == "user-steering" { return None; } if item.item_type == target_type && &item.is_subagent == subagent_marker { @@ -3032,6 +3276,7 @@ impl RemoteSessionStateTracker { status: &str, input_preview: Option, tool_input: Option, + plan: Option, is_subagent: bool, ) { let resolved_id = if tool_id.is_empty() { @@ -3054,6 +3299,9 @@ impl RemoteSessionStateTracker { if tool_input.is_some() { tool.tool_input = tool_input.clone(); } + if plan.is_some() { + tool.plan = Self::merge_plan_tool(tool.plan.as_ref(), plan.clone()); + } } else { let tool_status = RemoteToolStatus { id: resolved_id.clone(), @@ -3068,10 +3316,13 @@ impl RemoteSessionStateTracker { ), input_preview, tool_input, + plan, }; state.active_tools.push(tool_status.clone()); state.active_items.push(ChatMessageItem { item_type: "tool".to_string(), + steering_id: None, + round_index: None, content: None, tool: Some(tool_status), is_subagent: subagent_marker, @@ -3093,7 +3344,32 @@ impl RemoteSessionStateTracker { if tool_input.is_some() { tool.tool_input = tool_input; } + if plan.is_some() { + tool.plan = Self::merge_plan_tool(tool.plan.as_ref(), plan); + } + } + } + } + + fn merge_plan_tool( + existing: Option<&RemotePlanTool>, + incoming: Option, + ) -> Option { + match (existing, incoming) { + (Some(existing), Some(mut incoming)) => { + if incoming.file_path.is_empty() { + incoming.file_path.clone_from(&existing.file_path); + } + if !existing.name.is_empty() { + incoming.name.clone_from(&existing.name); + } + if incoming.overview.is_none() { + incoming.overview.clone_from(&existing.overview); + } + Some(incoming) } + (None, incoming) => incoming, + (Some(existing), None) => Some(existing.clone()), } } @@ -3156,6 +3432,8 @@ impl RemoteSessionStateTracker { } else { state.active_items.push(ChatMessageItem { item_type: "text".to_string(), + steering_id: None, + round_index: None, content: Some(text.clone()), tool: None, is_subagent: subagent_marker, @@ -3184,6 +3462,8 @@ impl RemoteSessionStateTracker { } else { state.active_items.push(ChatMessageItem { item_type: "thinking".to_string(), + steering_id: None, + round_index: None, content: Some(clean), tool: None, is_subagent: subagent_marker, @@ -3232,12 +3512,14 @@ impl RemoteSessionStateTracker { "preparing", None, None, + None, is_subagent, ); } "ConfirmationNeeded" => { let params = effective_params.clone(); let input_preview = params.as_ref().and_then(make_slim_tool_params); + let plan = project_remote_plan_tool(&tool_name, params.as_ref(), None); Self::upsert_active_tool( &mut state, &tool_id, @@ -3245,6 +3527,7 @@ impl RemoteSessionStateTracker { "pending_confirmation", input_preview, params, + plan, is_subagent, ); } @@ -3259,6 +3542,7 @@ impl RemoteSessionStateTracker { } else { None }; + let plan = project_remote_plan_tool(&tool_name, params.as_ref(), None); Self::upsert_active_tool( &mut state, &tool_id, @@ -3266,6 +3550,7 @@ impl RemoteSessionStateTracker { "running", input_preview, tool_input, + plan, is_subagent, ); let _ = self.event_tx.send(TrackerEvent::ToolStarted { @@ -3282,6 +3567,7 @@ impl RemoteSessionStateTracker { "confirmed", None, None, + None, is_subagent, ); } @@ -3293,12 +3579,15 @@ impl RemoteSessionStateTracker { "rejected", None, None, + None, is_subagent, ); } "Completed" | "Succeeded" => { let duration = value.get("duration_ms").and_then(|value| value.as_u64()); + let plan = + project_remote_plan_tool(&tool_name, None, value.get("result")); if let Some(tool) = state.active_tools.iter_mut().rev().find(|tool| { (tool.id == tool_id || (allow_name_fallback && tool.name == tool_name)) @@ -3306,6 +3595,10 @@ impl RemoteSessionStateTracker { }) { tool.status = "completed".to_string(); tool.duration_ms = duration; + if plan.is_some() { + tool.plan = + Self::merge_plan_tool(tool.plan.as_ref(), plan.clone()); + } } if let Some(item) = state.active_items.iter_mut().rev().find(|item| { item.item_type == "tool" @@ -3318,6 +3611,10 @@ impl RemoteSessionStateTracker { if let Some(tool) = item.tool.as_mut() { tool.status = "completed".to_string(); tool.duration_ms = duration; + if plan.is_some() { + tool.plan = + Self::merge_plan_tool(tool.plan.as_ref(), plan.clone()); + } } } pending_tool_event = Some(TrackerEvent::ToolCompleted { @@ -3390,6 +3687,29 @@ impl RemoteSessionStateTracker { } } } + AE::UserSteeringInjected { + steering_id, + display_content, + round_index, + .. + } if is_direct => { + let mut state = self.state.write().unwrap(); + if !state.active_items.iter().any(|item| { + item.item_type == "user-steering" + && item.steering_id.as_deref() == Some(steering_id.as_str()) + }) { + state.active_items.push(ChatMessageItem { + item_type: "user-steering".to_string(), + steering_id: Some(steering_id.clone()), + round_index: Some(*round_index), + content: Some(display_content.clone()), + tool: None, + is_subagent: None, + }); + } + drop(state); + self.bump_version(); + } AE::DialogTurnStarted { turn_id, .. } if is_direct => { let mut state = self.state.write().unwrap(); state.turn_id = Some(turn_id.clone()); diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 374d212ec1..1fd8f2550d 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -16,14 +16,15 @@ use bitfun_services_integrations::remote_connect::{ build_remote_session_create_request, build_remote_submission_request, cancel_remote_task, handle_remote_command, handle_remote_workspace_file_command, make_slim_tool_params, normalize_remote_model_selection, normalize_remote_session_model_id, project_remote_chat_user, - read_remote_workspace_file, read_remote_workspace_file_chunk, read_remote_workspace_file_info, - remote_answer_question_response, remote_assistant_list_response, - remote_assistant_updated_response, remote_dialog_submit_outcome_from_scheduler, + project_remote_plan_tool, read_remote_workspace_file, read_remote_workspace_file_chunk, + read_remote_workspace_file_info, remote_answer_question_response, + remote_assistant_list_response, remote_assistant_updated_response, + remote_dialog_steer_response, remote_dialog_submit_outcome_from_scheduler, remote_dialog_submit_response, remote_file_chunk_response, remote_file_content_response, remote_file_display_name, remote_file_info_response, remote_initial_sync_response, remote_interaction_accepted_response, remote_messages_response, remote_model_catalog_poll_delta, remote_model_selection_needs_config, - remote_no_change_poll_response, remote_persisted_poll_response, + remote_no_change_poll_response, remote_persisted_poll_response, remote_plan_build_content, remote_recent_workspaces_response, remote_session_created_response, remote_session_deleted_response, remote_session_info, remote_session_list_response, remote_session_model_updated_response, remote_session_restore_target, @@ -39,15 +40,16 @@ use bitfun_services_integrations::remote_connect::{ RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, - RemoteDialogSubmissionPolicy, RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, - RemoteDialogWorkspaceBinding, RemoteImageContext, RemoteImageContextAdapter, - RemoteModelCapabilityFact, RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelConfig, - RemoteModelFacts, RemoteRecentWorkspaceFacts, RemoteResponse, RemoteSessionMetadata, - RemoteSessionModelSelection, RemoteSessionStateTracker, RemoteSessionTrackerHost, - RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, RemoteTerminalPrewarmRequest, - RemoteToolStatus, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, RemoteWorkspaceFileContent, - RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, - RemoteWorkspaceUpdate, TrackerEvent, REMOTE_CAPABILITY_HARNESS_PROFILES_V1, + RemoteDialogSteerOutcome, RemoteDialogSteerRequest, RemoteDialogSubmissionPolicy, + RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, + RemoteImageContext, RemoteImageContextAdapter, RemoteModelCapabilityFact, RemoteModelCatalog, + RemoteModelCatalogFacts, RemoteModelConfig, RemoteModelFacts, RemoteRecentWorkspaceFacts, + RemoteResponse, RemoteSessionMetadata, RemoteSessionModelSelection, RemoteSessionStateTracker, + RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, + RemoteTerminalPrewarmRequest, RemoteToolStatus, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, + RemoteWorkspaceFileContent, RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, + RemoteWorkspaceKind, RemoteWorkspaceUpdate, TrackerEvent, REMOTE_CAPABILITY_DIALOG_STEER_V1, + REMOTE_CAPABILITY_HARNESS_PROFILES_V1, REMOTE_CAPABILITY_PLAN_BUILD_V1, REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, }; use std::path::PathBuf; @@ -595,6 +597,7 @@ fn remote_history_contract_turn(is_in_progress: bool) -> RemoteChatHistoryTurn { id: "call-1".to_string(), input: serde_json::json!({ "question": "confirm?" }), }, + result: None, has_result: false, status: Some("running".to_string()), duration_ms: Some(25), @@ -863,6 +866,7 @@ impl RemoteCancelRuntimeHost for RecordingCancelHost { struct RecordingCommandHost { events: Mutex>, submitted_dialog: Mutex>>, + steered_dialog: Mutex>>, cancel_request: Mutex>, explicit_context_ids: Mutex>, legacy_image_names: Mutex>, @@ -888,6 +892,14 @@ impl RecordingCommandHost { .clone() .expect("cancel requested") } + + fn steered_dialog(&self) -> RemoteDialogSteerRequest { + self.steered_dialog + .lock() + .unwrap() + .clone() + .expect("dialog steered") + } } #[async_trait::async_trait] @@ -960,6 +972,19 @@ impl RemoteCommandRuntimeHost for RecordingCommandHost { }) } + async fn steer_dialog( + &self, + request: RemoteDialogSteerRequest, + ) -> Result { + self.events.lock().unwrap().push("steer".to_string()); + *self.steered_dialog.lock().unwrap() = Some(request.clone()); + Ok(RemoteDialogSteerOutcome { + session_id: request.session_id, + turn_id: request.turn_id, + steering_id: "steering-command".to_string(), + }) + } + async fn cancel_task(&self, request: RemoteCancelTaskRequest) -> Result<(), String> { self.events.lock().unwrap().push("cancel".to_string()); *self.cancel_request.lock().unwrap() = Some(request); @@ -1001,6 +1026,7 @@ async fn remote_connect_command_owner_routes_send_message_and_prefers_explicit_i &RemoteCommand::SendMessage { session_id: "session-1".to_string(), content: "hello".to_string(), + display_content: None, agent_type: Some("code".to_string()), images: Some(vec![ImageAttachment { name: "legacy.png".to_string(), @@ -1041,6 +1067,114 @@ async fn remote_connect_command_owner_routes_send_message_and_prefers_explicit_i assert!(submitted.turn_id.is_none()); } +#[tokio::test] +async fn remote_connect_command_owner_builds_a_projected_plan_with_hidden_execution_copy() { + let host = RecordingCommandHost::default(); + let response = handle_remote_command( + &host, + &RemoteCommand::BuildPlan { + session_id: "session-1".to_string(), + plan_file_path: "/repo/.bitfun/plans/mobile.plan.md".to_string(), + plan_name: Some("Mobile plan".to_string()), + agent_type: Some("code".to_string()), + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + + assert_eq!( + response, + RemoteResponse::MessageSent { + session_id: "session-1".to_string(), + turn_id: "turn-command".to_string() + } + ); + let submitted = host.submitted_dialog(); + assert_eq!( + submitted.content, + remote_plan_build_content("/repo/.bitfun/plans/mobile.plan.md") + ); + assert_eq!( + submitted.display_content.as_deref(), + Some("Build Plan: Mobile plan") + ); + assert!(submitted.image_contexts.is_empty()); +} + +#[test] +fn remote_connect_plan_projection_covers_legacy_create_and_modern_write_tools() { + let legacy = project_remote_plan_tool( + "CreatePlan", + Some(&serde_json::json!({ "name": "Legacy plan" })), + Some(&serde_json::json!({ + "plan_file_path": "/repo/.bitfun/plans/legacy.plan.md", + "overview": "Ship the mobile flow" + })), + ) + .expect("legacy CreatePlan is projected"); + assert_eq!(legacy.name, "Legacy plan"); + assert_eq!(legacy.file_path, "/repo/.bitfun/plans/legacy.plan.md"); + + let modern = project_remote_plan_tool( + "Write", + Some(&serde_json::json!({ + "file_path": "/repo/.bitfun/plans/modern.plan.md" + })), + None, + ) + .expect("modern plan write is projected"); + assert_eq!(modern.name, "modern"); + assert!(project_remote_plan_tool( + "Write", + Some(&serde_json::json!({ "file_path": "/repo/README.md" })), + None, + ) + .is_none()); +} + +#[tokio::test] +async fn remote_connect_command_owner_routes_steering_without_starting_a_new_turn() { + let host = RecordingCommandHost::default(); + let response = handle_remote_command( + &host, + &RemoteCommand::SteerTurn { + session_id: "session-1".to_string(), + turn_id: "turn-running".to_string(), + content: "change direction".to_string(), + display_content: Some("Change direction".to_string()), + images: None, + image_contexts: Some(vec![RemoteImageContext { + id: "ctx-steer".to_string(), + image_path: None, + data_url: Some("data:image/png;base64,aGVsbG8=".to_string()), + mime_type: "image/png".to_string(), + metadata: None, + }]), + metadata: serde_json::Map::from_iter([( + "source".to_string(), + serde_json::json!("harmony"), + )]), + }, + RemoteConnectSubmissionSource::Relay, + ) + .await; + + assert_eq!( + response, + RemoteResponse::SteeringAccepted { + session_id: "session-1".to_string(), + turn_id: "turn-running".to_string(), + steering_id: "steering-command".to_string(), + } + ); + assert_eq!(host.events(), vec!["steer"]); + let request = host.steered_dialog(); + assert_eq!(request.content, "change direction"); + assert_eq!(request.display_content.as_deref(), Some("Change direction")); + assert_eq!(request.image_contexts, vec!["explicit:ctx-steer"]); + assert_eq!(request.metadata["source"], "harmony"); +} + #[tokio::test] async fn remote_connect_command_owner_preserves_cancel_and_group_routing() { let host = RecordingCommandHost::default(); @@ -1125,6 +1259,7 @@ async fn remote_connect_dialog_runtime_owns_restore_prewarm_and_submit_order() { RemoteDialogSubmissionRequest { session_id: "session-1".to_string(), content: "hello".to_string(), + display_content: None, agent_type: Some("code".to_string()), image_contexts: vec!["image-1".to_string()], policy: RemoteDialogSubmissionPolicy::for_source(RemoteConnectSubmissionSource::Relay), @@ -1190,6 +1325,7 @@ async fn remote_connect_dialog_runtime_preserves_remote_workspace_identity() { RemoteDialogSubmissionRequest { session_id: "session-1".to_string(), content: "hello".to_string(), + display_content: None, agent_type: Some("code".to_string()), image_contexts: Vec::::new(), policy: RemoteDialogSubmissionPolicy::for_source(RemoteConnectSubmissionSource::Relay), @@ -1235,6 +1371,7 @@ async fn remote_connect_dialog_runtime_preserves_explicit_turn_without_restore() RemoteDialogSubmissionRequest { session_id: "session-1".to_string(), content: "from bot".to_string(), + display_content: None, agent_type: Some("Cowork".to_string()), image_contexts: Vec::new(), policy: RemoteDialogSubmissionPolicy::for_source(RemoteConnectSubmissionSource::Bot), @@ -1301,6 +1438,7 @@ async fn remote_connect_dialog_runtime_keeps_legacy_restore_failure_tolerance() RemoteDialogSubmissionRequest { session_id: "session-1".to_string(), content: "hello".to_string(), + display_content: None, agent_type: None, image_contexts: Vec::new(), policy: RemoteDialogSubmissionPolicy::for_source(RemoteConnectSubmissionSource::Relay), @@ -1685,6 +1823,19 @@ fn remote_connect_execution_response_helpers_preserve_wire_shape() { } ); + assert_eq!( + remote_dialog_steer_response(Ok(RemoteDialogSteerOutcome { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + steering_id: "steering-1".to_string(), + })), + RemoteResponse::SteeringAccepted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + steering_id: "steering-1".to_string(), + } + ); + assert_eq!( remote_task_cancel_response("session-1", Ok(())), RemoteResponse::TaskCancelled { @@ -1735,7 +1886,11 @@ fn remote_connect_workspace_response_helpers_own_wire_shape() { assert_eq!(info_json["remote_ssh_host"], "dev-host"); assert_eq!( info_json["capabilities"], - serde_json::json!([REMOTE_CAPABILITY_HARNESS_PROFILES_V1]) + serde_json::json!([ + REMOTE_CAPABILITY_HARNESS_PROFILES_V1, + REMOTE_CAPABILITY_DIALOG_STEER_V1, + REMOTE_CAPABILITY_PLAN_BUILD_V1 + ]) ); let mut legacy_info_json = info_json.clone(); legacy_info_json @@ -1898,7 +2053,11 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { assert_eq!(initial_json["authenticated_user_id"], "user-1"); assert_eq!( initial_json["capabilities"], - serde_json::json!([REMOTE_CAPABILITY_HARNESS_PROFILES_V1]) + serde_json::json!([ + REMOTE_CAPABILITY_HARNESS_PROFILES_V1, + REMOTE_CAPABILITY_DIALOG_STEER_V1, + REMOTE_CAPABILITY_PLAN_BUILD_V1 + ]) ); let mut legacy_initial_json = initial_json; legacy_initial_json @@ -2012,10 +2171,13 @@ fn remote_connect_message_dtos_keep_current_wire_shape() { start_ms: Some(42), input_preview: Some("{\"cmd\":\"git status\"}".to_string()), tool_input: None, + plan: None, }]), thinking: None, items: Some(vec![ChatMessageItem { item_type: "tool".to_string(), + steering_id: None, + round_index: None, content: None, tool: None, is_subagent: Some(false), @@ -2042,6 +2204,7 @@ fn remote_connect_command_wire_shape_lives_in_owner_contract() { let command = RemoteCommand::SendMessage { session_id: "session-1".to_string(), content: "hello".to_string(), + display_content: None, agent_type: Some("code".to_string()), images: Some(vec![ImageAttachment { name: "clip.png".to_string(), @@ -2193,10 +2356,13 @@ fn remote_connect_response_wire_shape_lives_in_owner_contract() { start_ms: Some(42), input_preview: Some("{\"path\":\"README.md\"}".to_string()), tool_input: None, + plan: None, }], round_index: 2, items: Some(vec![ChatMessageItem { item_type: "tool".to_string(), + steering_id: None, + round_index: None, content: None, tool: None, is_subagent: None, @@ -2546,6 +2712,56 @@ fn remote_connect_tracker_preserves_streaming_snapshot_contract() { assert_eq!(items[1].content.as_deref(), Some("answer")); } +#[test] +fn remote_connect_tracker_keeps_steering_between_assistant_text_segments() { + let tracker = RemoteSessionStateTracker::new("session-1".to_string()); + tracker.handle_agentic_event(&AgenticEvent::DialogTurnStarted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + turn_index: 0, + user_input: "hello".to_string(), + original_user_input: None, + user_message_metadata: None, + }); + tracker.handle_agentic_event(&AgenticEvent::TextChunk { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "before".to_string(), + }); + tracker.handle_agentic_event(&AgenticEvent::UserSteeringInjected { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 1, + steering_id: "steering-1".to_string(), + content: "raw direction".to_string(), + display_content: "New direction".to_string(), + }); + tracker.handle_agentic_event(&AgenticEvent::TextChunk { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-2".to_string(), + attempt_id: None, + attempt_index: None, + text: "after".to_string(), + }); + + let items = tracker + .snapshot_active_turn() + .expect("active turn") + .items + .expect("ordered items"); + assert_eq!(items.len(), 3); + assert_eq!(items[0].content.as_deref(), Some("before")); + assert_eq!(items[1].item_type, "user-steering"); + assert_eq!(items[1].steering_id.as_deref(), Some("steering-1")); + assert_eq!(items[1].round_index, Some(1)); + assert_eq!(items[1].content.as_deref(), Some("New direction")); + assert_eq!(items[2].content.as_deref(), Some("after")); +} + #[test] fn remote_connect_tracker_keeps_subagent_items_out_of_parent_accumulators() { let tracker = RemoteSessionStateTracker::new("parent-session".to_string());