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 57889ae10f..9f3fe26d4a 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 @@ -199,19 +199,6 @@ export const EN_US_MESSAGES: [string, string][] = [ ['remote.actions', 'Remote settings'], ['remote.device', 'Connected device'], ['remote.newChat', 'Chat'], - ['remote.create.title', 'New task'], - ['remote.create.chat', 'Chat'], - ['remote.create.noDevice', 'Choose a desktop'], - ['remote.create.noOnlineDevice', 'No online desktop is available'], - ['remote.create.placeholder', 'Tell BitFun what to do'], - ['remote.create.deviceLoadFailed', 'Could not load devices. Try again later.'], - ['remote.create.workspaceLoadFailed', 'Could not load workspaces. Try again later.'], - ['remote.create.deviceMismatch', 'The selected device is not connected. Choose it again and retry.'], - ['remote.create.submitFailed', 'Could not create the session. Check the desktop connection and retry.'], - ['remote.create.harness.minimal', 'Minimal'], - ['remote.create.harness.balanced', 'Standard'], - ['remote.create.harness.ultimate', 'Ultimate'], - ['remote.create.harnessAccessibility', 'Harness: {0}'], ['remote.workspace', 'Workspace'], ['remote.assistant', 'Assistant'], ['remote.noAssistant', 'Default assistant'], @@ -453,6 +440,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['chat.toolOpAskUser', 'Ask for confirmation'], ['chat.toolTodoFallback', 'Task list'], ['chat.toolTaskFallback', 'Subtask'], + ['chat.subagentOutput', 'Subtask output'], ['chat.toolFailed', '{0} failed'], ['chat.toolRunning', 'Running'], ['chat.showMore', 'Show more'], @@ -492,6 +480,8 @@ export const EN_US_MESSAGES: [string, string][] = [ ['filePreview.offline', 'Offline. Showing loaded content only.'], ['filePreview.fitImage', 'Fit window'], ['filePreview.actualImageSize', 'Actual size'], + ['filePreview.expand', 'Expand preview'], + ['filePreview.restoreSize', 'Restore preview'], ['filePreview.imageDecodeFailed', 'The image could not be decoded. Retry or download the file.'], ['filePreview.loadFailed', 'Could not open the file'], ['filePreview.notFound', 'The file does not exist or was moved'], @@ -605,9 +595,9 @@ export const EN_US_MESSAGES: [string, string][] = [ ['errors.voiceInputUnavailable', 'Speech recognition is unavailable. Try again later.'], ['errors.voiceInputFailed', 'Speech recognition failed ({0}). Try again later.'], - ['watchProvision.title', 'Add this watch to your BitFun account?'], + ['watchProvision.title', 'Sign in to BitFun on this watch?'], ['watchProvision.deviceId', 'Device {0}'], - ['watchProvision.body', 'If you allow this, the watch can use your account to connect a desktop for 30 days. Confirm the device is in your hands.'], + ['watchProvision.body', 'If you allow this, the watch will sign in to your account and can connect to a desktop. Confirm the watch is in your hands.'], ['watchProvision.approve', 'Allow'], ['watchProvision.reject', 'Deny'], ['watchProvision.gotIt', 'Got it'], @@ -615,11 +605,13 @@ export const EN_US_MESSAGES: [string, string][] = [ ['watchProvision.passwordBody', 'Confirm your account password once more to create a separate sign-in for this watch. The password is used only for this verification.'], ['watchProvision.passwordPlaceholder', 'Account password'], ['watchProvision.passwordConfirm', 'Confirm'], - ['watchProvision.doneBody', '{0} was added to the account and can be used on the watch now.'], + ['watchProvision.doneBody', '{0} is signed in and ready to use BitFun.'], ['watchProvision.rejected', 'Denied on the phone.'], ['watchProvision.busy', 'The phone is handling another device request. Try again later.'], - ['watchProvision.errors.noDesktop', 'Scan and connect a desktop on the phone before authorizing a watch.'], - ['watchProvision.errors.desktopUnreachable', 'The desktop is offline or too old. Update it and retry.'], + ['watchProvision.errors.noDesktop', 'Sign in to BitFun on the phone, or connect a desktop signed in to the same account.'], + ['watchProvision.errors.accountUnavailable', 'The account could not be verified. Check the phone network and try again.'], + ['watchProvision.errors.desktopUnreachable', 'The desktop could not be reached. Make sure it is online and try again.'], + ['watchProvision.errors.desktopAuthorizationFailed', 'The desktop could not sign in the watch. Make sure it uses the same BitFun account.'], ['watchProvision.errors.passwordFailed', 'Account verification failed. Check the password or network and retry.'], ['watchProvision.errors.handoffFailed', 'Authorization finished, but the credential could not be sent to the watch. Retry on the watch.'], 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 71a9d1df16..95a1c113af 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 @@ -199,19 +199,6 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['remote.actions', '远程设置'], ['remote.device', '连接的设备'], ['remote.newChat', '聊天'], - ['remote.create.title', '新建任务'], - ['remote.create.chat', '聊天'], - ['remote.create.noDevice', '选择桌面设备'], - ['remote.create.noOnlineDevice', '没有可用的在线桌面设备'], - ['remote.create.placeholder', '告诉 BitFun 要做什么'], - ['remote.create.deviceLoadFailed', '设备列表加载失败,请稍后重试。'], - ['remote.create.workspaceLoadFailed', '工作区加载失败,请稍后重试。'], - ['remote.create.deviceMismatch', '所选设备尚未连接,请重新选择设备后再试。'], - ['remote.create.submitFailed', '无法创建会话,请检查桌面连接后重试。'], - ['remote.create.harness.minimal', '极简'], - ['remote.create.harness.balanced', '标准'], - ['remote.create.harness.ultimate', '极致'], - ['remote.create.harnessAccessibility', 'Harness:{0}'], ['remote.workspace', '工作区'], ['remote.assistant', '助理'], ['remote.noAssistant', '默认助理'], @@ -453,6 +440,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['chat.toolOpAskUser', '请求确认'], ['chat.toolTodoFallback', '任务列表'], ['chat.toolTaskFallback', '子任务'], + ['chat.subagentOutput', '子任务输出'], ['chat.toolFailed', '{0}失败'], ['chat.toolRunning', '正在运行'], ['chat.showMore', '查看更多'], @@ -492,6 +480,8 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['filePreview.offline', '离线,仅显示已加载内容'], ['filePreview.fitImage', '适应窗口'], ['filePreview.actualImageSize', '原始大小'], + ['filePreview.expand', '放大预览'], + ['filePreview.restoreSize', '还原预览'], ['filePreview.imageDecodeFailed', '图片无法解码,请重试或下载文件。'], ['filePreview.loadFailed', '无法打开文件'], ['filePreview.notFound', '文件不存在或已被移动'], @@ -605,9 +595,9 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['errors.voiceInputUnavailable', '语音识别暂不可用,请稍后重试。'], ['errors.voiceInputFailed', '语音识别失败({0}),请稍后重试。'], - ['watchProvision.title', '把这块手表加入你的 BitFun 账号?'], + ['watchProvision.title', '在这块手表上登录 BitFun?'], ['watchProvision.deviceId', '设备编号 {0}'], - ['watchProvision.body', '同意后,这块手表可以用你的账号连接桌面端,有效期 30 天。请确认这台设备就在你手里。'], + ['watchProvision.body', '同意后,手表会登录你的账号并可以连接桌面端。请确认这块手表就在你手里。'], ['watchProvision.approve', '允许'], ['watchProvision.reject', '拒绝'], ['watchProvision.gotIt', '知道了'], @@ -615,11 +605,13 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['watchProvision.passwordBody', '需要再确认一次账号密码,才能给这块手表创建独立的登录凭证。密码只会用于本次验证。'], ['watchProvision.passwordPlaceholder', '账号密码'], ['watchProvision.passwordConfirm', '确认'], - ['watchProvision.doneBody', '{0} 已加入账号,手表上可以直接使用了。'], + ['watchProvision.doneBody', '{0} 已登录,可以在手表上使用 BitFun 了。'], ['watchProvision.rejected', '已在手机上拒绝。'], ['watchProvision.busy', '手机正在处理另一台设备的请求,请稍后再试。'], - ['watchProvision.errors.noDesktop', '需要先在手机上扫码连接桌面端,才能给手表授权。'], - ['watchProvision.errors.desktopUnreachable', '桌面端未在线或版本过旧,请更新桌面端后重试。'], + ['watchProvision.errors.noDesktop', '请先在手机上登录 BitFun 账号,或连接已登录同一账号的桌面端。'], + ['watchProvision.errors.accountUnavailable', '暂时无法验证账号,请检查手机网络后重试。'], + ['watchProvision.errors.desktopUnreachable', '暂时无法连接桌面端,请确认桌面端在线后重试。'], + ['watchProvision.errors.desktopAuthorizationFailed', '桌面端没能完成手表登录,请确认桌面端已登录同一 BitFun 账号。'], ['watchProvision.errors.passwordFailed', '账号验证失败,请检查密码或网络后重试。'], ['watchProvision.errors.handoffFailed', '授权已完成,但没能把凭证发给手表,请在手表上重试一次。'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/ChatTimelineModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/ChatTimelineModels.ets index bd8cda7623..c16a4e6766 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/ChatTimelineModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/ChatTimelineModels.ets @@ -71,12 +71,27 @@ export class ChatTimelineRowStore { private signaturesByKey: Map = new Map(); reconcile(items: ChatTimelineItem[]): ObservableChatTimelineItem[] { + // A lazy container requires unique keys. Keep the final projection for a + // key as a defensive boundary even if an upstream adapter temporarily + // exposes both a persisted assistant stub and its active turn snapshot. + const orderedKeys: string[] = []; + const itemsByKey: Map = new Map(); + items.forEach((item: ChatTimelineItem) => { + const key = ChatTimelineRevisionTracker.itemKey(item); + if (!itemsByKey.has(key)) { + orderedKeys.push(key); + } + itemsByKey.set(key, item); + }); const nextRowsByKey: Map = new Map(); const nextSignaturesByKey: Map = new Map(); const rows: ObservableChatTimelineItem[] = []; - items.forEach((item: ChatTimelineItem) => { - const key = ChatTimelineRevisionTracker.itemKey(item); + orderedKeys.forEach((key: string) => { + const item = itemsByKey.get(key); + if (!item) { + return; + } const signature = ChatTimelineRevisionTracker.itemSignature(item); let row = this.rowsByKey.get(key); if (row) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index 638d04fee2..bdf2ce7e99 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -41,7 +41,6 @@ struct AppRoot { navigationStack: this.runtime.navigationStack, remotePageState: this.runtime.remotePageState, deviceDirectoryState: this.runtime.deviceDirectoryState, - remoteCreateState: this.runtime.remoteCreateState, generalPageState: this.runtime.generalChatPageState, filePreviewState: this.runtime.filePreviewState, deviceId: this.runtime.remoteConnectionController.getDeviceId(), diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets index d545780f0f..157e8c1190 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -3,7 +3,6 @@ import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { DetectedUrlAction } from '../../services/ConnectScanDecisionPolicy'; import { ConversationIntent } from './ConversationIntent'; import { AppRoute } from '../navigation/AppRouteContract'; -import { RemoteHarnessProfile } from '../state/RemoteCreateSessionState'; export interface AppRootPresentationActions { readonly onNavigationBack: (route: AppRoute) => boolean; @@ -12,7 +11,6 @@ export interface AppRootPresentationActions { readonly onCompactLayoutEntered: () => void; readonly onLayoutModeChanged: (wideLayout: boolean) => void; readonly onRemoteHome: RemoteHomePresentationActions; - readonly onRemoteCreate: RemoteCreatePresentationActions; readonly onSidebar: SidebarPresentationActions; readonly onSettings: SettingsPresentationActions; readonly onConnect: ConnectPresentationActions; @@ -27,19 +25,6 @@ export interface FilePreviewPresentationActions { readonly openLink: (reference: string, label: string) => void; } -export interface RemoteCreatePresentationActions { - readonly back: () => void; - readonly toggleDevices: () => void; - readonly toggleWorkspaces: () => void; - readonly selectDevice: (device: CloudAccountDevice) => void; - readonly selectWorkspace: (path: string) => void; - readonly draftChanged: (value: string) => void; - readonly voiceInput: () => void; - readonly selectModel: (modelId: string) => void; - readonly selectHarness: (profile: RemoteHarnessProfile) => void; - readonly send: () => void; -} - export interface RemoteHomePresentationActions { readonly openSidebar: () => void; readonly connectWorkspace: () => void; @@ -60,8 +45,6 @@ export interface RemoteHomePresentationActions { readonly clearPairing: () => void; readonly create: (agentType: string) => void; readonly createInPlace: (agentType: string) => void; - readonly createAssistant: () => void; - readonly openCreateInWorkspace: (path: string, deviceId?: string) => void; readonly createInWorkspace: (path: string, agentType: string, deviceId?: string) => void; readonly createInWorkspaceInPlace: (path: string, agentType: string, deviceId?: string) => void; readonly selectDirectoryDevice: (deviceId: string) => void; @@ -136,17 +119,11 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions { refresh: () => {}, showWorkspaces: () => {}, showAssistants: () => {}, selectWorkspace: () => {}, selectAssistant: () => {}, cancelWorkspace: () => {}, cancelAssistant: () => {}, queryChanged: () => {}, search: () => {}, loadMore: () => {}, reconnect: () => {}, disconnect: () => {}, clearPairing: () => {}, - create: () => {}, createInPlace: () => {}, createAssistant: () => {}, openCreateInWorkspace: () => {}, - createInWorkspace: () => {}, + create: () => {}, createInPlace: () => {}, createInWorkspace: () => {}, createInWorkspaceInPlace: () => {}, selectDirectoryDevice: () => {}, addDirectoryWorkspace: () => {}, retryDirectoryDevice: () => {}, loadWorkspaceSessions: async () => {}, openSession: () => {}, openSessionInPlace: () => {}, deleteSession: () => {} }, - onRemoteCreate: { - back: () => {}, toggleDevices: () => {}, toggleWorkspaces: () => {}, selectDevice: () => {}, - selectWorkspace: () => {}, draftChanged: () => {}, voiceInput: () => {}, selectModel: () => {}, - selectHarness: () => {}, send: () => {} - }, onSidebar: { close: () => {}, newChat: () => {}, enterCode: () => {}, scanDesktop: () => {}, addDesktop: () => {}, refreshDevices: () => {}, settings: () => {}, openAccount: () => {}, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AdaptiveSheetOptions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AdaptiveSheetOptions.ets index 7d63bf4197..7b1ae8250c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AdaptiveSheetOptions.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AdaptiveSheetOptions.ets @@ -12,6 +12,7 @@ export class AdaptiveSheetOptions { if (placement.mode === SettingsPlacementMode.FoldOperate) { return { height: placement.height, + keyboardAvoidMode: SheetKeyboardAvoidMode.RESIZE_ONLY, backgroundColor: '#00000000', maskColor: '#44000000', showClose: false, @@ -22,6 +23,7 @@ export class AdaptiveSheetOptions { return { height: placement.height, width: placement.width, + keyboardAvoidMode: SheetKeyboardAvoidMode.RESIZE_ONLY, preferType: SheetType.SIDE, backgroundColor: '#00000000', maskColor: '#44000000', @@ -33,6 +35,7 @@ export class AdaptiveSheetOptions { return { height: placement.height, width: placement.width, + keyboardAvoidMode: SheetKeyboardAvoidMode.RESIZE_ONLY, preferType: SheetType.CENTER, backgroundColor: '#00000000', maskColor: '#44000000', @@ -43,6 +46,7 @@ export class AdaptiveSheetOptions { if (placement.height > 0) { return { height: placement.height, + keyboardAvoidMode: SheetKeyboardAvoidMode.RESIZE_ONLY, backgroundColor: '#00000000', maskColor: '#44000000', showClose: false, @@ -51,6 +55,7 @@ export class AdaptiveSheetOptions { } return { height: SheetSize.LARGE, + keyboardAvoidMode: SheetKeyboardAvoidMode.RESIZE_ONLY, backgroundColor: '#00000000', maskColor: '#44000000', showClose: false, 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 46f38c21c2..371e3c4e4e 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 @@ -83,7 +83,7 @@ export struct AppSidebarSurface { onOpenSession: this.actions.onSidebar.openSession, onCreateInWorkspace: (deviceId: string, path: string) => { this.actions.onSidebar.close(); - this.actions.onRemoteHome.openCreateInWorkspace(path, deviceId); + this.actions.onRemoteHome.createInWorkspace(path, 'code', 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/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index 60ac89ea05..63e5a6eb7a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -28,7 +28,6 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { DeviceDirectoryState } from '../state/DeviceDirectoryState'; import { RemotePageState } from '../state/RemotePageState'; -import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; import { FilePreviewState } from '../state/FilePreviewState'; import { FilePreviewLayout, @@ -53,13 +52,18 @@ function safeFoldStatus(): display.FoldStatus { try { return display.getFoldStatus(); } catch (_err) { return display.FoldStatus.FOLD_STATUS_UNKNOWN; } } +enum WideWorkspacePresentationMode { + Default = 'default', + SidebarCollapsed = 'sidebar_collapsed', + PreviewExpanded = 'preview_expanded' +} + @ComponentV2 export struct AppRootPresentation { @Param shellState: AppShellState = new AppShellState(); @Param navigationStack: NavPathStack = new NavPathStack(); @Param remotePageState: RemotePageState = new RemotePageState(); @Param deviceDirectoryState: DeviceDirectoryState = new DeviceDirectoryState(); - @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); @Param filePreviewState: FilePreviewState = new FilePreviewState(); @Param deviceId: string = ''; @@ -74,9 +78,8 @@ export struct AppRootPresentation { @Local wideDetailContentWidth: number = 0; @Local wideCollapsedDetailContentOffset: number = 0; @Local wideCollapsedDetailContentWidth: number = 0; - @Local wideMasterPaneCollapsed: boolean = false; + @Local wideWorkspacePresentationMode: WideWorkspacePresentationMode = WideWorkspacePresentationMode.Default; @Local wideMasterPaneMotionActive: boolean = false; - @Local restoreCollapsedMasterAfterPreview: boolean = false; @Local showRemoteViewSettings: boolean = false; @Local remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); @Local settingsPlacement: SettingsPlacement = @@ -118,12 +121,8 @@ export struct AppRootPresentation { @Monitor('filePreviewState.visible') onFilePreviewVisibilityChanged(): void { - if (this.filePreviewState.visible && this.wideMasterPaneCollapsed) { - this.restoreCollapsedMasterAfterPreview = true; - this.wideMasterPaneCollapsed = false; - } else if (!this.filePreviewState.visible && this.restoreCollapsedMasterAfterPreview && this.isWideLayout()) { - this.wideMasterPaneCollapsed = true; - this.restoreCollapsedMasterAfterPreview = false; + if (!this.filePreviewState.visible && this.isFilePreviewExpanded()) { + this.wideWorkspacePresentationMode = WideWorkspacePresentationMode.Default; } } @@ -209,7 +208,6 @@ export struct AppRootPresentation { shellState: this.shellState, remotePageState: this.remotePageState, deviceDirectoryState: this.deviceDirectoryState, - remoteCreateState: this.remoteCreateState, generalPageState: this.generalPageState, filePreviewState: this.filePreviewState, remoteSurfaceState: this.remoteSurfaceState, @@ -222,10 +220,15 @@ export struct AppRootPresentation { wideDetailContentWidth: this.wideDetailContentWidth, wideCollapsedDetailContentOffset: this.wideCollapsedDetailContentOffset, wideCollapsedDetailContentWidth: this.wideCollapsedDetailContentWidth, - wideMasterPaneCollapsed: this.wideMasterPaneCollapsed, + wideMasterPaneCollapsed: this.wideWorkspacePresentationMode === + WideWorkspacePresentationMode.SidebarCollapsed, wideMasterPaneMotionActive: this.wideMasterPaneMotionActive, + filePreviewExpanded: this.wideWorkspacePresentationMode === + WideWorkspacePresentationMode.PreviewExpanded, + canToggleFilePreviewExpanded: this.canToggleFilePreviewExpanded(), onCollapseMasterPane: () => this.collapseWideMasterPane(), onRestoreMasterPane: () => this.restoreWideMasterPane(), + onToggleFilePreviewExpanded: () => this.toggleFilePreviewExpanded(), onOpenRemoteViewSettings: () => { this.showRemoteViewSettings = true; } }) } @@ -242,7 +245,6 @@ export struct AppRootPresentation { ConversationRouteSurface({ route, remotePageState: this.remotePageState, - remoteCreateState: this.remoteCreateState, generalPageState: this.generalPageState, filePreviewState: this.filePreviewState, remoteSurfaceState: this.remoteSurfaceState, @@ -294,20 +296,29 @@ export struct AppRootPresentation { } private collapseWideMasterPane(): void { - if (!this.isWideLayout() || this.filePreviewState.visible) { + if (!this.isWideLayout()) { return; } + // Expanded preview borrows the conversation panels while keeping workspace + // navigation visible. Collapsing navigation exits that focus mode first, + // then leaves the regular right-panel preview beside the wider chat. this.enableWideMasterPaneMotion(); this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseInOut }, () => { - this.wideMasterPaneCollapsed = true; + this.wideWorkspacePresentationMode = WideWorkspacePresentationMode.SidebarCollapsed; }); } private restoreWideMasterPane(): void { + const previewLayout = this.filePreviewLayout(); + if (WideLayoutGeometry.restoreMasterClosesPreview(previewLayout)) { + // Focus split temporarily borrows the master pane's space. Restoring the + // master therefore dismisses the preview instead of leaving a restore + // control that cannot change the visible layout. + this.actions.onFilePreview.close(); + } this.enableWideMasterPaneMotion(); this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseInOut }, () => { - this.wideMasterPaneCollapsed = false; - this.restoreCollapsedMasterAfterPreview = false; + this.wideWorkspacePresentationMode = WideWorkspacePresentationMode.Default; }); } @@ -318,6 +329,38 @@ export struct AppRootPresentation { }, 240); } + private toggleFilePreviewExpanded(): void { + if (!this.canToggleFilePreviewExpanded()) { + return; + } + this.wideWorkspacePresentationMode = this.isFilePreviewExpanded() ? + WideWorkspacePresentationMode.Default : WideWorkspacePresentationMode.PreviewExpanded; + } + + private canToggleFilePreviewExpanded(): boolean { + if (!this.filePreviewState.visible || !this.isWideLayout()) { + return false; + } + const defaultLayout = FilePreviewPlacementPolicy.resolveLayout( + true, + true, + this.viewportWidth, + this.verticalCreases, + this.wideMasterPaneWidth, + false + ); + const expandedLayout = FilePreviewPlacementPolicy.resolveLayout( + true, + true, + this.viewportWidth, + this.verticalCreases, + this.wideMasterPaneWidth, + true + ); + return defaultLayout.placement === FilePreviewPlacement.WideTriplePane && + expandedLayout.placement === FilePreviewPlacement.WideMasterPreviewFocus; + } + private filePreviewPlacement(): FilePreviewPlacement { return this.filePreviewLayout().placement; } @@ -328,10 +371,19 @@ export struct AppRootPresentation { this.isWideLayout(), this.viewportWidth, this.verticalCreases, - this.wideMasterPaneWidth + this.wideMasterPaneWidth, + this.isFilePreviewExpanded() ); } + private isWideMasterPaneCollapsed(): boolean { + return this.wideWorkspacePresentationMode === WideWorkspacePresentationMode.SidebarCollapsed; + } + + private isFilePreviewExpanded(): boolean { + return this.wideWorkspacePresentationMode === WideWorkspacePresentationMode.PreviewExpanded; + } + private bindResponsiveQueries(): void { this.unbindResponsiveQueries(); try { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets index 46329e7da1..3efdf954fb 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets @@ -18,16 +18,11 @@ export struct BitFunAccountLoginPage { @Local showAdvanced: boolean = false; build() { - Stack({ alignContent: Alignment.TopStart }) { + Column({ space: 0 }) { + this.NavigationHeader() + Scroll() { Column({ space: 0 }) { - Text(RemoteI18n.t('remote.settings.accountLoginTitle')) - .fontSize(MobileDesignTypography.displayLarge.size) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .width('100%') - .margin({ top: 0 }) - Text(RemoteI18n.t('remote.settings.accountLoginBody')) .fontSize(MobileDesignTypography.bodyLarge.size) .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) @@ -107,31 +102,41 @@ export struct BitFunAccountLoginPage { .width('100%') .margin({ top: 12 }) } - - Button(this.isBusy ? RemoteI18n.t('remote.settings.accountSigningIn') : - RemoteI18n.t('remote.settings.accountSignIn')) - .height(56) - .width('100%') - .fontSize(MobileDesignTypography.labelLarge.size) - .fontWeight(FontWeight.Bold) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .borderRadius(18) - .margin({ top: this.errorText.length > 0 ? 22 : 30 }) - .opacity(this.canSubmit() ? 1 : 0.28) - .enabled(this.canSubmit()) - .onClick(async () => { - await this.submit(); - }) } .width('100%') - .padding({ left: 28, right: 28, top: 24, bottom: 44 }) + .padding({ left: 28, right: 28, top: 24, bottom: 18 }) .alignItems(HorizontalAlign.Start) } .width('100%') - .height('100%') + .layoutWeight(1) .scrollBar(BarState.Off) + Row() { + Button(this.isBusy ? RemoteI18n.t('remote.settings.accountSigningIn') : + RemoteI18n.t('remote.settings.accountSignIn')) + .height(56) + .layoutWeight(1) + .fontSize(MobileDesignTypography.labelLarge.size) + .fontWeight(FontWeight.Bold) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) + .borderRadius(18) + .opacity(this.canSubmit() ? 1 : 0.28) + .enabled(this.canSubmit()) + .onClick(async () => { + await this.submit(); + }) + } + .width('100%') + .padding({ left: 28, right: 28, top: 12, bottom: 28 }) + } + .width('100%') + .height('100%') + } + + @Builder + private NavigationHeader() { + Row() { Button() { TemplateIcon({ src: $r('app.media.remote_ref_back'), @@ -144,14 +149,22 @@ export struct BitFunAccountLoginPage { .padding(0) .type(ButtonType.Circle) .backgroundColor('#00000000') - .position({ x: 28, y: 22 }) .accessibilityText(RemoteI18n.t('common.back')) .onClick(() => { this.onBack(); }) + + Text(RemoteI18n.t('remote.settings.accountLoginTitle')) + .fontSize(MobileDesignTypography.headlineMedium.size) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .layoutWeight(1) + .margin({ left: 10 }) } .width('100%') - .height('100%') + .height(72) + .padding({ left: 18, right: 18 }) + .alignItems(VerticalAlign.Center) } private canSubmit(): boolean { 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 6c62bc2645..94c74d418f 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 @@ -166,10 +166,43 @@ export struct ChatMessageBubble { ) } if (this.shouldRenderStructuredItems(this.item)) { - this.StructuredItems(this.item.items || [], this.shouldOmitStructuredThinking(this.item)) + // Read the observed message here. Passing its plain items array through + // an @Builder parameter freezes the first tool-only snapshot on current + // ArkUI builds, even though the retained message row itself keeps + // receiving revisions. + ForEach([ + this.structuredRenderKey(this.structuredProcessItems(this.item.items || [])) + ], (_frameKey: string) => { + Column({ space: 10 }) { + ForEach( + this.structuredGroups(this.structuredProcessItems(this.item.items || []), 'item'), + (group: StructuredRenderGroup) => { + this.StructuredGroup(group, this.shouldOmitStructuredThinking(this.item)) + }, + (group: StructuredRenderGroup) => group.key + ) + } + .width('100%') + }, (frameKey: string) => frameKey) if (this.uncoveredTools(this.item).length > 0) { this.Tools(this.uncoveredTools(this.item)) } + if (this.trailingStructuredText(this.item).length > 0) { + // Keep the growing answer outside the keyed structured ForEach. The + // latter intentionally rebuilds entries whose content hash changes, + // which turns a streamed answer into a sequence of static snapshots. + MessageBodyMarkdown({ + text: this.trailingStructuredText(this.item), + renderRevision: this.renderRevision, + active: this.isStreamingAssistant(), + streamKey: `${this.item.id}-trailing-body`, + onCopyText: this.onCopyMessage, + onOpenLink: this.onOpenFilePreview + }) + if (this.fileReferences(this.trailingStructuredText(this.item)).length > 0) { + this.FileCards(this.trailingStructuredText(this.item)) + } + } } else { if (this.item.tools && this.item.tools.length > 0) { this.Tools(this.item.tools) @@ -202,16 +235,6 @@ export struct ChatMessageBubble { } } - @Builder - StructuredItems(items: ConversationUiMessageItem[], omitActiveThinking: boolean = false) { - Column({ space: 10 }) { - ForEach(this.structuredGroups(items, 'item'), (group: StructuredRenderGroup) => { - this.StructuredGroup(group, omitActiveThinking) - }, (group: StructuredRenderGroup) => group.key) - } - .width('100%') - } - @Builder StructuredGroup(group: StructuredRenderGroup, omitActiveThinking: boolean = false) { if (group.type === 'activity_group') { @@ -306,17 +329,17 @@ export struct ChatMessageBubble { } @Builder - SubagentGroup(entry: ConversationUiMessageItem, path: string, activeScope: boolean = false) { + SubagentGroup(entry: ConversationUiMessageItem, _path: string, activeScope: boolean = false) { SubagentTaskCard({ title: this.subagentTitle(entry), - running: !!(entry.tool && this.isRunningTool(entry.tool)), - content: this.subagentBody(entry), - hasChildren: !!(entry.subItems && entry.subItems.length > 0), - childItems: () => { - if (entry.subItems && entry.subItems.length > 0) { - this.ChildItems(entry.subItems, path, activeScope) - } - } + status: entry.tool?.status || (activeScope ? 'running' : 'completed'), + items: entry.subItems || [], + renderRevision: this.renderRevision, + onApproveTool: this.onApproveTool, + onRejectTool: this.onRejectTool, + onCancelTool: this.onCancelTool, + onAnswerQuestion: this.onAnswerQuestion, + onOpenFilePreview: this.onOpenFilePreview }) } @@ -348,6 +371,7 @@ export struct ChatMessageBubble { ToolStatusList({ tools, thinkingParts, + renderRevision: this.renderRevision, isBusy: this.isBusy, onApproveTool: (toolId: string, updatedInput?: Object) => { this.onApproveTool(toolId, updatedInput); @@ -431,6 +455,14 @@ export struct ChatMessageBubble { return this.toolsNotCoveredByItems(item.tools || [], item.items || []); } + private structuredProcessItems(items: ConversationUiMessageItem[]): ConversationUiMessageItem[] { + return ChatMessageStructurePolicy.withoutTrailingTopLevelText(items); + } + + private trailingStructuredText(item: ConversationUiMessage): string { + return ChatMessageStructurePolicy.trailingTopLevelText(item.items || []); + } + private shouldPinThinkingToBottom(item: ConversationUiMessage): boolean { return AssistantProcessLayoutPolicy.pinsThinkingAfterTools() && this.isStreamingAssistant() && @@ -481,13 +513,6 @@ export struct ChatMessageBubble { }); } - private subagentBody(entry: ConversationUiMessageItem): string { - if (!entry.content || this.isTextEntry(entry) || this.isThinkingEntry(entry)) { - return ''; - } - return entry.content; - } - private shouldOmitStructuredThinking(_item: ConversationUiMessage): boolean { return AssistantProcessLayoutPolicy.omitsInFlowThinkingWhileStreaming(); } @@ -584,6 +609,15 @@ export struct ChatMessageBubble { ); } + private structuredRenderKey(items: ConversationUiMessageItem[]): string { + return ChatMessageStructurePolicy.structuredRenderKey( + items, + 'item', + this.isStreamingAssistant(), + this.item.status || 'active' + ); + } + private activityGroupTools(group: StructuredRenderGroup): ConversationUiToolStatus[] { return ChatMessageStructurePolicy.activityGroupTools(group); } @@ -636,7 +670,8 @@ export struct ChatMessageBubble { } private subagentTitle(entry: ConversationUiMessageItem): string { - return ChatMessageStructurePolicy.subagentTitle(entry); + const title = ChatMessageStructurePolicy.subagentTitle(entry); + return title.length > 0 ? title : RemoteI18n.t('chat.toolTaskFallback'); } private isRunningTool(tool: ConversationUiToolStatus): boolean { 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 a839ea3b45..6131a8311e 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 @@ -33,6 +33,7 @@ struct ChatAssistantTimelineRow { isStreaming: false, isFinalizing: false }); + @Param renderRevision: number = 0; @Param isBusy: boolean = false; @Param downloadingFilePath: string = ''; @Param downloadedFilePath: string = ''; @@ -55,7 +56,7 @@ struct ChatAssistantTimelineRow { this.synchronizeMessage(); } - @Monitor('row.message') + @Monitor('row.message', 'renderRevision') private onMessageChanged(): void { this.synchronizeMessage(); } @@ -64,6 +65,7 @@ struct ChatAssistantTimelineRow { if (this.row.message) { ChatMessageBubble({ item: this.uiMessage, + renderRevision: this.renderRevision, isStreaming: this.row.isStreaming, isFinalizing: this.row.isFinalizing, showRetryAction: this.row.showRetryAction, @@ -243,12 +245,43 @@ export struct ChatTimeline { }) .key((item: ObservableChatTimelineItem, _index: number) => ChatTimelineRevisionTracker.itemKey(item)) - // Keep the single volatile row outside Repeat.virtualScroll(). A stable - // key preserves its StreamingMarkdownContent timer and rendered prefix; - // replacement @Param values then update that live component in place. + // Keep the single volatile row outside Repeat.virtualScroll(). Feed it + // through the same observed adapter as history rows: passing a mapped + // temporary object through an @Builder freezes the first empty active + // snapshot on current ArkUI builds. ForEach(this.activeTimelineItems(), (item: ObservableChatTimelineItem) => { - this.ActiveTimelineItem(item) + if (item.message && this.shouldRenderTimelineMessage(item)) { + ListItem() { + ChatAssistantTimelineRow({ + row: item, + renderRevision: this.timelineRevision, + 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 + }) + } + .onAppear(() => { + this.logRenderedItem(item); + }) + .onAreaChange((oldArea: Area, newArea: Area) => { + if (Math.abs(this.areaLength(newArea.height) - this.areaLength(oldArea.height)) > 0.5) { + this.requestFollowToBottom('active-height'); + } + }) + } }, (item: ObservableChatTimelineItem) => ChatTimelineRevisionTracker.itemKey(item)) + } .width('100%') .height('100%') @@ -334,43 +367,6 @@ export struct ChatTimeline { .border({ width: 1, color: LINE }) } - @Builder - ActiveTimelineItem(item: ObservableChatTimelineItem) { - if (item.message && this.shouldRenderTimelineMessage(item)) { - ListItem() { - ChatMessageBubble({ - item: toConversationUiMessage(item.message), - renderRevision: this.timelineRevision, - isStreaming: item.isStreaming, - isFinalizing: item.isFinalizing, - showRetryAction: item.showRetryAction === true, - 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 - }) - } - .onAppear(() => { - this.logRenderedItem(item); - }) - .onAreaChange((oldArea: Area, newArea: Area) => { - if (Math.abs(this.areaLength(newArea.height) - this.areaLength(oldArea.height)) > 0.5) { - this.requestFollowToBottom('active-height'); - } - }) - } - } - private historyTimelineItems(): ObservableChatTimelineItem[] { return this.timelineItems.filter((item: ObservableChatTimelineItem) => !this.isVolatileItem(item)); } 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 aa1b95c943..ba91f9fc9e 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 @@ -13,8 +13,7 @@ import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, RED, SOFT } from './T export enum ComposerPresentation { Compact = 'compact', - Floating = 'floating', - Create = 'create' + Floating = 'floating' } const COMPOSER_ACTION_SIZE: number = MobileDesignGeometry.composerActionSize; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets index 076bb15c79..ca77442803 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets @@ -6,12 +6,9 @@ import { import { AppRoute } from '../navigation/AppRouteContract'; import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; -import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; import { RemotePageState } from '../state/RemotePageState'; import { ComposerPresentation } from './ComposerBar'; import { ConversationViewHost } from './ConversationViewHost'; -import { toConversationUiModelCatalog } from '../state/ConversationUiModels'; -import { RemoteCreateSessionView } from './RemoteCreateSessionView'; import { RemoteSurfaceHost, RemoteSurfaceMode, @@ -24,7 +21,6 @@ import { PAGE_BG } from './Theme'; export struct ConversationRouteSurface { @Param route: AppRoute = AppRoute.ChatHome; @Param remotePageState: RemotePageState = new RemotePageState(); - @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); @Param filePreviewState: FilePreviewState = new FilePreviewState(); @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); @@ -46,35 +42,13 @@ export struct ConversationRouteSurface { actions: this.actions, onOpenSidebar: this.actions.onRemoteHome.openSidebar }) - } else if (this.route === AppRoute.RemoteCreate) { - RemoteCreateSessionView({ - state: this.remoteCreateState, - presentation: this.useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Create, - isVoiceListening: this.remoteCreateState.isVoiceListening, - modelCatalog: toConversationUiModelCatalog(this.remotePageState.conversation.modelCatalog), - selectedModelId: this.remoteCreateState.selectedModelId, - showSidebarRestoreButton: this.showSidebarRestoreButton, - hoverOperateMode: this.hoverOperateMode, - onRestoreSidebar: this.onRestoreSidebar, - onBack: this.actions.onRemoteCreate.back, - onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, - onToggleWorkspaceMenu: this.actions.onRemoteCreate.toggleWorkspaces, - onSelectDevice: this.actions.onRemoteCreate.selectDevice, - onSelectWorkspace: (workspace) => this.actions.onRemoteCreate.selectWorkspace(workspace?.path || ''), - onDraftChange: this.actions.onRemoteCreate.draftChanged, - onVoiceInput: this.actions.onRemoteCreate.voiceInput, - onSelectModel: this.actions.onRemoteCreate.selectModel, - onSelectHarness: this.actions.onRemoteCreate.selectHarness, - onSend: this.actions.onRemoteCreate.send - }) } else { ConversationViewHost({ viewState: ConversationViewState.project( this.route, this.remotePageState, this.generalPageState, - this.actions.generalStatus(), - this.remoteCreateState + this.actions.generalStatus() ), activeFilePreviewPath: this.route === AppRoute.RemoteChat && this.filePreviewState.visible ? this.filePreviewState.target.remotePath : '', @@ -83,10 +57,11 @@ export struct ConversationRouteSurface { showSidebarButton: this.showSidebarButton, showBackButton: this.showBackButton, showSidebarRestoreButton: this.showSidebarRestoreButton, - composerPresentation: this.useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Compact, + composerPresentation: this.composerPresentation(), + composerInputId: 'conversation-composer-input', hoverOperateMode: this.hoverOperateMode, onRestoreSidebar: this.onRestoreSidebar, - onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(this.route, intent) + onIntent: (intent: ConversationIntent) => this.dispatchConversationIntent(intent) }) } } @@ -94,4 +69,15 @@ export struct ConversationRouteSurface { .height('100%') .backgroundColor(PAGE_BG) } + + private composerPresentation(): ComposerPresentation { + if (this.useWidePresentation) { + return ComposerPresentation.Floating; + } + return ComposerPresentation.Compact; + } + + private dispatchConversationIntent(intent: ConversationIntent): void { + this.actions.onConversationIntent(this.route, intent); + } } 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 9296894046..0363c9bee9 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 @@ -1,14 +1,11 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { KeyboardAvoidMode } from '@kit.ArkUI'; -import { RecentWorkspaceEntry } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ChatTimelineItem, ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; import { RemoteLogger } from '../../services/RemoteLogger'; import { ChatComposerCapabilities } from '../state/ChatComposerCapabilities'; import { ChatSurface } from '../state/ChatSurface'; -import { RemoteCreateSessionState, RemoteHarnessProfile } from '../state/RemoteCreateSessionState'; import { ConversationViewState } from '../state/ConversationViewState'; import { ConversationIntent, ConversationIntents, ConversationIntentType } from '../actions/ConversationIntent'; import { ChatStatusBar } from './ChatStatusBar'; @@ -16,8 +13,7 @@ import { ChatTimeline } from './ChatTimeline'; import { ConversationLoadingState } from './ConversationLoadingState'; import { ConversationViewContract, - ConversationViewOptions, - RemoteCreateViewActions + ConversationViewOptions } from './ConversationViewContract'; import { ConversationUiModelCatalog, @@ -29,14 +25,11 @@ import { ComposerBar, ComposerPresentation } from './ComposerBar'; import { ConversationHeader } from './ConversationHeader'; import { ConversationHeaderPolicy, ConversationHeaderPresentation } from '../policy/ConversationHeaderPolicy'; import { FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; -import { RemoteCreateContextControls } from './RemoteCreateContextControls'; @ComponentV2 export struct ConversationView { @Param state: ConversationViewState = new ConversationViewState(); @Param options: ConversationViewOptions = new ConversationViewOptions(); - @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); - @Param createActions: RemoteCreateViewActions = new RemoteCreateViewActions(); @Event onIntent: (intent: ConversationIntent) => void = (_intent: ConversationIntent) => {}; @Event onRestoreSidebar: () => void = () => {}; @Local showHeaderActions: boolean = false; @@ -106,14 +99,7 @@ export struct ConversationView { if (this.shouldShowStatusBar()) { this.ExecutionStatusBar() } - if (this.surface === ChatSurface.Create) { - Blank() - .layoutWeight(1) - .width('100%') - .onClick(() => { - this.onBackgroundTap(); - }) - } else if (this.shouldCenterInlineStatus()) { + if (this.shouldCenterInlineStatus()) { this.CenteredInlineStatus() } else { // Keep the list subtree alive while a slow transcript is loading. The @@ -259,17 +245,6 @@ export struct ConversationView { @Builder Composer() { Column() { - if (this.showRemoteCreateContext) { - RemoteCreateContextControls({ - state: this.remoteCreateState, - presentation: this.composerPresentation, - onToggleDeviceMenu: () => this.onToggleCreateDeviceMenu(), - onToggleWorkspaceMenu: () => this.onToggleCreateWorkspaceMenu(), - onSelectDevice: (device: CloudAccountDevice) => this.onSelectCreateDevice(device), - onSelectWorkspace: (workspace?: RecentWorkspaceEntry) => this.onSelectCreateWorkspace(workspace), - onSelectHarness: (profile: RemoteHarnessProfile) => this.onSelectCreateHarness(profile) - }) - } ComposerBar({ presentation: this.composerPresentation, capabilities: this.composerCapabilities, @@ -415,7 +390,7 @@ export struct ConversationView { if (this.connectionState === 'reconnecting') { return RemoteI18n.t('chat.emptyReconnecting'); } - return RemoteI18n.t('status.messagesSynced'); + return ConnectionStatusPresenter.label(this.connectionState); } private taskSummaryDetail(): string { @@ -426,7 +401,10 @@ export struct ConversationView { private shouldShowStatusBar(): boolean { return this.surface === ChatSurface.Remote && !this.isLoadingConversation && - this.connectionState !== 'connected'; + this.connectionState !== 'connected' && + (this.activeSession.sessionId.length > 0 || + this.connectionState === 'failed' || + this.connectionState === 'reconnecting'); } private headerTitle(): string { @@ -501,16 +479,6 @@ export struct ConversationView { private get composerPresentation(): ComposerPresentation { return this.options.composerPresentation; } private get composerInputId(): string { return this.options.composerInputId; } private get hoverOperateMode(): boolean { return this.options.hoverOperateMode; } - private get showRemoteCreateContext(): boolean { return this.options.showRemoteCreateContext; } - - private onToggleCreateDeviceMenu(): void { this.createActions.toggleDeviceMenu(); } - private onToggleCreateWorkspaceMenu(): void { this.createActions.toggleWorkspaceMenu(); } - private onSelectCreateDevice(device: CloudAccountDevice): void { this.createActions.selectDevice(device); } - private onSelectCreateWorkspace(workspace?: RecentWorkspaceEntry): void { - this.createActions.selectWorkspace(workspace); - } - private onSelectCreateHarness(profile: RemoteHarnessProfile): void { this.createActions.selectHarness(profile); } - private onBackgroundTap(): void { this.createActions.backgroundTap(); } private onOpenSidebar(): void { this.dispatchSimple(ConversationIntentType.OpenSidebar); } private onBack(): void { this.dispatchSimple(ConversationIntentType.Back); } private onTogglePinSession(): void { this.dispatchSimple(ConversationIntentType.TogglePin); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets index 86a8bf68cc..5fe3898692 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets @@ -1,8 +1,5 @@ import { ChatTimelineItem } from '../../model/ChatTimelineModels'; -import { RecentWorkspaceEntry } from '../../model/RemoteModels'; -import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ChatSurface } from '../state/ChatSurface'; -import { RemoteHarnessProfile } from '../state/RemoteCreateSessionState'; import { ComposerPresentation } from './ComposerBar'; /** Layout and host chrome that vary independently from conversation data. */ @@ -15,17 +12,6 @@ export class ConversationViewOptions { composerPresentation: ComposerPresentation = ComposerPresentation.Compact; composerInputId: string = 'conversation-composer-input'; hoverOperateMode: boolean = false; - showRemoteCreateContext: boolean = false; -} - -/** Actions that only exist on the remote-session creation surface. */ -export class RemoteCreateViewActions { - toggleDeviceMenu: () => void = () => {}; - toggleWorkspaceMenu: () => void = () => {}; - selectDevice: (device: CloudAccountDevice) => void = (_device: CloudAccountDevice) => {}; - selectWorkspace: (workspace?: RecentWorkspaceEntry) => void = (_workspace?: RecentWorkspaceEntry) => {}; - selectHarness: (profile: RemoteHarnessProfile) => void = (_profile: RemoteHarnessProfile) => {}; - backgroundTap: () => void = () => {}; } export class ConversationViewContract { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets index 81c59e5dd2..a4d46a0665 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets @@ -13,6 +13,7 @@ export struct ConversationViewHost { @Param showBackButton: boolean = true; @Param showSidebarRestoreButton: boolean = false; @Param composerPresentation: ComposerPresentation = ComposerPresentation.Compact; + @Param composerInputId: string = 'conversation-composer-input'; @Param hoverOperateMode: boolean = false; @Param onRestoreSidebar: () => void = () => {}; @Param onIntent: (intent: ConversationIntent) => void = (_intent: ConversationIntent) => {}; @@ -34,6 +35,7 @@ export struct ConversationViewHost { options.showBackButton = this.showBackButton; options.showSidebarRestoreButton = this.showSidebarRestoreButton; options.composerPresentation = this.composerPresentation; + options.composerInputId = this.composerInputId; options.hoverOperateMode = this.hoverOperateMode; return options; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets index d34684e37f..3eba8e2968 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets @@ -363,7 +363,7 @@ export struct ConversationViewSettings { private agentFilterLabel(value: string): string { if (value === 'chat') { - return RemoteI18n.t('remote.create.chat'); + return RemoteI18n.t('remote.newChat'); } return value === 'cowork' ? 'Cowork' : 'Code'; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets index ae91867bf8..8bf978dbc8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets @@ -44,10 +44,13 @@ export struct FilePreviewSurface { @Param downloadPath: string = ''; @Param downloadedPath: string = ''; @Param downloadStatus: string = ''; + @Param previewExpanded: boolean = false; + @Param canToggleExpanded: boolean = false; @Event onClose: () => void = () => {}; @Event onRefresh: () => void = () => {}; @Event onDownload: (path: string) => void = (_path: string) => {}; @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; + @Event onToggleExpanded: () => void = () => {}; aboutToDisappear(): void { this.clearRestoreScrollTimer(); @@ -112,6 +115,24 @@ export struct FilePreviewSurface { }) } + if (this.canToggleExpanded) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph(this.previewExpanded ? + $r('sys.symbol.arrow_down_right_and_arrow_up_left') : + $r('sys.symbol.arrow_up_left_and_arrow_down_right')) + .fontSize(20) + .fontColor([INK]) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t( + this.previewExpanded ? 'filePreview.restoreSize' : 'filePreview.expand' + )) + .onClick(() => { + this.onToggleExpanded(); + }) + } + Stack({ alignContent: Alignment.Center }) { SymbolGlyph($r('sys.symbol.arrow_clockwise')) .fontSize(20) @@ -317,12 +338,12 @@ export struct FilePreviewSurface { }) }, (token: CodeSyntaxToken) => token.id) } - .fontSize(MobileDesignTypography.labelSmall.size) - .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) .fontColor(INK) .fontFamily('monospace') .textSelectable(TextSelectableMode.SELECTABLE_UNFOCUSABLE) - .padding({ left: 14, right: 20, top: 14, bottom: 24 }) + .padding({ left: 12, right: 12, top: 14, bottom: 24 }) .constraintSize({ minWidth: '100%' }) } .scrollable(ScrollDirection.FREE) @@ -351,7 +372,8 @@ export struct FilePreviewSurface { this.onOpenLink(reference, label); } }) - .padding({ left: 20, right: 20, top: 16, bottom: 28 }) + .width('100%') + .padding({ left: 16, right: 16, top: 16, bottom: 28 }) } .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Auto) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets index 44af809a46..b45f19eb17 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets @@ -450,10 +450,7 @@ export struct RemoteControlSettingsSheet { connectionState: this.connectionState, showNavigationHeader: true, onBack: () => this.leaveAccountPage(), - onLoggedOut: () => { - this.showProfile = false; - this.showLogin = true; - }, + onLoggedOut: () => this.leaveAccountPage(), onReconnect: this.onReconnect, cloudLogout: this.cloudLogout, cloudListDevices: this.cloudListDevices, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateContextControls.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateContextControls.ets deleted file mode 100644 index ba152f143c..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateContextControls.ets +++ /dev/null @@ -1,354 +0,0 @@ -import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; -import { RecentWorkspaceEntry } from '../../model/RemoteModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CloudAccountDevice } from '../../services/CloudAccountClient'; -import { RemoteCreateSessionState, RemoteHarnessProfile } from '../state/RemoteCreateSessionState'; -import { CARD, INK, LINE, MUTED, SOFT } from './Theme'; -import { ComposerPresentation } from './ComposerBar'; - -/** - * Remote-session creation context rendered immediately above the composer. - * - * Keep this as a real component instead of injecting a parent-owned builder - * into ConversationView. ArkUI can otherwise compile the slot successfully - * while dropping its nodes on device because the V2 render owner differs. - */ -@ComponentV2 -export struct RemoteCreateContextControls { - @Param state: RemoteCreateSessionState = new RemoteCreateSessionState(); - @Param presentation: ComposerPresentation = ComposerPresentation.Create; - @Event onToggleDeviceMenu: () => void = () => {}; - @Event onToggleWorkspaceMenu: () => void = () => {}; - @Event onSelectDevice: (device: CloudAccountDevice) => void = (_device: CloudAccountDevice) => {}; - @Event onSelectWorkspace: (workspace?: RecentWorkspaceEntry) => void = - (_workspace?: RecentWorkspaceEntry) => {}; - @Event onSelectHarness: (profile: RemoteHarnessProfile) => void = (_profile: RemoteHarnessProfile) => {}; - @Local showSelectorSheet: boolean = false; - - build() { - Column({ space: 2 }) { - if (this.presentation === ComposerPresentation.Floating) { - this.ContextRow('device', this.state.isLoadingDevices, () => this.onToggleDeviceMenu()) - } - this.ContextRow('workspace', this.state.isLoadingWorkspaces, () => this.onToggleWorkspaceMenu()) - if (this.state.supportsHarnessProfiles && this.state.selectedWorkspacePath.length > 0) { - this.HarnessSelector() - } - } - .width('100%') - .padding({ - left: this.presentation === ComposerPresentation.Floating ? 10 : 28, - right: this.presentation === ComposerPresentation.Floating ? 10 : 28, - top: this.presentation === ComposerPresentation.Floating ? 8 : 0, - bottom: 4 - }) - .bindSheet($$this.showSelectorSheet, this.SelectorSheet(), this.selectorSheetOptions()) - } - - @Builder - private HarnessSelector() { - Row({ space: 3 }) { - this.HarnessOption('minimal', 1) - this.HarnessOption('balanced', 2) - this.HarnessOption('ultimate', 3) - } - .width('100%') - .height(42) - .padding(3) - .borderRadius(12) - .backgroundColor(SOFT) - } - - @Builder - private HarnessOption(profile: RemoteHarnessProfile, density: number) { - Row({ space: 7 }) { - this.HarnessDensityMark(density, profile === this.state.selectedHarnessProfile) - Text(RemoteI18n.t(`remote.create.harness.${profile}`)) - .fontSize(MobileDesignTypography.bodyMedium.size) - .fontWeight(profile === this.state.selectedHarnessProfile ? FontWeight.Medium : FontWeight.Regular) - .fontColor(profile === this.state.selectedHarnessProfile ? INK : MUTED) - } - .layoutWeight(1) - .height(36) - .justifyContent(FlexAlign.Center) - .borderRadius(9) - .backgroundColor(profile === this.state.selectedHarnessProfile ? CARD : SOFT) - .accessibilityText(RemoteI18n.f( - 'remote.create.harnessAccessibility', - RemoteI18n.t(`remote.create.harness.${profile}`) - )) - .onClick(() => { - this.onSelectHarness(profile); - this.keepComposerFocused(); - }) - } - - @Builder - private HarnessDensityMark(density: number, selected: boolean) { - Row({ space: 2 }) { - Stack().width(3).height(8).borderRadius(2).backgroundColor(selected ? INK : MUTED) - if (density >= 2) { - Stack().width(3).height(12).borderRadius(2).backgroundColor(selected ? INK : MUTED) - } - if (density >= 3) { - Stack().width(3).height(16).borderRadius(2).backgroundColor(selected ? INK : MUTED) - } - } - .width(17) - .height(18) - .alignItems(VerticalAlign.Bottom) - .justifyContent(FlexAlign.Center) - } - - @Builder - private ContextRow(kind: string, loading: boolean, onClick: () => void) { - Row({ space: 13 }) { - this.ContextGlyph(kind, loading) - Text(loading ? RemoteI18n.t('common.loading') : - (kind === 'device' ? - (this.state.selectedDeviceName || RemoteI18n.t('remote.create.noDevice')) : - (this.state.selectedWorkspaceName || RemoteI18n.t('remote.create.chat')))) - .constraintSize({ maxWidth: '74%' }) - .fontSize(MobileDesignTypography.bodyLarge.size) - .fontWeight(FontWeight.Medium) - .fontColor(loading ? MUTED : INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - SymbolGlyph(this.state.openMenu === (kind === 'device' ? 'devices' : 'workspaces') ? - $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) - .fontSize(13) - .fontColor([MUTED]) - .width(22) - .height(30) - .opacity(loading ? 0.42 : 1) - Blank().layoutWeight(1) - } - .width('100%') - .height(48) - .padding({ left: 8, right: 10 }) - .borderRadius(12) - .bindPopup(this.state.openMenu === (kind === 'device' ? 'devices' : 'workspaces'), { - builder: () => { - if (kind === 'device') { - this.DeviceMenu(false) - } else { - this.WorkspaceMenu(false) - } - }, - placement: Placement.Top, - popupColor: '#00000000', - enableArrow: false, - autoCancel: true, - mask: false, - targetSpace: 8, - onStateChange: (event) => { - if (!event.isVisible) { - this.state.closeMenu(); - } - } - }) - .onClick(() => { - onClick(); - this.keepComposerFocused(); - }) - } - - @Builder - private ContextGlyph(kind: string, loading: boolean) { - if (kind === 'device') { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(22).fontColor([MUTED]).width(26).height(26).opacity(loading ? 0.42 : 1) - } else if (this.state.selectedWorkspacePath.length > 0) { - SymbolGlyph($r('sys.symbol.folder')) - .fontSize(22).fontColor([MUTED]).width(26).height(26).opacity(loading ? 0.42 : 1) - } else { - SymbolGlyph($r('sys.symbol.message')) - .fontSize(22).fontColor([MUTED]).width(26).height(26).opacity(loading ? 0.42 : 1) - } - } - - @Builder - private DeviceMenu(asSheet: boolean) { - Column() { - if (this.state.devices.length === 0 && this.state.isLoadingDevices) { - this.MenuMessage(RemoteI18n.t('common.loading')) - } else if (this.state.devices.length === 0) { - this.MenuMessage(RemoteI18n.t('remote.create.noOnlineDevice')) - } else { - ForEach(this.state.devices, (device: CloudAccountDevice) => { - this.DeviceMenuRow(device) - }, (device: CloudAccountDevice) => device.deviceId) - } - } - .width(asSheet ? '100%' : (this.presentation === ComposerPresentation.Floating ? 360 : 340)) - .padding({ top: 8, bottom: 8 }) - .backgroundColor(CARD) - .borderRadius(asSheet ? 0 : 16) - .border({ width: 1, color: LINE }) - .shadow({ radius: asSheet ? 0 : 20, color: asSheet ? '#00000000' : '#1A000000', offsetY: 8 }) - } - - @Builder - private DeviceMenuRow(device: CloudAccountDevice) { - Row({ space: 12 }) { - this.SelectionMark(device.deviceId === this.state.selectedDeviceId) - SymbolGlyph($r('sys.symbol.desktop')).fontSize(22).fontColor([MUTED]).width(27).height(27) - Text(device.deviceName) - .layoutWeight(1) - .fontSize(MobileDesignTypography.bodyLarge.size) - .fontColor(INK) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - .height(58) - .padding({ left: 16, right: 18, top: 8, bottom: 8 }) - .onClick(() => { - this.showSelectorSheet = false; - this.onSelectDevice(device); - this.keepComposerFocused(); - }) - } - - @Builder - private WorkspaceMenu(asSheet: boolean) { - Column() { - Row({ space: 12 }) { - this.SelectionMark(this.state.selectedWorkspacePath.length === 0) - SymbolGlyph($r('sys.symbol.message')).fontSize(22).fontColor([MUTED]).width(27).height(27) - Text(RemoteI18n.t('remote.create.chat')).layoutWeight(1).fontSize(MobileDesignTypography.bodyLarge.size).fontColor(INK) - } - .width('100%') - .height(58) - .padding({ left: 16, right: 18 }) - .onClick(() => { - this.showSelectorSheet = false; - this.onSelectWorkspace(undefined); - this.keepComposerFocused(); - }) - - Divider().color(LINE).margin({ left: 58, right: 18 }) - - if (this.state.workspaces.length === 0 && this.state.isLoadingWorkspaces) { - this.MenuMessage(RemoteI18n.t('common.loading')) - } else if (this.state.workspaces.length === 0) { - this.MenuMessage(RemoteI18n.t('home.noRecentWorkspaces')) - } else { - Scroll() { - Column() { - ForEach(this.state.workspaces, (workspace: RecentWorkspaceEntry) => { - this.WorkspaceMenuRow(workspace) - }, (workspace: RecentWorkspaceEntry) => workspace.path) - } - } - .width('100%') - .constraintSize({ maxHeight: this.presentation === ComposerPresentation.Floating ? 190 : 92 }) - .scrollBar(BarState.Off) - } - } - .width(asSheet ? '100%' : (this.presentation === ComposerPresentation.Floating ? 360 : 340)) - .padding({ top: 8, bottom: 8 }) - .backgroundColor(CARD) - .borderRadius(asSheet ? 0 : 16) - .border({ width: 1, color: LINE }) - .shadow({ radius: asSheet ? 0 : 20, color: asSheet ? '#00000000' : '#1A000000', offsetY: 8 }) - } - - @Builder - private WorkspaceMenuRow(workspace: RecentWorkspaceEntry) { - Row({ space: 12 }) { - this.SelectionMark(workspace.path === this.state.selectedWorkspacePath) - SymbolGlyph($r('sys.symbol.folder')).fontSize(22).fontColor([MUTED]).width(27).height(27) - Column({ space: 2 }) { - Text(workspace.name) - .width('100%').fontSize(MobileDesignTypography.bodyLarge.size).fontColor(INK).maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(workspace.path) - .width('100%').fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED).maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .height(64) - .padding({ left: 16, right: 18 }) - .onClick(() => { - this.showSelectorSheet = false; - this.onSelectWorkspace(workspace); - this.keepComposerFocused(); - }) - } - - @Builder - private SelectionMark(selected: boolean) { - Stack({ alignContent: Alignment.Center }) { - if (selected) { - SymbolGlyph($r('sys.symbol.checkmark_circle')).fontSize(19).fontColor([INK]) - } - } - .width(20) - .height(24) - } - - @Builder - private MenuMessage(message: string) { - Text(message) - .width('100%') - .padding({ left: 20, right: 20, top: 18, bottom: 18 }) - .fontSize(MobileDesignTypography.bodySmall.size) - .fontColor(MUTED) - } - - @Builder - private SelectorSheet() { - Column() { - Row() { - Text(this.state.openMenu === 'devices' ? RemoteI18n.t('remote.device') : RemoteI18n.t('remote.workspace')) - .layoutWeight(1).fontSize(MobileDesignTypography.bodyLarge.size).fontWeight(FontWeight.Medium).fontColor(INK) - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.xmark')).fontSize(16).fontColor([MUTED]) - } - .width(44) - .height(44) - .accessibilityText(RemoteI18n.t('common.close')) - .onClick(() => this.closeSelectorSheet()) - } - .width('100%') - .height(52) - .padding({ left: 18, right: 8 }) - if (this.state.openMenu === 'devices') { - this.DeviceMenu(true) - } else { - this.WorkspaceMenu(true) - } - } - .width('100%') - .padding({ left: 12, right: 12, bottom: 16 }) - .backgroundColor(CARD) - .borderRadius(16) - } - - private keepComposerFocused(): void { - setTimeout(() => focusControl.requestFocus('remote-create-composer'), 30); - } - - private closeSelectorSheet(): void { - this.showSelectorSheet = false; - this.state.closeMenu(); - this.keepComposerFocused(); - } - - private selectorSheetOptions(): SheetOptions { - return { - height: this.state.openMenu === 'devices' ? 420 : 440, - backgroundColor: '#00000000', - maskColor: '#44000000', - showClose: false, - dragBar: true, - onWillDismiss: () => { - this.state.closeMenu(); - } - }; - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets deleted file mode 100644 index a41aeafeb3..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets +++ /dev/null @@ -1,117 +0,0 @@ -import { RecentWorkspaceEntry } from '../../model/RemoteModels'; -import { CloudAccountDevice } from '../../services/CloudAccountClient'; -import { RemoteCreateSessionState, RemoteHarnessProfile } from '../state/RemoteCreateSessionState'; -import { ConversationViewState } from '../state/ConversationViewState'; -import { ConversationUiModelCatalog } from '../state/ConversationUiModels'; -import { REMOTE_CREATE_COMPOSER_CAPABILITIES } from '../state/ChatComposerCapabilities'; -import { ChatSurface } from '../state/ChatSurface'; -import { ConversationIntent, ConversationIntentType } from '../actions/ConversationIntent'; -import { ComposerPresentation } from './ComposerBar'; -import { ConversationView } from './ConversationView'; -import { ConversationViewOptions, RemoteCreateViewActions } from './ConversationViewContract'; -import { PAGE_BG } from './Theme'; - -@ComponentV2 -export struct RemoteCreateSessionView { - @Param state: RemoteCreateSessionState = new RemoteCreateSessionState(); - @Param presentation: ComposerPresentation = ComposerPresentation.Create; - @Param showSidebarRestoreButton: boolean = false; - @Param hoverOperateMode: boolean = false; - @Param isVoiceListening: boolean = false; - @Param modelCatalog: ConversationUiModelCatalog = { - version: 0, - models: [], - default_models: {} - }; - @Param selectedModelId: string = ''; - @Event onBack: () => void = () => {}; - @Event onRestoreSidebar: () => void = () => {}; - @Event onToggleDeviceMenu: () => void = () => {}; - @Event onToggleWorkspaceMenu: () => void = () => {}; - @Event onSelectDevice: (device: CloudAccountDevice) => void = (_device: CloudAccountDevice) => {}; - @Event onSelectWorkspace: (workspace?: RecentWorkspaceEntry) => void = (_workspace?: RecentWorkspaceEntry) => {}; - @Event onDraftChange: (value: string) => void = (_value: string) => {}; - @Event onSend: () => void = () => {}; - @Event onVoiceInput: () => void = () => {}; - @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; - @Event onSelectHarness: (profile: RemoteHarnessProfile) => void = (_profile: RemoteHarnessProfile) => {}; - - aboutToAppear(): void { - setTimeout(() => this.keepComposerFocused(), 180); - } - - build() { - Column() { - ConversationView({ - state: this.conversationState(), - options: this.viewOptions(), - remoteCreateState: this.state, - createActions: this.createActions(), - onRestoreSidebar: this.onRestoreSidebar, - onIntent: (intent: ConversationIntent) => this.handleIntent(intent) - }) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - private keepComposerFocused(): void { - setTimeout(() => focusControl.requestFocus('remote-create-composer'), 30); - } - - private conversationState(): ConversationViewState { - const viewState = new ConversationViewState(); - viewState.surface = ChatSurface.Create; - viewState.composerCapabilities = REMOTE_CREATE_COMPOSER_CAPABILITIES; - viewState.chatInput = this.state.draft; - viewState.isBusy = this.state.isSubmitting; - viewState.inlineStatusText = this.state.errorText; - viewState.connectionState = 'connected'; - viewState.isVoiceListening = this.isVoiceListening; - viewState.modelCatalog = this.modelCatalog; - viewState.selectedModelId = this.selectedModelId; - return viewState; - } - - private viewOptions(): ConversationViewOptions { - const options = new ConversationViewOptions(); - options.composerPresentation = this.presentation === ComposerPresentation.Floating ? - ComposerPresentation.Floating : ComposerPresentation.Create; - options.composerInputId = 'remote-create-composer'; - options.showBackButton = true; - options.showSidebarButton = false; - options.showSidebarRestoreButton = this.showSidebarRestoreButton; - options.hoverOperateMode = this.hoverOperateMode; - options.showRemoteCreateContext = true; - return options; - } - - private createActions(): RemoteCreateViewActions { - const actions = new RemoteCreateViewActions(); - actions.toggleDeviceMenu = (): void => this.onToggleDeviceMenu(); - actions.toggleWorkspaceMenu = (): void => this.onToggleWorkspaceMenu(); - actions.selectDevice = (device: CloudAccountDevice): void => this.onSelectDevice(device); - actions.selectWorkspace = (workspace?: RecentWorkspaceEntry): void => this.onSelectWorkspace(workspace); - actions.selectHarness = (profile: RemoteHarnessProfile): void => this.onSelectHarness(profile); - actions.backgroundTap = (): void => { - this.state.closeMenu(); - this.keepComposerFocused(); - }; - return actions; - } - - private handleIntent(intent: ConversationIntent): void { - if (intent.type === ConversationIntentType.Back) { - this.onBack(); - } else if (intent.type === ConversationIntentType.Send) { - this.onSend(); - } else if (intent.type === ConversationIntentType.ChatInputChanged) { - this.onDraftChange(intent.value); - } else if (intent.type === ConversationIntentType.VoiceInput) { - this.onVoiceInput(); - } else if (intent.type === ConversationIntentType.SelectModel) { - this.onSelectModel(intent.value); - } - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index 97a3b29645..458db8cdc3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -128,7 +128,7 @@ export struct SettingsSheet { cloudListDevices: this.cloudListDevices, cloudSelectDevice: this.cloudSelectDevice, onReconnect: this.onReconnect, - onLoggedOut: this.onOpenAccount, + onLoggedOut: this.onClose, embeddedInSettings: true, preferences: () => { this.GeneralSettingsSection() 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 1bb03caec6..906a9e4a0d 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 @@ -326,12 +326,10 @@ export struct SidebarWorkspaceSection { private selectedEntry(): DeviceDirectoryEntry | undefined { const entries = this.deviceEntries(); const selectedId = this.directoryState.selectedDeviceId.trim(); - const selected = entries.find((entry: DeviceDirectoryEntry): boolean => entry.deviceId === selectedId); - if (selected) { - return selected; + if (selectedId.length === 0) { + return undefined; } - const activeId = this.controlTargetDeviceId.trim(); - return entries.find((entry: DeviceDirectoryEntry): boolean => entry.deviceId === activeId) || entries[0]; + return entries.find((entry: DeviceDirectoryEntry): boolean => entry.deviceId === selectedId); } private isSelected(entry: DeviceDirectoryEntry): boolean { 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 a2b464ddfe..d96e9af28d 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 @@ -1,90 +1,184 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ToolCollapsePolicy } from '../policy/ToolCollapsePolicy'; -import { ChatTypingDots } from './ChatMessageChrome'; -import { INK, LINE, MUTED, SOFT } from './Theme'; +import { ConversationUiMessageItem, ConversationUiQuestionAnswer } from '../state/ConversationUiModels'; +import { ChatMessageStructurePolicy } from '../policy/ChatMessageStructurePolicy'; +import { CARD, INK, LINE, MUTED, RED, SOFT } from './Theme'; +import { ThinkingBlock } from './ThinkingBlock'; +import { ToolStatusList } from './ToolStatusList'; +import { ToolGlyph } from './ToolGlyphs'; @ComponentV2 export struct SubagentTaskCard { @Param title: string = ''; - @Param running: boolean = false; - @Param content: string = ''; - @Param hasChildren: boolean = false; - @BuilderParam childItems: () => void = this.EmptyChildren; + @Param status: string = 'completed'; + @Param items: ConversationUiMessageItem[] = []; + @Param renderRevision: number = 0; + @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = + (_toolId: string, _updatedInput?: Object) => {}; + @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; + @Event onCancelTool: (toolId: string) => void = (_toolId: string) => {}; + @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = + (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Event onOpenFilePreview: (path: string, label: string) => void = + (_path: string, _label: string) => {}; @Local expanded: boolean = false; - @Local userToggled: boolean = false; - - aboutToAppear(): void { - this.userToggled = false; - this.expanded = ToolCollapsePolicy.defaultSubagentExpanded(this.running); - } - - @Monitor('running') - onRunningChanged(): void { - this.expanded = ToolCollapsePolicy.subagentExpandedForStatus( - this.running, - this.userToggled, - this.expanded - ); - if (!this.running) { - this.userToggled = false; - } - } build() { - Column({ space: 8 }) { + Column() { Row({ space: 8 }) { - Stack({ alignContent: Alignment.Center }) { - // The Stack is the slot that keeps the title from shifting between - // the two states; the glyph must stay unsized, or a chevron_right - // (whose natural box is half as wide as it is tall) gets drawn - // against the left edge of a box forced square. - SymbolGlyph(this.expanded ? $r('sys.symbol.chevron_down') : $r('sys.symbol.chevron_right')) - .fontSize(14) - .fontColor([MUTED]) + Stack({ alignContent: Alignment.BottomEnd }) { + Stack({ alignContent: Alignment.Center }) { + ToolGlyph({ kind: 'task', color: this.isError() ? RED : MUTED }) + } + .width(18) + .height(18) + .backgroundColor(SOFT) + .borderRadius(5) + .border({ width: 1, color: this.isError() ? RED : LINE }) + if (this.isRunning() || this.isError()) { + Text(this.isError() ? '!' : '•') + .width(10) + .height(10) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontWeight(FontWeight.Bold) + .fontColor(CARD) + .textAlign(TextAlign.Center) + .backgroundColor(this.isError() ? RED : MUTED) + .borderRadius(5) + .border({ width: 1, color: CARD }) + } } - .width(18) - .height(32) + .width(20) + .height(20) Text(this.title) .fontSize(MobileDesignTypography.bodySmall.size) - .fontWeight(FontWeight.Medium) - .fontColor(INK) + .fontColor(this.isError() ? RED : MUTED) .layoutWeight(1) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.statusText()) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(this.isError() ? RED : MUTED) + .maxLines(1) + if (this.hasProcessItems()) { + SymbolGlyph(this.expanded ? $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + .fontSize(13) + .fontColor([MUTED]) + .width(16) + .height(16) + } } .width('100%') - .height(32) + .height(28) .alignItems(VerticalAlign.Center) .onClick(() => { - this.userToggled = this.running; - this.expanded = !this.expanded; - }) - .accessibilityText(this.expanded ? RemoteI18n.t('chat.showLess') : RemoteI18n.t('chat.showMore')) - if (this.expanded) { - if (this.running) { - ChatTypingDots() - } - if (this.content.trim().length > 0) { - Text(this.content) - .fontSize(MobileDesignTypography.bodySmall.size) - .lineHeight(MobileDesignTypography.bodySmall.lineHeight) - .fontColor(MUTED) - .width('100%') - } - if (this.hasChildren) { - this.childItems() + if (this.hasProcessItems()) { + this.expanded = !this.expanded; } + }) + if (this.expanded && this.hasProcessItems()) { + this.ProcessItems() } } .width('100%') - .padding({ left: 10, right: 10, top: 8, bottom: 8 }) - .backgroundColor(SOFT) - .borderRadius(12) - .border({ width: 1, color: LINE }) + .padding({ + left: this.isEmphasized() ? 10 : 0, + right: this.isEmphasized() ? 10 : 0, + top: this.isEmphasized() ? 6 : 0, + bottom: this.isEmphasized() ? 6 : 0 + }) + .backgroundColor(this.isEmphasized() || this.expanded ? SOFT : '#00000000') + .borderRadius(this.isEmphasized() ? 14 : 8) + .border({ width: this.isEmphasized() || this.expanded ? 1 : 0, color: LINE }) + .accessibilityText(`${this.title}, ${this.statusText()}`) } @Builder - EmptyChildren() { + private ProcessItems() { + Column({ space: 6 }) { + ForEach(this.visibleProcessItems(), (entry: ConversationUiMessageItem, index: number) => { + if (ChatMessageStructurePolicy.isThinkingEntry(entry)) { + ThinkingBlock({ + text: entry.content || '', + status: this.childStatus(index), + keepExpandedWhenDone: false, + streaming: this.childStatus(index) === 'running', + streamKey: `${this.title}-subagent-thinking-${index}` + }) + } else if (entry.tool) { + ToolStatusList({ + tools: [entry.tool], + renderRevision: this.renderRevision, + isBusy: this.isRunning(), + onApproveTool: this.onApproveTool, + onRejectTool: this.onRejectTool, + onCancelTool: this.onCancelTool, + onAnswerQuestion: this.onAnswerQuestion, + onOpenFilePreview: this.onOpenFilePreview + }) + } else if ((entry.content || '').trim().length > 0) { + Column({ space: 3 }) { + Text(RemoteI18n.t('chat.subagentOutput')) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Text(this.processTextPreview(entry.content || '')) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(INK) + .width('100%') + .maxLines(4) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + }, (entry: ConversationUiMessageItem, index: number) => + ChatMessageStructurePolicy.structuredItemKey(entry, `subagent-${index}`)) + } + .width('100%') + .padding({ left: 14, top: 6, bottom: 4 }) + .border({ width: { left: 1 }, color: LINE }) + } + + private isRunning(): boolean { + const normalized = this.status.toLowerCase(); + return normalized === 'running' || normalized === 'active' || normalized === 'preparing' || + normalized === 'pending' || normalized === 'queued'; + } + + private isError(): boolean { + const normalized = this.status.toLowerCase(); + return normalized === 'failed' || normalized === 'error' || normalized === 'timeout' || + normalized === 'cancelled' || normalized === 'canceled' || normalized === 'rejected'; + } + + private statusText(): string { + if (this.isRunning()) return RemoteI18n.t('common.running'); + return RemoteI18n.t(this.isError() ? 'common.failed' : 'common.done'); + } + + private isEmphasized(): boolean { + return this.isRunning() || this.isError(); + } + + private visibleProcessItems(): ConversationUiMessageItem[] { + return this.items.filter((entry: ConversationUiMessageItem) => + !!entry.tool || (entry.content || '').trim().length > 0 || + (!!entry.subItems && entry.subItems.length > 0)); + } + + private hasProcessItems(): boolean { + return this.visibleProcessItems().length > 0; + } + + private childStatus(index: number): string { + return this.isRunning() && index === this.visibleProcessItems().length - 1 ? 'running' : 'done'; + } + + private processTextPreview(raw: string): string { + const text = raw.trim(); + if (text.length <= 320) return text; + return `${text.slice(0, 320).trim()}…`; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets index 3e66465774..7fe6b67953 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets @@ -103,30 +103,32 @@ export struct ThinkingBlock { @Builder RunningDots() { - Row({ space: 6 }) { + Row({ space: ThinkingPresentationPolicy.RUNNING_DOT_GAP }) { Text('•') - .width(6) + .width(ThinkingPresentationPolicy.RUNNING_DOT_WIDTH) .height(18) .fontSize(MobileDesignTypography.bodyLarge.size) .fontColor(MUTED) .opacity(this.dotOpacity(0)) .animation({ duration: 180, curve: Curve.EaseInOut }) Text('•') - .width(6) + .width(ThinkingPresentationPolicy.RUNNING_DOT_WIDTH) .height(18) .fontSize(MobileDesignTypography.bodyLarge.size) .fontColor(MUTED) .opacity(this.dotOpacity(1)) .animation({ duration: 180, curve: Curve.EaseInOut }) Text('•') - .width(6) + .width(ThinkingPresentationPolicy.RUNNING_DOT_WIDTH) .height(18) .fontSize(MobileDesignTypography.bodyLarge.size) .fontColor(MUTED) .opacity(this.dotOpacity(2)) .animation({ duration: 180, curve: Curve.EaseInOut }) } - .width(22) + // Reserve the full intrinsic strip width. A narrower fixed width lets the + // last dots paint into the following "Thinking" label in ArkUI Rows. + .width(ThinkingPresentationPolicy.runningDotsWidth()) .height(18) } 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 4484948547..80bc1cb6f3 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 @@ -52,6 +52,8 @@ export struct ToolStatusList { @Param tools: ConversationUiToolStatus[] = []; /** Reasoning that ran between these tools; folded away with them. */ @Param thinkingParts: ActivityThinkingPart[] = []; + /** Explicit invalidation signal for replacement snapshots from the relay. */ + @Param renderRevision: number = 0; @Param isBusy: boolean = false; @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; @@ -66,7 +68,7 @@ export struct ToolStatusList { build() { Column({ space: 4 }) { - ForEach(this.toolRenderEntries(), (entry: ToolRenderEntry, index: number) => { + ForEach(this.toolRenderEntries(this.renderRevision), (entry: ToolRenderEntry, index: number) => { if (entry.type === 'summary') { this.CollapsedToolSummary(entry) } else if (entry.thinking) { @@ -346,7 +348,7 @@ export struct ToolStatusList { return this.isEmphasizedToolRow(tool, index) ? 10 : 0; } - private toolRenderEntries(): ToolRenderEntry[] { + private toolRenderEntries(_renderRevision: number): ToolRenderEntry[] { const groups = this.expanded ? ToolCollapsePolicy.expandedGroups(this.tools) : ToolCollapsePolicy.collapsedGroups(this.tools); @@ -620,8 +622,7 @@ export struct ToolStatusList { } if (name === 'Task') { - return payload.description || payload.task || payload.prompt || payload.content || - RemoteI18n.t('chat.toolTaskFallback'); + return ToolStatusPresentationPolicy.taskTitle(tool) || RemoteI18n.t('chat.toolTaskFallback'); } const path = payload.file_path || payload.filePath || payload.path || ''; 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 3259f4a83e..97d6028435 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 @@ -14,7 +14,6 @@ import { import { AppShellState } from '../state/AppShellState'; import { FilePreviewState } from '../state/FilePreviewState'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; -import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; import { DeviceDirectoryState } from '../state/DeviceDirectoryState'; import { RemotePageState } from '../state/RemotePageState'; import { AppSidebar } from './AppSidebar'; @@ -37,7 +36,6 @@ export struct WideConversationHost { @Param shellState: AppShellState = new AppShellState(); @Param remotePageState: RemotePageState = new RemotePageState(); @Param deviceDirectoryState: DeviceDirectoryState = new DeviceDirectoryState(); - @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); @Param filePreviewState: FilePreviewState = new FilePreviewState(); @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); @@ -53,22 +51,26 @@ export struct WideConversationHost { @Param wideCollapsedDetailContentWidth: number = 0; @Param wideMasterPaneCollapsed: boolean = false; @Param wideMasterPaneMotionActive: boolean = false; + @Param filePreviewExpanded: boolean = false; + @Param canToggleFilePreviewExpanded: boolean = false; @Event onCollapseMasterPane: () => void = () => {}; @Event onRestoreMasterPane: () => void = () => {}; + @Event onToggleFilePreviewExpanded: () => void = () => {}; @Event onOpenRemoteViewSettings: () => void = () => {}; build() { if (this.route === AppRoute.ChatHome || this.route === AppRoute.GeneralChat) { this.GeneralChatContent(); + } else if (this.showsRemoteConversation() && + this.filePreviewLayout.placement === FilePreviewPlacement.WideMasterPreviewFocus) { + this.RemoteMasterPreviewFocusContent(); } else if (this.showsRemoteConversation() && this.filePreviewLayout.placement === FilePreviewPlacement.WideFocusSplit) { this.RemotePreviewFocusContent(); - } else if (this.showsRemoteConversation()) { + } else if (this.showsRemoteDetail()) { this.RemoteChatContent(); } else if (this.route === AppRoute.RemoteHome) { this.RemoteHomeContent(); - } else { - this.RemoteCreateContent(); } } @@ -131,18 +133,6 @@ export struct WideConversationHost { .layoutWeight(1).height('100%').backgroundColor(PAGE_BG) } - @Builder - private RemoteCreateContent() { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.MasterPane() - this.MasterDetailGap() - } - this.ConversationDetail(false) - } - .width('100%').height('100%').backgroundColor(PAGE_BG) - } - @Builder private MasterPane() { Column() { @@ -210,7 +200,7 @@ export struct WideConversationHost { workspacePickerPlacement: this.sessionDetailsPlacement, onOpenSession: this.actions.onRemoteHome.openSessionInPlace, onCreateInWorkspace: (deviceId: string, path: string) => { - this.actions.onRemoteHome.openCreateInWorkspace(path, deviceId); + this.actions.onRemoteHome.createInWorkspaceInPlace(path, 'code', deviceId); }, onOpenWorkspace: (deviceId: string, path: string) => { this.actions.onRemoteHome.selectWorkspace(path, deviceId); @@ -230,10 +220,22 @@ export struct WideConversationHost { private RemoteChatContent() { if (this.filePreviewLayout.placement === FilePreviewPlacement.WideTriplePane) { Row() { - this.MasterPane() - this.PaneGap(this.filePreviewLayout.masterConversationGap) - this.ConversationDetail(false, this.filePreviewLayout.conversationPaneWidth) - this.PaneGap(this.filePreviewLayout.conversationPreviewGap) + if (!this.wideMasterPaneCollapsed) { + this.MasterPane() + this.PaneGap( + this.filePreviewLayout.masterConversationGap, + this.filePreviewLayout.masterConversationGapIsCrease + ) + } + this.ConversationDetail( + false, + this.wideMasterPaneCollapsed ? this.collapsedTripleConversationWidth() : + this.filePreviewLayout.conversationPaneWidth + ) + this.PaneGap( + this.filePreviewLayout.conversationPreviewGap, + this.filePreviewLayout.conversationPreviewGapIsCrease + ) this.FilePreviewPane(this.filePreviewLayout.previewPaneWidth) } .width('100%').height('100%').backgroundColor(PAGE_BG) @@ -253,7 +255,23 @@ export struct WideConversationHost { private RemotePreviewFocusContent() { Row() { this.ConversationDetail(false, this.filePreviewLayout.conversationPaneWidth) - this.PaneGap(this.filePreviewLayout.conversationPreviewGap) + this.PaneGap( + this.filePreviewLayout.conversationPreviewGap, + this.filePreviewLayout.conversationPreviewGapIsCrease + ) + this.FilePreviewPane(this.filePreviewLayout.previewPaneWidth) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private RemoteMasterPreviewFocusContent() { + Row() { + this.MasterPane() + this.PaneGap( + this.filePreviewLayout.masterConversationGap, + this.filePreviewLayout.masterConversationGapIsCrease + ) this.FilePreviewPane(this.filePreviewLayout.previewPaneWidth) } .width('100%').height('100%').backgroundColor(PAGE_BG) @@ -268,10 +286,13 @@ export struct WideConversationHost { downloadPath: this.remotePageState.downloadingFilePath, downloadedPath: this.remotePageState.downloadedFilePath, downloadStatus: this.remotePageState.fileDownloadStatus, + previewExpanded: this.filePreviewExpanded, + canToggleExpanded: this.canToggleFilePreviewExpanded, onClose: this.actions.onFilePreview.close, onRefresh: this.actions.onFilePreview.refresh, onDownload: this.actions.onFilePreview.download, - onOpenLink: this.actions.onFilePreview.openLink + onOpenLink: this.actions.onFilePreview.openLink, + onToggleExpanded: this.onToggleFilePreviewExpanded }) } .width(paneWidth).height('100%').backgroundColor(PAGE_BG) @@ -313,14 +334,16 @@ export struct WideConversationHost { ConversationRouteSurface({ route: this.route, remotePageState: this.remotePageState, - remoteCreateState: this.remoteCreateState, generalPageState: this.generalPageState, filePreviewState: this.filePreviewState, remoteSurfaceState: this.remoteSurfaceState, actions: this.actions, showSidebarButton: false, showBackButton, - showSidebarRestoreButton: this.wideMasterPaneCollapsed, + showSidebarRestoreButton: WideLayoutGeometry.showsMasterRestoreButton( + this.wideMasterPaneCollapsed, + this.filePreviewLayout + ), useWidePresentation: true, onRestoreSidebar: this.onRestoreMasterPane }) @@ -336,9 +359,9 @@ export struct WideConversationHost { } @Builder - private PaneGap(width: number) { + private PaneGap(width: number, isPhysicalCrease: boolean) { if (width > 0) { - Row() {}.width(width).height('100%').backgroundColor(LINE) + Row() {}.width(width).height('100%').backgroundColor(isPhysicalCrease ? PAGE_BG : LINE) } } @@ -354,6 +377,10 @@ export struct WideConversationHost { return this.route === AppRoute.RemoteChat; } + private showsRemoteDetail(): boolean { + return this.route === AppRoute.RemoteChat; + } + private currentDetailOffset(): number { return WideLayoutGeometry.detailOffset( this.wideMasterPaneCollapsed, @@ -369,4 +396,11 @@ export struct WideConversationHost { this.wideCollapsedDetailContentWidth ); } + + private collapsedTripleConversationWidth(): number { + return this.filePreviewLayout.masterPaneWidth + + this.filePreviewLayout.masterConversationGap + + this.filePreviewLayout.conversationPaneWidth; + } + } 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 9e4dd0862b..705b8996d7 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 @@ -252,7 +252,7 @@ export struct RemoteSurfaceHost { .width(148).height(46).fontSize(MobileDesignTypography.titleSmall.size).fontWeight(FontWeight.Medium) .fontColor(PRIMARY_ACTION_TEXT).backgroundColor(PRIMARY_ACTION) .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) - .onClick(() => this.actions.onRemoteHome.createAssistant()) + .onClick(() => this.createSession('code')) } } .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) @@ -346,10 +346,7 @@ export struct RemoteSurfaceHost { } private createAssistantSession(): void { - if (this.compact) { - this.actions.onSidebar.close(); - } - this.actions.onRemoteHome.createAssistant(); + this.createSession('code'); } /** diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets index 8cf9ccec4b..9bc7af78b8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets @@ -3,7 +3,8 @@ import { FilePreviewLayout, FilePreviewPlacement } from '../policy/FilePreviewPl /** Pure geometry helpers shared by wide conversation presentation paths. */ export class WideLayoutGeometry { static masterPaneWidth(layout: FilePreviewLayout, fallback: number): number { - return layout.placement === FilePreviewPlacement.WideTriplePane ? layout.masterPaneWidth : fallback; + return layout.placement === FilePreviewPlacement.WideTriplePane || + layout.placement === FilePreviewPlacement.WideMasterPreviewFocus ? layout.masterPaneWidth : fallback; } static detailOffset(collapsed: boolean, expandedOffset: number, collapsedOffset: number): number { @@ -14,6 +15,14 @@ export class WideLayoutGeometry { return collapsed ? collapsedWidth : expandedWidth; } + static showsMasterRestoreButton(collapsed: boolean, layout: FilePreviewLayout): boolean { + return collapsed || layout.placement === FilePreviewPlacement.WideFocusSplit; + } + + static restoreMasterClosesPreview(layout: FilePreviewLayout): boolean { + return layout.placement === FilePreviewPlacement.WideFocusSplit; + } + static areaLength(value: Object): number { const parsed = Number.parseFloat(`${value}`); return Number.isNaN(parsed) ? 0 : parsed; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets index c8bf31a02f..6eac84fdea 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets @@ -2,7 +2,6 @@ export enum AppRoute { ChatHome = 'ChatHome', GeneralChat = 'GeneralChat', RemoteHome = 'RemoteHome', - RemoteCreate = 'RemoteCreate', RemoteChat = 'RemoteChat' } @@ -106,9 +105,6 @@ export class AppRouteContract { if (route === AppRoute.RemoteHome) { return AppNavigationBackAction.PopRemoteHome; } - if (route === AppRoute.RemoteCreate) { - return AppNavigationBackAction.PopRemoteHome; - } return AppNavigationBackAction.AllowSystem; } } 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 9c5bac8fcb..ba95f4e0d6 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 @@ -3,11 +3,6 @@ import { ActivityGroupPolicy, ActivityThinkingPart } from './ActivityGroupPolicy import { ThinkingPresentationPolicy } from './ThinkingPresentationPolicy'; import { ToolStatusPresentationPolicy } from './ToolStatusPresentationPolicy'; -interface SubagentTaskInput { - description?: string; - title?: string; -} - export interface StructuredRenderGroup { type: string; items: ConversationUiMessageItem[]; @@ -39,12 +34,31 @@ export class ChatMessageStructurePolicy { return !ChatMessageStructurePolicy.isTextEntry(entry) || hasSubItems || !!entry.tool; } + /** + * A composite turn commonly ends with one growing top-level text item after + * its reasoning and tools. Keep that volatile item out of the keyed + * structured ForEach so its Markdown leaf can be retained while text grows. + * Only peel it when it is the final renderable entry, preserving transcript + * order for turns that continue with another tool or nested activity. + */ + static trailingTopLevelText(items: ConversationUiMessageItem[]): string { + const index = ChatMessageStructurePolicy.trailingTopLevelTextIndex(items); + return index >= 0 ? (items[index].content || '') : ''; + } + + static withoutTrailingTopLevelText(items: ConversationUiMessageItem[]): ConversationUiMessageItem[] { + const index = ChatMessageStructurePolicy.trailingTopLevelTextIndex(items); + if (index < 0) return items; + return items.slice(0, index).concat(items.slice(index + 1)); + } + static structuredGroups( items: ConversationUiMessageItem[], prefix: string, activeScope: boolean, messageStatus: string ): StructuredRenderGroup[] { + const scopedItems = ChatMessageStructurePolicy.scopeSubagentItems(items); const groups: StructuredRenderGroup[] = []; let toolBuffer: ConversationUiToolStatus[] = []; let toolStart = 0; @@ -54,7 +68,7 @@ export class ChatMessageStructurePolicy { let activityChildScopes: boolean[] = []; let activityStart = 0; let activityHasThinking = false; - const activeItemIndex = activeScope ? ChatMessageStructurePolicy.lastRenderableItemIndex(items) : -1; + const activeItemIndex = activeScope ? ChatMessageStructurePolicy.lastRenderableItemIndex(scopedItems) : -1; const flushTools = (): void => { if (toolBuffer.length === 0) return; @@ -102,7 +116,10 @@ export class ChatMessageStructurePolicy { activityHasThinking = false; }; - items.forEach((entry: ConversationUiMessageItem, index: number) => { + scopedItems.forEach((entry: ConversationUiMessageItem, index: number) => { + // Remote snapshots also mark tools executed inside a subagent. Those + // are Task details, not sibling subagent cards in the parent timeline. + if (ChatMessageStructurePolicy.isSubagentInternalTool(entry)) return; if (ChatMessageStructurePolicy.shouldFoldIntoActivityGroup(entry)) { if (activityItems.length === 0) activityStart = index; activityItems.push(entry); @@ -116,6 +133,23 @@ export class ChatMessageStructurePolicy { } flushActivity(); + // Task tool entries are subagent branches, not ordinary tool rows. Route + // them as structured items before the generic tool buffer so they keep + // their branch title, children, and lifecycle presentation. + if (ChatMessageStructurePolicy.isSubagentEntry(entry)) { + flushTools(); + groups.push({ + type: 'item', items: [entry], + itemStatuses: [ChatMessageStructurePolicy.itemStatus(entry, index, activeItemIndex, + activeScope, messageStatus)], + itemStreaming: [ChatMessageStructurePolicy.itemShouldStream(entry, index, activeItemIndex, activeScope)], + itemChildActiveScopes: [ChatMessageStructurePolicy.itemChildActiveScope(entry, index, + activeItemIndex, activeScope)], + tools: [], key: ChatMessageStructurePolicy.structuredItemKey(entry, `${prefix}-${index}`), + path: `${prefix}-${index}` + }); + return; + } if (entry.tool) { if (toolBuffer.length === 0) toolStart = index; toolBuffer.push(entry.tool); @@ -138,6 +172,59 @@ export class ChatMessageStructurePolicy { return groups; } + /** + * Rebuilds the branch hierarchy carried by the remote wire format. + * + * Live snapshots are intentionally flat and mark child-agent records with + * `is_subagent`. The parent UI must attach those records to the Task that + * owns them before grouping ordinary transcript activity. Older desktop + * builds did not emit the marker, so while a Task is running we use the same + * conservative fallback as mobile web and scope its following process items. + */ + static scopeSubagentItems(items: ConversationUiMessageItem[]): ConversationUiMessageItem[] { + const result: ConversationUiMessageItem[] = []; + const hasSubagentMarkers = items.some((entry: ConversationUiMessageItem) => entry.is_subagent === true); + let currentTask: ConversationUiMessageItem | undefined = undefined; + + items.forEach((entry: ConversationUiMessageItem) => { + if (ChatMessageStructurePolicy.isParentTaskEntry(entry)) { + const task = ChatMessageStructurePolicy.copyItem(entry); + result.push(task); + currentTask = task; + return; + } + if (entry.is_subagent === true) { + if (currentTask) { + currentTask.subItems = (currentTask.subItems || []).concat([entry]); + } + return; + } + if (!hasSubagentMarkers && currentTask && + ChatMessageStructurePolicy.isRunningTaskEntry(currentTask) && + ChatMessageStructurePolicy.isLegacySubagentProcessEntry(entry)) { + currentTask.subItems = (currentTask.subItems || []).concat([entry]); + return; + } + result.push(entry); + }); + return result; + } + + static structuredRenderKey( + items: ConversationUiMessageItem[], + prefix: string, + activeScope: boolean, + messageStatus: string + ): string { + const groupKeys = ChatMessageStructurePolicy.structuredGroups( + items, + prefix, + activeScope, + messageStatus + ).map((group: StructuredRenderGroup) => group.key); + return `${prefix}-structured-${ChatMessageStructurePolicy.stableTextHash(groupKeys.join('|'))}`; + } + static activityGroupTools(group: StructuredRenderGroup): ConversationUiToolStatus[] { const tools: ConversationUiToolStatus[] = []; group.items.forEach((entry: ConversationUiMessageItem) => { @@ -212,6 +299,7 @@ export class ChatMessageStructurePolicy { } static isRenderableStructuredEntry(entry: ConversationUiMessageItem): boolean { + if (ChatMessageStructurePolicy.isSubagentInternalTool(entry)) return false; if (ChatMessageStructurePolicy.isThinkingEntry(entry) || ChatMessageStructurePolicy.isTextEntry(entry) || ChatMessageStructurePolicy.isSubagentEntry(entry) || !!entry.tool) return true; return !!entry.subItems && entry.subItems.some((child: ConversationUiMessageItem) => @@ -237,15 +325,57 @@ export class ChatMessageStructurePolicy { (!!entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task'); } + private static isParentTaskEntry(entry: ConversationUiMessageItem): boolean { + return entry.is_subagent !== true && !!entry.tool && + ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task'; + } + + private static isRunningTaskEntry(entry: ConversationUiMessageItem): boolean { + if (!entry.tool) return false; + const status = (entry.tool.status || '').toLowerCase(); + return status === 'running' || status === 'active' || status === 'preparing' || + status === 'pending' || status === 'queued'; + } + + private static isLegacySubagentProcessEntry(entry: ConversationUiMessageItem): boolean { + return ChatMessageStructurePolicy.isThinkingEntry(entry) || + ChatMessageStructurePolicy.isTextEntry(entry) || !!entry.tool; + } + + private static copyItem(entry: ConversationUiMessageItem): ConversationUiMessageItem { + return { + type: entry.type, + content: entry.content, + tool: entry.tool, + is_subagent: entry.is_subagent, + subItems: entry.subItems ? entry.subItems.slice() : [] + }; + } + + private static isSubagentInternalTool(entry: ConversationUiMessageItem): boolean { + return entry.is_subagent === true && !!entry.tool && + ToolStatusPresentationPolicy.normalizedName(entry.tool) !== 'task'; + } + static subagentTitle(entry: ConversationUiMessageItem): string { - const content = (entry.content || '').trim(); - if (content.length > 0 && content.length <= 80) return content; - if (entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task') { + const taskTool = !!entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task'; + if (entry.tool && taskTool) { const title = ChatMessageStructurePolicy.subagentTaskTitle(entry.tool); if (title.length > 0) return title; } - if (entry.tool && entry.tool.name) return entry.tool.name; - return 'Subagent'; + const content = ChatMessageStructurePolicy.plainSubagentLabel(entry.content || ''); + if (content.length > 0 && content.length <= 80) return content; + if (entry.tool && entry.tool.name && !taskTool) { + return ChatMessageStructurePolicy.plainSubagentLabel(entry.tool.name); + } + return ''; + } + + static subagentBody(entry: ConversationUiMessageItem): string { + if (entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task') return ''; + const content = ChatMessageStructurePolicy.plainSubagentLabel(entry.content || ''); + if (content.length === 0 || content === ChatMessageStructurePolicy.subagentTitle(entry)) return ''; + return content; } static isAttentionTool(tool: ConversationUiToolStatus): boolean { @@ -272,6 +402,12 @@ export class ChatMessageStructurePolicy { return -1; } + private static trailingTopLevelTextIndex(items: ConversationUiMessageItem[]): number { + const index = ChatMessageStructurePolicy.lastRenderableItemIndex(items); + if (index < 0 || !ChatMessageStructurePolicy.isTextEntry(items[index])) return -1; + return index; + } + private static itemStatus( entry: ConversationUiMessageItem, index: number, @@ -373,14 +509,23 @@ export class ChatMessageStructurePolicy { } private static subagentTaskTitle(tool: ConversationUiToolStatus): string { - const raw = tool.input_preview || ''; - if (raw.length === 0) return ''; - try { - const input = JSON.parse(raw) as SubagentTaskInput; - return (input.description || input.title || '').trim(); - } catch (_err) { - return ''; + return ChatMessageStructurePolicy.plainSubagentLabel(ToolStatusPresentationPolicy.taskTitle(tool)); + } + + private static plainSubagentLabel(raw: string): string { + let text = (raw || '').trim(); + const wrappers: string[] = ['**', '__', '`', '*', '_']; + for (let index = 0; index < wrappers.length; index++) { + const wrapper = wrappers[index]; + if (text.length > wrapper.length * 2 && text.startsWith(wrapper) && text.endsWith(wrapper)) { + text = text.slice(wrapper.length, text.length - wrapper.length).trim(); + break; + } + } + while (text.startsWith('#')) { + text = text.slice(1).trim(); } + return text; } static structuredItemKey(entry: ConversationUiMessageItem, path: string): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationHeaderPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationHeaderPolicy.ets index 4531abc170..2b9407dc63 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationHeaderPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationHeaderPolicy.ets @@ -23,7 +23,7 @@ export class ConversationHeaderPresentation { } /** - * One header contract for local chat, remote chat, create, and remote home. + * One header contract for local and remote chat. * View supplies i18n for fallbackKey; this policy only decides structure. */ export class ConversationHeaderPolicy { @@ -36,9 +36,6 @@ export class ConversationHeaderPolicy { workspaceBranch: string, hasTimeline: boolean ): ConversationHeaderPresentation { - if (surface === ChatSurface.Create) { - return new ConversationHeaderPresentation('', '', 'remote.create.title', false, false); - } if (surface === ChatSurface.Remote) { return new ConversationHeaderPresentation( sessionTitle, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets index 9bc03214f8..386c2f437f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets @@ -4,6 +4,7 @@ export enum FilePreviewPlacement { Hidden = 'hidden', CompactFullPage = 'compact_full_page', WideFocusSplit = 'wide_focus_split', + WideMasterPreviewFocus = 'wide_master_preview_focus', WideTriplePane = 'wide_triple_pane' } @@ -14,6 +15,8 @@ export class FilePreviewLayout { readonly conversationPaneWidth: number; readonly conversationPreviewGap: number; readonly previewPaneWidth: number; + readonly masterConversationGapIsCrease: boolean; + readonly conversationPreviewGapIsCrease: boolean; constructor( placement: FilePreviewPlacement, @@ -21,7 +24,9 @@ export class FilePreviewLayout { masterConversationGap: number = 0, conversationPaneWidth: number = 0, conversationPreviewGap: number = 0, - previewPaneWidth: number = 0 + previewPaneWidth: number = 0, + masterConversationGapIsCrease: boolean = false, + conversationPreviewGapIsCrease: boolean = false ) { this.placement = placement; this.masterPaneWidth = masterPaneWidth; @@ -29,6 +34,8 @@ export class FilePreviewLayout { this.conversationPaneWidth = conversationPaneWidth; this.conversationPreviewGap = conversationPreviewGap; this.previewPaneWidth = previewPaneWidth; + this.masterConversationGapIsCrease = masterConversationGapIsCrease; + this.conversationPreviewGapIsCrease = conversationPreviewGapIsCrease; } } @@ -36,6 +43,10 @@ export class FilePreviewPlacementPolicy { static readonly MIN_MASTER_WIDTH: number = 280; static readonly MIN_CONVERSATION_WIDTH: number = 360; static readonly MIN_PREVIEW_WIDTH: number = 360; + // A physical tri-fold panel is a stronger boundary than the generic tablet + // minimum. Current tri-fold right panels are about 290vp: that is still the + // complete screen and must not make preview hide the master pane. + static readonly MIN_CREASE_ALIGNED_PREVIEW_WIDTH: number = 280; static readonly PANE_DIVIDER_WIDTH: number = 1; static resolve( @@ -57,7 +68,8 @@ export class FilePreviewPlacementPolicy { largeScreenLayout: boolean, viewportWidth: number, creases: ConversationLayoutCrease[], - preferredMasterWidth: number = FilePreviewPlacementPolicy.MIN_MASTER_WIDTH + preferredMasterWidth: number = FilePreviewPlacementPolicy.MIN_MASTER_WIDTH, + previewExpanded: boolean = false ): FilePreviewLayout { if (!previewVisible) { return new FilePreviewLayout(FilePreviewPlacement.Hidden); @@ -72,6 +84,15 @@ export class FilePreviewPlacementPolicy { Math.max(0, viewportWidth) ); } + if (previewExpanded) { + const triFoldPreviewFocus = FilePreviewPlacementPolicy.creaseAlignedMasterPreviewFocus( + viewportWidth, + creases + ); + if (triFoldPreviewFocus) { + return triFoldPreviewFocus; + } + } const creaseLayout = FilePreviewPlacementPolicy.creaseAlignedTriplePane(viewportWidth, creases); if (creaseLayout) { return creaseLayout; @@ -173,7 +194,9 @@ export class FilePreviewPlacementPolicy { 0, conversationWidth, crease.width, - previewWidth + previewWidth, + false, + true ); } @@ -198,7 +221,9 @@ export class FilePreviewPlacementPolicy { 0, conversationWidth, crease.width, - previewWidth + previewWidth, + false, + true ); } @@ -217,7 +242,7 @@ export class FilePreviewPlacementPolicy { const previewWidth = viewportWidth - second.left - second.width; if (masterWidth < FilePreviewPlacementPolicy.MIN_MASTER_WIDTH || conversationWidth < FilePreviewPlacementPolicy.MIN_CONVERSATION_WIDTH || - previewWidth < FilePreviewPlacementPolicy.MIN_PREVIEW_WIDTH) { + previewWidth < FilePreviewPlacementPolicy.MIN_CREASE_ALIGNED_PREVIEW_WIDTH) { return undefined; } return new FilePreviewLayout( @@ -226,7 +251,46 @@ export class FilePreviewPlacementPolicy { first.width, conversationWidth, second.width, - previewWidth + previewWidth, + true, + true + ); + } + + /** + * When requested by the user, preserve navigation on the first panel and + * promote a narrow physical right-panel preview to the two-panel detail canvas. + * This is deliberately limited to geometry where the middle conversation + * panel was otherwise valid: malformed fold reports still use conservative + * focus fallbacks instead of being mistaken for a tri-fold device. + */ + private static creaseAlignedMasterPreviewFocus( + viewportWidth: number, + creases: ConversationLayoutCrease[] + ): FilePreviewLayout | undefined { + const visible = FilePreviewPlacementPolicy.visibleCreases(viewportWidth, creases); + if (visible.length < 2) return undefined; + const first = visible[0]; + const second = visible[1]; + const masterWidth = first.left; + const middleWidth = second.left - first.left - first.width; + const rightWidth = viewportWidth - second.left - second.width; + const previewWidth = viewportWidth - first.left - first.width; + if (masterWidth < FilePreviewPlacementPolicy.MIN_MASTER_WIDTH || + middleWidth < FilePreviewPlacementPolicy.MIN_CONVERSATION_WIDTH || + rightWidth < FilePreviewPlacementPolicy.MIN_CREASE_ALIGNED_PREVIEW_WIDTH || + rightWidth >= FilePreviewPlacementPolicy.MIN_PREVIEW_WIDTH) { + return undefined; + } + return new FilePreviewLayout( + FilePreviewPlacement.WideMasterPreviewFocus, + masterWidth, + first.width, + 0, + 0, + previewWidth, + true, + false ); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ThinkingPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ThinkingPresentationPolicy.ets index 84df0b152f..37a84f7442 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ThinkingPresentationPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ThinkingPresentationPolicy.ets @@ -1,4 +1,13 @@ export class ThinkingPresentationPolicy { + static readonly RUNNING_DOT_COUNT: number = 3; + static readonly RUNNING_DOT_WIDTH: number = 6; + static readonly RUNNING_DOT_GAP: number = 6; + + static runningDotsWidth(): number { + return ThinkingPresentationPolicy.RUNNING_DOT_WIDTH * ThinkingPresentationPolicy.RUNNING_DOT_COUNT + + ThinkingPresentationPolicy.RUNNING_DOT_GAP * (ThinkingPresentationPolicy.RUNNING_DOT_COUNT - 1); + } + static isRunning(status: string): boolean { const normalized = (status || '').toLowerCase(); return normalized === 'active' || normalized === 'running'; 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 98ea29c0f4..5bf47eb5ed 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 @@ -15,24 +15,6 @@ export class ToolCollapseGroup { export class ToolCollapsePolicy { static readonly MIN_SUMMARY_COUNT: number = 2; - static defaultSubagentExpanded(isRunning: boolean): boolean { - return isRunning; - } - - static subagentExpandedForStatus( - isRunning: boolean, - userToggled: boolean, - currentlyExpanded: boolean - ): boolean { - if (!isRunning) { - return false; - } - if (userToggled) { - return currentlyExpanded; - } - return true; - } - static shouldFoldIntoSummary(tool: ConversationUiToolStatus): boolean { if (ToolCollapsePolicy.hasError(tool) || ToolCollapsePolicy.isPending(tool) || diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets index 6db67487eb..1a53d8111a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets @@ -11,6 +11,18 @@ interface QuestionPreviewPayload { questions?: QuestionPreview[]; } +interface TaskPreviewPayload { + description?: string; + task?: string; + task_name?: string; + taskName?: string; + prompt?: string; + message?: string; + content?: string; + title?: string; + name?: string; +} + /** Pure tool classification and payload projection used by the ArkUI renderer. */ export class ToolStatusPresentationPolicy { static normalizedName(tool: ConversationUiToolStatus): string { @@ -186,6 +198,16 @@ export class ToolStatusPresentationPolicy { return ''; } + static taskTitle(tool: ConversationUiToolStatus): string { + const payload = ToolStatusPresentationPolicy.taskPayload(tool); + const title = payload.description || payload.task || payload.title || payload.prompt || payload.message || + payload.task_name || payload.taskName || payload.name || payload.content || ''; + if (title.trim().length > 0) return title.trim(); + const preview = ToolStatusPresentationPolicy.inputPreview(tool).trim(); + if (preview.length > 0 && !preview.startsWith('{') && !preview.startsWith('[')) return preview; + return ''; + } + static resultPreview(tool: ConversationUiToolStatus): string { const parts: string[] = []; if (tool.result_preview && tool.result_preview.length > 0) parts.push(tool.result_preview); @@ -245,6 +267,16 @@ export class ToolStatusPresentationPolicy { return ToolStatusPresentationPolicy.stableTextHash(parts.join('|')); } + private static taskPayload(tool: ConversationUiToolStatus): TaskPreviewPayload { + const preview = ToolStatusPresentationPolicy.inputPreview(tool); + if (preview.trim().length === 0) return {}; + try { + return JSON.parse(preview) as TaskPreviewPayload; + } catch (_err) { + return {}; + } + } + private static objectSignature(value?: Object): string { if (!value) return ''; try { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets index 0dc7bfeefa..7b16f944e5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -29,6 +29,8 @@ import { const DEVICE_PRESENCE_MAX_AGE_MS: number = 15000; export class AppRootRuntime extends AppRootRuntimeComposition { + private directorySelectionVersion: number = 0; + constructor(host: AppRootHostPort) { super(host); } @@ -237,13 +239,20 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } async selectDirectoryDevice(deviceId: string): Promise { + const selectionVersion = ++this.directorySelectionVersion; await this.deviceDirectoryViewModel.selectDevice(deviceId); + if (selectionVersion !== this.directorySelectionVersion) { + return; + } const entry = this.deviceDirectoryState.find(deviceId); if (entry && !entry.online && !this.isConnectedDirectoryTarget(deviceId)) { return; } try { await this.conversationController.ensureRemoteControlTarget(deviceId); + if (selectionVersion !== this.directorySelectionVersion) { + return; + } this.filePreviewController.close(); if (this.appShellState.wideLayout) { await this.enterRemoteSurfaceWide(); 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 3e7b3301f5..97e4770ffd 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 @@ -82,7 +82,6 @@ import { ConversationIntentDispatcher } from '../actions/ConversationIntentDispa import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { DeviceDirectoryState } from '../state/DeviceDirectoryState'; import { RemotePageState } from '../state/RemotePageState'; -import { RemoteCreateSessionState, RemoteHarnessProfile } from '../state/RemoteCreateSessionState'; import { WatchProvisionState } from '../state/WatchProvisionState'; import { WatchProvisionController, @@ -171,7 +170,6 @@ export abstract class AppRootRuntimeComposition { readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); readonly remotePageState: RemotePageState = new RemotePageState(); readonly deviceDirectoryState: DeviceDirectoryState = new DeviceDirectoryState(); - readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); readonly watchProvisionState: WatchProvisionState = new WatchProvisionState(); private readonly watchProvisionPort: WatchProvisionPort = { // Two ways to reach a credential, and the phone's own account is the @@ -626,6 +624,7 @@ export abstract class AppRootRuntimeComposition { onRouteHome: (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), onStopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), onStartPolling: (): void => this.conversationController.startRemotePolling(), + onNudgePolling: (): void => this.remoteChatPollingLifecycleController.nudge(), onResetTimeline: (sessionId: string): void => this.conversationController.resetRemoteTimeline(sessionId), onClearRemoteFiles: (): void => this.remoteFileDownloadController.clear(), onKnownStateReset: (): void => this.conversationController.resetKnownRemoteState(), @@ -749,7 +748,6 @@ export abstract class AppRootRuntimeComposition { new ConversationController( this.generalChatPageState, this.remotePageState, - this.remoteCreateState, { currentRoute: (): AppRoute => this.appShellViewModel.currentRoute() }, { timeline: this.chatTimelineStore, @@ -861,10 +859,6 @@ export abstract class AppRootRuntimeComposition { createInPlace: (agentType: string): void => { this.conversationController.createRemoteSession(agentType, true); }, - createAssistant: (): void => { this.conversationController.openRemoteCreateSession(); }, - openCreateInWorkspace: (path: string, deviceId?: string): void => { - void this.conversationController.openRemoteCreateSessionInWorkspace(path, deviceId); - }, createInWorkspace: (path: string, agentType: string, deviceId?: string): void => { this.conversationController.createRemoteSessionInWorkspace(path, agentType, false, deviceId); }, @@ -887,20 +881,6 @@ export abstract class AppRootRuntimeComposition { openSessionInPlace: (session: RemoteSession): void => this.conversationController.openHomeSession(session, true), deleteSession: (session: RemoteSession): void => { this.conversationController.deleteHomeSession(session); } }, - onRemoteCreate: { - back: (): void => this.conversationController.closeRemoteCreateSession(), - toggleDevices: (): void => { this.conversationController.toggleRemoteCreateDevices(); }, - toggleWorkspaces: (): void => { this.conversationController.toggleRemoteCreateWorkspaces(); }, - selectDevice: (device: CloudAccountDevice): void => { - this.conversationController.selectRemoteCreateDevice(device); - }, - selectWorkspace: (path: string): void => this.conversationController.selectRemoteCreateWorkspace(path), - draftChanged: (value: string): void => this.remoteCreateState.setDraft(value), - voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, - selectModel: (modelId: string): void => this.remoteCreateState.setSelectedModelId(modelId), - selectHarness: (profile: RemoteHarnessProfile): void => this.remoteCreateState.setSelectedHarnessProfile(profile), - send: (): void => { this.conversationController.submitRemoteCreateSession(); } - }, onSidebar: { close: (): void => this.closeAppSidebar(), newChat: (): void => { this.closeAppSidebar(); this.conversationController.prepareNewGeneralChat(); }, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatComposerCapabilities.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatComposerCapabilities.ets index 0da62826a0..911be61ed3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatComposerCapabilities.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatComposerCapabilities.ets @@ -34,6 +34,3 @@ export const GENERAL_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities = export const REMOTE_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities = new ChatComposerCapabilities(ChatSurface.Remote, true, true, true, true, true); - -export const REMOTE_CREATE_COMPOSER_CAPABILITIES: ChatComposerCapabilities = - new ChatComposerCapabilities(ChatSurface.Create, false, true, false, true); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatSurface.ets index 4a3940af24..284d69a2a4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatSurface.ets @@ -1,5 +1,4 @@ export enum ChatSurface { General = 'general', - Remote = 'remote', - Create = 'create' + Remote = 'remote' } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets index 3c34676197..1f291d191d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets @@ -125,6 +125,24 @@ export class ConversationCoreState { this.selectedImages = []; } + /** + * Drops the complete observable projection of one conversation context. + * + * Account logout, disconnect and workspace replacement must not rebuild this + * list field-by-field: leaving even one field behind makes the next surface + * look as if it still belongs to the previous identity. + */ + reset(): void { + this.sessions = []; + this.clearActiveSession(); + this.isBusy = false; + this.modelCatalog = RemoteUiState.emptyModelCatalog(); + this.selectedModelId = ''; + this.statusText = ''; + this.clearComposer(); + this.isVoiceListening = false; + } + setVoiceListening(isVoiceListening: boolean): void { this.isVoiceListening = isVoiceListening; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets index 04932cd9d2..d04ba9413a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets @@ -6,8 +6,7 @@ import { RemoteUiState } from '../../services/RemoteUiState'; import { ChatComposerCapabilities, GENERAL_CHAT_COMPOSER_CAPABILITIES, - REMOTE_CHAT_COMPOSER_CAPABILITIES, - REMOTE_CREATE_COMPOSER_CAPABILITIES + REMOTE_CHAT_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; import { ChatSurface } from './ChatSurface'; import { @@ -21,7 +20,6 @@ import { import { AppRoute } from '../navigation/AppRouteContract'; import { ConversationCoreState } from './ConversationCoreState'; import { GeneralChatPageState } from './GeneralChatPageState'; -import { RemoteCreateSessionState } from './RemoteCreateSessionState'; import { RemotePageState } from './RemotePageState'; /** Immutable presentation state consumed by ConversationView. */ @@ -55,34 +53,14 @@ export class ConversationViewState { route: AppRoute, remote: RemotePageState, general: GeneralChatPageState, - generalInlineStatus: string, - create: RemoteCreateSessionState = new RemoteCreateSessionState() + generalInlineStatus: string ): ConversationViewState { - if (route === AppRoute.RemoteCreate) { - return ConversationViewState.create(remote, create); - } if (route === AppRoute.RemoteChat) { return ConversationViewState.remote(remote); } return ConversationViewState.general(general, generalInlineStatus); } - private static create(remote: RemotePageState, create: RemoteCreateSessionState): ConversationViewState { - const state = new ConversationViewState(); - state.surface = ChatSurface.Create; - state.desktopName = remote.desktopName; - state.workspaceBranch = remote.workspaceBranch; - state.connectionState = 'connected'; - state.composerCapabilities = REMOTE_CREATE_COMPOSER_CAPABILITIES; - state.chatInput = create.draft; - state.isBusy = create.isSubmitting; - state.inlineStatusText = create.errorText; - state.isVoiceListening = create.isVoiceListening; - state.selectedModelId = create.selectedModelId; - state.modelCatalog = toConversationUiModelCatalog(remote.conversation.modelCatalog); - return state; - } - private static remote(remote: RemotePageState): ConversationViewState { const state = ConversationViewState.fromCore(remote.conversation); state.surface = ChatSurface.Remote; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets deleted file mode 100644 index 992c3b078e..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets +++ /dev/null @@ -1,152 +0,0 @@ -import { RecentWorkspaceEntry } from '../../model/RemoteModels'; -import { CloudAccountDevice } from '../../services/CloudAccountClient'; - -export type RemoteHarnessProfile = 'minimal' | 'balanced' | 'ultimate'; -export const REMOTE_HARNESS_PROFILES_CAPABILITY: string = 'harness_profiles_v1'; - -export class RemoteCreateSessionContext { - readonly deviceId: string; - readonly workspacePath: string; - readonly agentType: string; - - constructor(deviceId: string, workspacePath: string, agentType: string = 'Claw') { - this.deviceId = deviceId; - this.workspacePath = workspacePath; - this.agentType = agentType; - } -} - -@ObservedV2 -export class RemoteCreateSessionState { - @Trace draft: string = ''; - @Trace devices: CloudAccountDevice[] = []; - @Trace workspaces: RecentWorkspaceEntry[] = []; - @Trace selectedDeviceId: string = ''; - @Trace selectedDeviceName: string = ''; - @Trace selectedWorkspacePath: string = ''; - @Trace selectedWorkspaceName: string = ''; - @Trace openMenu: string = 'none'; - @Trace isLoadingDevices: boolean = false; - @Trace isLoadingWorkspaces: boolean = false; - @Trace isSubmitting: boolean = false; - @Trace isVoiceListening: boolean = false; - @Trace selectedModelId: string = ''; - @Trace supportsHarnessProfiles: boolean = false; - @Trace selectedHarnessProfile: RemoteHarnessProfile = 'balanced'; - @Trace errorText: string = ''; - - prepare( - deviceId: string, - deviceName: string, - selectedModelId: string = '', - supportsHarnessProfiles: boolean = false - ): void { - this.draft = ''; - this.devices = []; - this.workspaces = []; - this.selectedDeviceId = deviceId; - this.selectedDeviceName = deviceName; - this.selectedWorkspacePath = ''; - this.selectedWorkspaceName = ''; - this.openMenu = 'none'; - this.isLoadingDevices = false; - this.isLoadingWorkspaces = false; - this.isSubmitting = false; - this.isVoiceListening = false; - this.selectedModelId = selectedModelId; - this.supportsHarnessProfiles = supportsHarnessProfiles; - this.selectedHarnessProfile = 'balanced'; - this.errorText = ''; - } - - setDraft(draft: string): void { - this.draft = draft; - this.errorText = ''; - } - - setSelectedModelId(modelId: string): void { - this.selectedModelId = modelId; - this.errorText = ''; - } - - setSelectedHarnessProfile(profile: RemoteHarnessProfile): void { - this.selectedHarnessProfile = profile; - this.errorText = ''; - } - - setDevices(devices: CloudAccountDevice[]): void { - this.devices = devices.slice(); - const selected = devices.find((device: CloudAccountDevice): boolean => - device.deviceId === this.selectedDeviceId - ); - if (selected) { - this.selectedDeviceName = selected.deviceName; - } - this.isLoadingDevices = false; - } - - setWorkspaces(workspaces: RecentWorkspaceEntry[]): void { - this.workspaces = workspaces.slice(); - const selected = workspaces.find((workspace: RecentWorkspaceEntry): boolean => - workspace.path === this.selectedWorkspacePath - ); - if (selected) { - this.selectedWorkspaceName = selected.name; - } - this.isLoadingWorkspaces = false; - } - - selectDevice(device: CloudAccountDevice, supportsHarnessProfiles: boolean = false): void { - this.selectedDeviceId = device.deviceId; - this.selectedDeviceName = device.deviceName; - this.supportsHarnessProfiles = supportsHarnessProfiles; - this.selectedHarnessProfile = 'balanced'; - this.clearWorkspace(); - this.openMenu = 'none'; - this.errorText = ''; - } - - selectWorkspace(workspace?: RecentWorkspaceEntry): void { - this.selectedWorkspacePath = workspace?.path || ''; - this.selectedWorkspaceName = workspace?.name || ''; - this.openMenu = 'none'; - this.errorText = ''; - } - - /** - * The desktop binds every Claw session to its assistant workspace and ignores - * the requested workspace_path, so a picked workspace only holds when it is - * paired with the code agent. No workspace means the chat option, which is - * what Claw is for. - */ - submissionContext(): RemoteCreateSessionContext { - return new RemoteCreateSessionContext( - this.selectedDeviceId, - this.selectedWorkspacePath, - this.selectedWorkspacePath.length > 0 ? this.harnessAgentType() : 'Claw' - ); - } - - private harnessAgentType(): string { - if (this.selectedHarnessProfile === 'minimal') { - return 'minimal'; - } - if (this.selectedHarnessProfile === 'ultimate') { - return 'Ultra'; - } - return 'agentic'; - } - - clearWorkspace(): void { - this.selectedWorkspacePath = ''; - this.selectedWorkspaceName = ''; - } - - toggleMenu(menu: string): void { - this.openMenu = this.openMenu === menu ? 'none' : menu; - } - - closeMenu(): void { - this.openMenu = 'none'; - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index 6cc8eb22cd..f07b1c116e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets @@ -319,6 +319,20 @@ export class RemotePageState { this.hostCapabilities = []; } + /** Clears every conversation-scoped value when its remote identity ends. */ + resetConversationContext(): void { + this.conversation.reset(); + this.hasMoreSessions = false; + this.isLoadingSessions = false; + this.isLoadingConversation = false; + this.pendingSessionId = ''; + this.isConversationDismissed = false; + this.isLoadingHome = false; + this.sessionErrorText = ''; + this.hostCapabilities = []; + this.clearDownloadStatus(); + } + clearWorkspaceActions(): void { this.recentWorkspaces = []; this.assistants = []; 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 c483145e3a..0d6d9d291d 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 @@ -4,13 +4,11 @@ import { SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; -import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ChatTimelineItem } from '../../model/ChatTimelineModels'; import { VoiceInputRouteSnapshot } from '../../services/VoiceInputLifecycleController'; import { AppRootRouteState } from '../navigation/AppRootRouteState'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; -import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; import { @@ -32,7 +30,6 @@ export { export class ConversationController { private readonly general: GeneralChatPageState; private readonly remote: RemotePageState; - private readonly remoteCreate: RemoteCreateSessionState; private readonly hooks: ConversationControllerHooks; private readonly remoteRuntime?: RemoteConversationDependencies; private readonly transcript: RemoteTranscriptController; @@ -42,19 +39,17 @@ export class ConversationController { constructor( general: GeneralChatPageState, remote: RemotePageState, - remoteCreate: RemoteCreateSessionState, hooks: ConversationControllerHooks, remoteRuntime?: RemoteConversationDependencies ) { this.general = general; this.remote = remote; - this.remoteCreate = remoteCreate; this.hooks = hooks; this.remoteRuntime = remoteRuntime; this.transcript = new RemoteTranscriptController(remote, remoteRuntime, (message: string) => { this.showHomeToast(message); }); - this.createFlow = new RemoteCreateFlowController(remote, remoteCreate, remoteRuntime); + this.createFlow = new RemoteCreateFlowController(remote, remoteRuntime); this.visible = new VisibleConversationController( general, remote, @@ -69,9 +64,7 @@ export class ConversationController { } visibleChatInput(): string { - const route = this.hooks.currentRoute(); - return route === AppRoute.RemoteCreate ? this.remoteCreate.draft : - AppRootRouteState.chatInput(route, this.general, this.remote); + return AppRootRouteState.chatInput(this.hooks.currentRoute(), this.general, this.remote); } visibleSelectedImages(): SelectedImageAttachment[] { @@ -79,16 +72,10 @@ export class ConversationController { } visibleVoiceListening(): boolean { - const route = this.hooks.currentRoute(); - return route === AppRoute.RemoteCreate ? this.remoteCreate.isVoiceListening : - AppRootRouteState.voiceListening(route, this.general, this.remote); + return AppRootRouteState.voiceListening(this.hooks.currentRoute(), this.general, this.remote); } setChatInput(route: AppRoute, value: string): void { - if (route === AppRoute.RemoteCreate) { - this.remoteCreate.setDraft(value); - return; - } AppRootRouteState.setChatInput(route, value, this.general, this.remote); } @@ -101,17 +88,12 @@ export class ConversationController { } setVoiceListening(route: AppRoute, isVoiceListening: boolean): void { - if (route === AppRoute.RemoteCreate) { - this.remoteCreate.isVoiceListening = isVoiceListening; - return; - } AppRootRouteState.setVoiceListening(route, isVoiceListening, this.general, this.remote); } clearAllVoiceListening(): void { this.general.setVoiceListening(false); this.remote.setVoiceListening(false); - this.remoteCreate.isVoiceListening = false; } visibleBusy(): boolean { @@ -133,15 +115,6 @@ export class ConversationController { } voiceInputSnapshot(route: AppRoute): VoiceInputRouteSnapshot { - if (route === AppRoute.RemoteCreate) { - return { - routeId: `${route}`, - isListening: this.remoteCreate.isVoiceListening, - isBusy: this.remoteCreate.isSubmitting, - inputText: this.remoteCreate.draft, - selectedImageCount: 0 - }; - } return AppRootRouteState.snapshot(route, this.visibleBusy(), this.general, this.remote); } @@ -187,22 +160,6 @@ export class ConversationController { async createRemoteSession(agentType: string, inPlace: boolean = false): Promise { await this.createFlow.createRemoteSession(agentType, inPlace); } - openRemoteCreateSession(): void { this.createFlow.openRemoteCreateSession(); } - async openRemoteCreateSessionInWorkspace(path: string, deviceId?: string): Promise { - await this.createFlow.openRemoteCreateSessionInWorkspace(path, deviceId); - } - closeRemoteCreateSession(): void { this.createFlow.closeRemoteCreateSession(); } - async loadRemoteCreateChoices(): Promise { await this.createFlow.loadRemoteCreateChoices(); } - async loadRemoteCreateModelCatalog(): Promise { await this.createFlow.loadRemoteCreateModelCatalog(); } - async loadRemoteCreateDevices(): Promise { await this.createFlow.loadRemoteCreateDevices(); } - async loadRemoteCreateWorkspaces(): Promise { await this.createFlow.loadRemoteCreateWorkspaces(); } - toggleRemoteCreateDevices(): void { this.createFlow.toggleRemoteCreateDevices(); } - toggleRemoteCreateWorkspaces(): void { this.createFlow.toggleRemoteCreateWorkspaces(); } - async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { - await this.createFlow.selectRemoteCreateDevice(device); - } - selectRemoteCreateWorkspace(path: string): void { this.createFlow.selectRemoteCreateWorkspace(path); } - async submitRemoteCreateSession(): Promise { await this.createFlow.submitRemoteCreateSession(); } async createRemoteSessionInWorkspace( path: string, agentType: string = 'code', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets index 50766953c1..e2f3a49bff 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets @@ -80,10 +80,6 @@ export function routeCreatedRemoteSession( if (remoteRuntime.appShell.isRoute(AppRoute.RemoteChat)) { return; } - if (remoteRuntime.appShell.isRoute(AppRoute.RemoteCreate)) { - remoteRuntime.appShell.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); - return; - } remoteRuntime.appShell.pushRoute(AppRoute.RemoteChat, sessionId); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets index 964f0d8b45..b2a96d58ec 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets @@ -137,11 +137,10 @@ export class DeviceDirectoryViewModel { if (this.state.find(this.state.selectedDeviceId)) { return; } - if (this.state.find(activeId)) { - this.state.select(activeId); - return; - } - this.state.select(activeId.length === 0 && next.length > 0 ? next[0].deviceId : ''); + // A live control target is transport state, not a user selection in the + // directory. Expanding it here made a freshly signed-in sidebar show a + // workspace loader before the user had chosen any device. + this.state.select(''); } async hydrateKnownDevices(): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteCreateFlowController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteCreateFlowController.ets index 9ff03a30e1..1ad6739070 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteCreateFlowController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteCreateFlowController.ets @@ -1,13 +1,7 @@ -import { RecentWorkspaceEntry, RemoteSession, SessionSummary } from '../../model/RemoteModels'; +import { RemoteSession, SessionSummary } from '../../model/RemoteModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RemoteLogger } from '../../services/RemoteLogger'; -import { RemoteUiState } from '../../services/RemoteUiState'; -import { AppRoute } from '../navigation/AppRouteContract'; -import { - REMOTE_HARNESS_PROFILES_CAPABILITY, - RemoteCreateSessionState -} from '../state/RemoteCreateSessionState'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteConversationDependencies, @@ -16,19 +10,16 @@ import { routeRemoteSessionInPlace } from './ConversationRuntime'; +/** Creates and opens remote sessions without an intermediate creation page. */ export class RemoteCreateFlowController { private readonly remote: RemotePageState; - private readonly remoteCreate: RemoteCreateSessionState; private readonly remoteRuntime?: RemoteConversationDependencies; - private remoteCreateWorkspaceLoadVersion: number = 0; constructor( remote: RemotePageState, - remoteCreate: RemoteCreateSessionState, remoteRuntime: RemoteConversationDependencies | undefined ) { this.remote = remote; - this.remoteCreate = remoteCreate; this.remoteRuntime = remoteRuntime; } @@ -55,263 +46,6 @@ export class RemoteCreateFlowController { ); } - openRemoteCreateSession(): void { - this.openRemoteCreateSessionWithWorkspace(); - } - - async openRemoteCreateSessionInWorkspace(path: string, deviceId?: string): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (!runtime.connection.ensureAvailable()) { - return; - } - try { - await this.ensureRemoteControlTarget(deviceId); - this.openRemoteCreateSessionWithWorkspace(path); - } catch (err) { - runtime.hooks.showToast(err instanceof Error ? err.message : String(err)); - } - } - - private openRemoteCreateSessionWithWorkspace(workspacePath: string = ''): void { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (!runtime.connection.ensureAvailable()) { - return; - } - const deviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; - const deviceName = this.remote.controlTargetDeviceName || this.remote.desktopName; - this.remoteCreate.prepare( - deviceId, - deviceName, - this.remote.selectedModelId, - this.remote.supportsHostCapability(REMOTE_HARNESS_PROFILES_CAPABILITY) - ); - if (deviceId.length > 0) { - this.remoteCreate.setDevices([{ - deviceId, - deviceName: deviceName || deviceId, - online: true - }]); - } - const workspaces = this.remote.recentWorkspaces; - this.remoteCreate.setWorkspaces(workspaces); - if (workspacePath.length > 0) { - const selected = workspaces.find((workspace: RecentWorkspaceEntry): boolean => - workspace.path === workspacePath - ); - this.remoteCreate.selectWorkspace(selected || { - path: workspacePath, - name: this.workspaceDisplayName(workspacePath), - lastOpened: '', - workspaceKind: 'normal' - }); - } - runtime.appShell.pushRoute(AppRoute.RemoteCreate); - this.loadRemoteCreateChoices(); - this.loadRemoteCreateModelCatalog(); - } - - private workspaceDisplayName(path: string): string { - const normalized = path.replace(/\/+$/, ''); - const separator = normalized.lastIndexOf('/'); - return separator >= 0 ? normalized.slice(separator + 1) : normalized; - } - - closeRemoteCreateSession(): void { - const runtime = requireRemoteRuntime(this.remoteRuntime); - this.remoteCreateWorkspaceLoadVersion += 1; - runtime.hooks.stopVoiceInput(); - this.remoteCreate.closeMenu(); - runtime.appShell.popRoute(AppRoute.RemoteHome); - } - - async loadRemoteCreateChoices(): Promise { - await Promise.all([ - this.loadRemoteCreateDevices(), - this.loadRemoteCreateWorkspaces() - ]); - } - - async loadRemoteCreateModelCatalog(): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (this.remote.modelCatalog.models.length > 0) { - return; - } - try { - const catalog = await runtime.sessionManager.getModelCatalog(); - const selectedModelId = RemoteUiState.selectedModelIdForCatalog(catalog, this.remote.selectedModelId); - this.remote.setModelCatalog(catalog, selectedModelId); - this.remoteCreate.setSelectedModelId(selectedModelId); - } catch (_err) { - // Model selection remains hidden when the remote does not expose a catalog. - } - } - - async loadRemoteCreateDevices(): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - this.remoteCreate.isLoadingDevices = this.remoteCreate.devices.length === 0; - try { - const phoneDeviceId = runtime.connection.getDeviceId(); - const accountDevices = await runtime.settings.listCloudAccountDevices(); - const devices = accountDevices.filter((device: CloudAccountDevice): boolean => - device.online && device.deviceId !== phoneDeviceId - ); - const currentId = this.remoteCreate.selectedDeviceId; - if (currentId.length > 0 && - !devices.some((device: CloudAccountDevice): boolean => device.deviceId === currentId)) { - devices.unshift({ - deviceId: currentId, - deviceName: this.remoteCreate.selectedDeviceName || currentId, - online: true - }); - } - this.remoteCreate.setDevices(devices); - } catch (_err) { - const currentId = this.remoteCreate.selectedDeviceId; - if (currentId.length > 0) { - this.remoteCreate.setDevices([{ - deviceId: currentId, - deviceName: this.remoteCreate.selectedDeviceName || currentId, - online: true - }]); - } else { - this.remoteCreate.setDevices([]); - } - this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceLoadFailed'); - } - } - - async loadRemoteCreateWorkspaces(): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - const loadVersion = ++this.remoteCreateWorkspaceLoadVersion; - const deviceId = this.remoteCreate.selectedDeviceId; - this.remoteCreate.isLoadingWorkspaces = this.remoteCreate.workspaces.length === 0; - try { - const workspaces = await runtime.workspace.recentWorkspaces(); - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreate.selectedDeviceId) { - return; - } - this.remoteCreate.setWorkspaces(workspaces); - } catch (_err) { - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreate.selectedDeviceId) { - return; - } - this.remoteCreate.setWorkspaces([]); - this.remoteCreate.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); - } - } - - toggleRemoteCreateDevices(): void { - this.remoteCreate.toggleMenu('devices'); - if (this.remoteCreate.openMenu === 'devices' && this.remoteCreate.devices.length === 0) { - this.loadRemoteCreateDevices(); - } - } - - toggleRemoteCreateWorkspaces(): void { - this.remoteCreate.toggleMenu('workspaces'); - if (this.remoteCreate.openMenu === 'workspaces' && this.remoteCreate.workspaces.length === 0) { - this.loadRemoteCreateWorkspaces(); - } - } - - async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (device.deviceId === this.remoteCreate.selectedDeviceId) { - this.remoteCreate.closeMenu(); - return; - } - const draft = this.remoteCreate.draft; - this.remoteCreate.closeMenu(); - this.remoteCreate.isLoadingWorkspaces = true; - try { - await runtime.settings.selectCloudAccountDevice(device, false); - this.remoteCreate.selectDevice( - device, - this.remote.supportsHostCapability(REMOTE_HARNESS_PROFILES_CAPABILITY) - ); - this.remoteCreate.setDraft(draft); - await this.loadRemoteCreateWorkspaces(); - } catch (err) { - this.remoteCreate.isLoadingWorkspaces = false; - this.remoteCreate.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceSwitchFailed'); - } - } - - selectRemoteCreateWorkspace(path: string): void { - const workspace = this.remoteCreate.workspaces - .find((item: RecentWorkspaceEntry): boolean => item.path === path); - this.remoteCreate.selectWorkspace(workspace); - } - - async submitRemoteCreateSession(): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - const instruction = this.remoteCreate.draft.trim(); - if (instruction.length === 0 || this.remoteCreate.isSubmitting || !runtime.connection.ensureAvailable()) { - return; - } - const context = this.remoteCreate.submissionContext(); - const activeDeviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; - if (context.deviceId.length === 0 || context.deviceId !== activeDeviceId) { - this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceMismatch'); - return; - } - this.remoteCreate.isSubmitting = true; - this.remoteCreate.errorText = ''; - this.remoteCreate.closeMenu(); - try { - if (context.workspacePath.length > 0) { - await runtime.sessions.createSessionInWorkspace( - context.workspacePath, - instruction, - context.agentType, - undefined, - this.remoteCreate.selectedModelId - ); - } else { - await this.bindAssistantWorkspace(); - await runtime.sessions.createSession( - context.agentType, - instruction, - undefined, - this.remoteCreate.selectedModelId - ); - } - if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { - this.remoteCreate.errorText = this.remote.statusText || RemoteI18n.t('remote.create.submitFailed'); - } - } catch (err) { - this.remoteCreate.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.create.submitFailed'); - } finally { - this.remoteCreate.isSubmitting = false; - } - } - - /** - * The chat option creates a Claw session, and the desktop always binds those - * to its assistant workspace. Follow it there first, otherwise the app stays - * bound to the code workspace it was on and the new chat is listed, titled - * and file-scoped as if it had been created inside that workspace. - */ - private async bindAssistantWorkspace(): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (this.remote.workspaceKind === 'assistant') { - return; - } - try { - const assistants = await runtime.workspace.assistants(); - if (assistants.length === 0) { - return; - } - await runtime.hooks.selectAssistantWorkspace(assistants[0].path); - } catch (err) { - RemoteLogger.warn(`assistant workspace bind failed: ${String(err)}`); - } - } - async ensureRemoteControlTarget(deviceId?: string): Promise { const target = (deviceId || '').trim(); if (target.length === 0) { @@ -332,12 +66,6 @@ export class RemoteCreateFlowController { if (!device) { throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); } - // The only visible sign that anything is happening. This switch is - // implicit — the user tapped a session, not a device — and it runs behind - // whatever surface the sidebar just closed over, which has no place to - // show a connection state. `isLoadingHome` looks like the answer and is - // not: the only thing that reads it is the RemoteHome route, and this - // path never routes there. runtime.hooks.showToast(RemoteI18n.f('remote.settings.deviceSwitching', device.deviceName || target)); await runtime.settings.selectCloudAccountDevice(device, false); } catch (err) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets index aee72f4eb1..0478581698 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets @@ -18,6 +18,7 @@ export interface RemoteSessionViewModelHooks { readonly onRouteHome: () => void; readonly onStopPolling: () => void; readonly onStartPolling: () => void; + readonly onNudgePolling: () => void; readonly onResetTimeline: (sessionId: string) => void; readonly onClearRemoteFiles: () => void; readonly onKnownStateReset: () => void; @@ -101,16 +102,17 @@ export class RemoteSessionViewModel { agentType: string, instruction: string = '', onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat, - modelId: string = '' + modelId: string = '', + routeBeforeCreate: boolean = true ): Promise { if (this.hooks.isBusy() || !this.hooks.remoteAvailable()) { return; } const previousSession: SessionSummary = this.copySession(this.pageState.activeSession); - this.beginSessionCreation(onRouteChat); + this.beginSessionCreation(onRouteChat, routeBeforeCreate); try { const created = await this.performCreateSession(agentType, instruction, onRouteChat, modelId); - if (!created) { + if (!created && routeBeforeCreate) { this.restoreAfterFailedCreation(previousSession, onRouteChat); } } finally { @@ -126,7 +128,7 @@ export class RemoteSessionViewModel { modelId: string, workspacePath: string = '' ): Promise { - return await this.sessions.create( + const created = await this.sessions.create( agentType, this.hooks.isBusy(), this.hooks.remoteAvailable(), @@ -136,6 +138,10 @@ export class RemoteSessionViewModel { this.hooks.onKnownStateReset(); this.hooks.onResetTimeline(session.sessionId); onRouteChat(session.sessionId); + // The create surface has already handed its optimistic instruction to + // the remote projection. Do not cover it with a loading veil while the + // empty initial transcript and first turn are being synchronized. + this.pageState.setConversationLoading(false); // Same ordering as openSession: whatever the new session already has to // show goes up first. The model picker needs no request of its own — // the poll started here carries the catalog back with it. @@ -147,6 +153,14 @@ export class RemoteSessionViewModel { modelId, workspacePath ); + if (created && (created.initialTurnId || '').length > 0) { + // The first poll may have observed the newly allocated session before + // send_message reached the host and backed off as idle. Wake it as soon + // as the initial turn is accepted instead of hiding up to ten seconds of + // otherwise available streaming output. + this.hooks.onNudgePolling(); + } + return created; } async createSessionInWorkspace( @@ -154,17 +168,18 @@ export class RemoteSessionViewModel { instruction: string = '', agentType: string = 'code', onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat, - modelId: string = '' + modelId: string = '', + routeBeforeCreate: boolean = true ): Promise { if (this.hooks.isBusy() || !this.hooks.remoteAvailable()) { return; } const previousSession: SessionSummary = this.copySession(this.pageState.activeSession); - this.beginSessionCreation(onRouteChat); + this.beginSessionCreation(onRouteChat, routeBeforeCreate); let created: SessionSummary | undefined; try { created = await this.performCreateSession(agentType, instruction, onRouteChat, modelId, path); - if (!created) { + if (!created && routeBeforeCreate) { this.restoreAfterFailedCreation(previousSession, onRouteChat); } } finally { @@ -173,11 +188,16 @@ export class RemoteSessionViewModel { } } - private beginSessionCreation(onRouteChat: (sessionId: string) => void): void { + private beginSessionCreation( + onRouteChat: (sessionId: string) => void, + routeBeforeCreate: boolean + ): void { this.hooks.onStopPolling(); this.pageState.setPendingSessionId(''); this.pageState.setConversationLoading(true); - onRouteChat(''); + if (routeBeforeCreate) { + onRouteChat(''); + } } private restoreAfterFailedCreation( diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets index dc18f81c97..cab23a76cf 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -73,10 +73,13 @@ export class SettingsController { private readonly cloud?: CloudAccountSettingsDependencies; private cloudSession?: CloudAccountSession; private cloudRelayUrl: string = ''; + /** Account login identity; pairing projections must never overwrite it. */ + private cloudAccountUsername: string = ''; private accountDevices: CloudAccountDevice[] = []; private lastAccountDevicesAt: number = 0; private presenceTicker?: RemoteHeartbeatController; private restoringControlTarget: boolean = false; + private accountDeviceSelectionVersion: number = 0; constructor( store: GeneralChatConfigStore, @@ -216,7 +219,7 @@ export class SettingsController { const cloud = this.requireCloud(); try { if (password.length > 0) { - const username = cloud.remoteState.accountUsername.trim(); + const username = this.cloudAccountUsername; if (username.length === 0) { throw new Error('The signed-in account name is unavailable.'); } @@ -260,9 +263,10 @@ export class SettingsController { RemoteLogger.info('phone account may not mint a device credential; deferring to the desktop'); return undefined; } - if (err instanceof CloudAccountRequestError && (err.statusCode === 404 || err.statusCode >= 500) && - cloud.remoteState.accountUsername.trim().length > 0) { - RemoteLogger.info('relay device provisioning unavailable; requesting account confirmation'); + if (err instanceof CloudAccountRequestError && + (err.statusCode === 404 || err.statusCode === 409 || err.statusCode >= 500) && + this.cloudAccountUsername.length > 0) { + RemoteLogger.info('relay cannot provision watch directly; requesting account confirmation'); return { ok: false, passwordRequired: true, @@ -313,17 +317,15 @@ export class SettingsController { const remoteState = this.requireCloud().remoteState; this.cloudSession = session; this.cloudRelayUrl = relayUrl.trim(); + this.cloudAccountUsername = username.trim(); remoteState.setAccountUserId(session.userId); - remoteState.setAccountUsername(username.trim()); + remoteState.setAccountUsername(this.cloudAccountUsername); } async logoutCloudAccount(): Promise { const cloud = this.requireCloud(); cloud.hooks.invalidatePreview(); - if (cloud.remoteState.controlTargetType === 'account_device') { - this.resetAccountDeviceConnection(true); - } - this.clearCloudAccountProjection(cloud); + this.teardownCloudAccountProjection(cloud); this.store.replaceAccountModels([]); await this.refreshModelCatalog(); await cloud.sessionStore.clear(); @@ -549,6 +551,7 @@ export class SettingsController { if (deviceId.length === 0 || deviceId === cloud.hooks.deviceId()) { return; } + const selectionVersion = ++this.accountDeviceSelectionVersion; if (deviceId === cloud.remoteState.controlTargetDeviceId && cloud.remoteState.connectionState === 'connected') { cloud.hooks.closeConnectSheet(); if (navigateHome) { @@ -567,6 +570,10 @@ export class SettingsController { session, deviceId ); + if (selectionVersion !== this.accountDeviceSelectionVersion) { + RemoteLogger.info(`stale device switch completion ignored device=${deviceId}`); + return; + } cloud.remoteState.setControlTarget('account_device', deviceId, device.deviceName); cloud.remoteState.setDesktopIdentity(device.deviceName, deviceId); cloud.remoteState.setWorkspace( @@ -609,6 +616,10 @@ export class SettingsController { // the cross-workspace scan runs behind a list that is already usable. void cloud.hooks.loadRecentWorkspaces(); } catch (err) { + if (selectionVersion !== this.accountDeviceSelectionVersion) { + RemoteLogger.info(`stale device switch failure ignored device=${deviceId}`); + return; + } if (err instanceof CloudAccountRequestError && err.statusCode === 401) { await this.expireCloudAccountSession(); } @@ -620,8 +631,10 @@ export class SettingsController { await this.restoreControlTarget(previousTarget); throw new Error(message); } finally { - cloud.remoteState.setLoadingHome(false); - cloud.remoteState.setBusy(false); + if (selectionVersion === this.accountDeviceSelectionVersion) { + cloud.remoteState.setLoadingHome(false); + cloud.remoteState.setBusy(false); + } } } @@ -779,13 +792,10 @@ export class SettingsController { private async expireCloudAccountSession(): Promise { const cloud = this.requireCloud(); cloud.hooks.invalidatePreview(); - if (cloud.remoteState.controlTargetType === 'account_device') { - this.resetAccountDeviceConnection(true); - } // Publish the signed-out projection before awaiting storage. A revoked // credential must never leave stale account devices actionable merely // because preferences or cache cleanup is slow (or fails). - this.clearCloudAccountProjection(cloud); + this.teardownCloudAccountProjection(cloud); this.store.replaceAccountModels([]); await this.refreshModelCatalog(); await cloud.sessionStore.clear(); @@ -793,11 +803,22 @@ export class SettingsController { RemoteLogger.info('cloud account session expired'); } - /** Drops every observable fact owned by the account session. */ - private clearCloudAccountProjection(cloud: CloudAccountSettingsDependencies): void { + /** Publishes the complete signed-out projection as one synchronous transition. */ + private teardownCloudAccountProjection(cloud: CloudAccountSettingsDependencies): void { + // Invalidate late device-selection completions before touching observable + // state. During an in-flight switch the target is deliberately `none`, but + // its busy/status/session projection still belongs to the account. + this.accountDeviceSelectionVersion += 1; + this.resetAccountDeviceConnection(true); + this.clearCloudAccountIdentity(cloud); + } + + /** Drops the remaining account identity facts after remote teardown. */ + private clearCloudAccountIdentity(cloud: CloudAccountSettingsDependencies): void { this.stopPresencePolling(); this.cloudSession = undefined; this.cloudRelayUrl = ''; + this.cloudAccountUsername = ''; this.accountDevices = []; this.lastAccountDevicesAt = 0; cloud.hooks.onAccountDevices([]); @@ -831,9 +852,9 @@ export class SettingsController { cloud.hooks.stopPolling(); cloud.hooks.stopHeartbeat(); cloud.sessionManager.reset(); - cloud.remoteState.setHostCapabilities([]); - cloud.remoteState.clearActiveSession(); - cloud.remoteState.setSessions([], false); + cloud.remoteState.resetConversationContext(); + cloud.hooks.resetTimeline(); + cloud.hooks.resetKnownRemoteState(); if (clearWorkspace) { cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); cloud.remoteState.setAuthenticatedUserId(''); 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 ae38647e62..f2cec22491 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 @@ -17,9 +17,16 @@ export class ChatTimelineProjector { hasMoreMessages: boolean, activeTurnAnchorId: string = '' ): ChatTimelineItem[] { - const timelineMessages = ChatTimelineProjector.realMessages(messages); + const persistedMessages = ChatTimelineProjector.realMessages(messages); + const shouldRenderActiveTurn = ChatTimelineProjector.shouldRenderActiveTurn(persistedMessages, activeTurn); + // A running turn can be delivered both as an active snapshot and as a + // persisted assistant stub containing only thinking/tools. They represent + // one visual row and share one stable turn key, so retaining both would + // violate the uniqueness contract required by Repeat.virtualScroll(). + const timelineMessages = shouldRenderActiveTurn ? persistedMessages.filter((message: ChatMessage) => { + return !ChatTimelineProjector.isMatchingAssistantTurn(activeTurn, message); + }) : persistedMessages; const pendingItems = ChatTimelineProjector.pendingMessagesNotPersisted(pendingMessages, timelineMessages); - const shouldRenderActiveTurn = ChatTimelineProjector.shouldRenderActiveTurn(timelineMessages, activeTurn); const anchorIndex = ChatTimelineProjector.anchorIndex(pendingItems, activeTurnAnchorId); const items: ChatTimelineItem[] = timelineMessages.map((message: ChatMessage) => { const item: ChatTimelineItem = { @@ -169,10 +176,14 @@ export class ChatTimelineProjector { } private static isPersistedAssistantDuplicate(activeTurn: ChatMessage, message: ChatMessage): boolean { - if (message.role !== 'assistant') { + if (!ChatTimelineProjector.hasDisplayableAssistantFinal(message)) { return false; } - if (!ChatTimelineProjector.hasDisplayableAssistantFinal(message)) { + return ChatTimelineProjector.isMatchingAssistantTurn(activeTurn, message); + } + + private static isMatchingAssistantTurn(activeTurn: ChatMessage, message: ChatMessage): boolean { + if (message.role !== 'assistant') { return false; } if (message.id === activeTurn.id) { 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 8a3036bf0f..9929543163 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 @@ -792,7 +792,27 @@ export class ChatTimelineStore { return 0; }; // A delayed poll must not move a completed tool back to running/pending. - return rank(incomingStatus) < rank(previousStatus) ? previous : incoming; + if (rank(incomingStatus) < rank(previousStatus)) { + return previous; + } + // Completion snapshots from older desktop builds may carry only status and + // result fields. Preserve the invocation metadata already observed while + // the tool was running so Task/subagent titles do not collapse to "Task". + return { + id: incoming.id !== undefined ? incoming.id : previous.id, + name: incoming.name !== undefined ? incoming.name : previous.name, + status: incoming.status !== undefined ? incoming.status : previous.status, + duration_ms: incoming.duration_ms !== undefined ? incoming.duration_ms : previous.duration_ms, + start_ms: incoming.start_ms !== undefined ? incoming.start_ms : previous.start_ms, + input_preview: (incoming.input_preview || '').length > 0 ? incoming.input_preview : previous.input_preview, + tool_input: incoming.tool_input !== undefined ? incoming.tool_input : previous.tool_input, + stdout: (incoming.stdout || '').length > 0 ? incoming.stdout : previous.stdout, + stderr: (incoming.stderr || '').length > 0 ? incoming.stderr : previous.stderr, + 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 + }; } private static isActiveTurnId(id: string): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets index d77570d1d9..2f03a991e8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets @@ -11,7 +11,10 @@ import { RemoteUiState } from './RemoteUiState'; export interface RemoteSessionClient { listSessions(limit: number, offset: number, query: string, agentType: string): Promise; - createSession(options: CreateSessionOptions): Promise; + createSession( + options: CreateSessionOptions, + onAllocated?: (session: SessionSummary) => Promise + ): Promise; deleteSession(sessionId: string): Promise; } @@ -149,16 +152,27 @@ export class RemoteSessionController { try { this.callbacks.onBusy(true); this.callbacks.onStatusText(RemoteI18n.t('status.createSession')); + let allocationHandled = false; + const handleAllocated = async (session: SessionSummary): Promise => { + allocationHandled = true; + this.callbacks.onActiveSession(session); + this.callbacks.onStatusText(RemoteI18n.t('status.sessionCreated')); + await onCreated(session); + }; const session = await this.client.createSession({ agentType, title: '', instruction, modelId, workspacePath - }); - this.callbacks.onActiveSession(session); - this.callbacks.onStatusText(RemoteI18n.t('status.sessionCreated')); - await onCreated(session); + }, handleAllocated); + if (!allocationHandled) { + await handleAllocated(session); + } else { + // Allocation routes immediately. Refresh the summary after the model + // and initial turn settle without rebuilding the conversation again. + this.callbacks.onActiveSession(session); + } return session; } catch (err) { this.callbacks.onStatusText(ConnectionErrorPolicy.errorText(err)); 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 6745e10a6c..9b6a078274 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 @@ -241,19 +241,30 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile }; } - async createSession(options: CreateSessionOptions): Promise { + async createSession( + options: CreateSessionOptions, + onAllocated?: (session: SessionSummary) => Promise + ): Promise { const workspacePath = options.workspacePath?.trim() || this.workspace?.path || ''; const command = RemoteCommandFactory.createSession(options, workspacePath); const response = await this.send(command); const sessionId = response.session_id || response.id || ''; - const modelId = options.modelId?.trim() || ''; - if (modelId.length > 0) { - await this.setSessionModel(sessionId, modelId); - } const normalizedAgentType = options.agentType.toLowerCase(); const fallbackTitle = normalizedAgentType === 'claw' || normalizedAgentType === 'assistant' || normalizedAgentType === 'chat' ? 'Assistant Session' : normalizedAgentType === 'cowork' ? 'Cowork Session' : 'Code Session'; const title = response.title || fallbackTitle; + if (onAllocated) { + await onAllocated({ + sessionId, + title, + workspacePath, + agentType: options.agentType + }); + } + const modelId = options.modelId?.trim() || ''; + if (modelId.length > 0) { + await this.setSessionModel(sessionId, modelId); + } let initialTurnId = ''; if (options.instruction.trim().length > 0) { initialTurnId = await this.sendMessage(sessionId, options.instruction.trim(), options.agentType); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets index 55428e79aa..32cdf163f5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets @@ -213,7 +213,7 @@ export class WatchProvisionController { this.state.requirePassword(RemoteI18n.t('watchProvision.errors.passwordFailed')); return false; } - await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.desktopUnreachable')); + await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.accountUnavailable')); return true; } if (outcome.passwordRequired) { @@ -224,8 +224,9 @@ export class WatchProvisionController { // A desktop that answered and refused knows why; a desktop that never // answered is either offline or on a build that does not know this // command, and the copy has to cover both because they look the same. - const message = outcome.desktopReported && outcome.failure.length > 0 ? - outcome.failure : RemoteI18n.t('watchProvision.errors.desktopUnreachable'); + const message = outcome.desktopReported ? + RemoteI18n.t('watchProvision.errors.desktopAuthorizationFailed') : + RemoteI18n.t('watchProvision.errors.desktopUnreachable'); await this.failAttempt(request.requestId, message); return true; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatEventMapper.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatEventMapper.ets index 4f3d6bf692..1ea540651c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatEventMapper.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatEventMapper.ets @@ -143,7 +143,8 @@ export class GeneralChatEventMapper { id: toolId.length > 0 ? toolId : eventId, type: 'tool', content: '', - tool: incoming + tool: index < 0 || !items[index].tool ? incoming : + GeneralChatEventMapper.mergeTool(items[index].tool as RemoteToolStatusResponse, incoming) }; if (index < 0) { return items.concat([mapped]); @@ -163,10 +164,31 @@ export class GeneralChatEventMapper { return tools.concat([incoming]); } const next = tools.slice(); - next[index] = incoming; + next[index] = GeneralChatEventMapper.mergeTool(tools[index], incoming); return next; } + private static mergeTool( + previous: RemoteToolStatusResponse, + incoming: RemoteToolStatusResponse + ): RemoteToolStatusResponse { + return { + id: incoming.id || previous.id, + name: incoming.name || previous.name, + status: incoming.status || previous.status, + duration_ms: incoming.duration_ms !== undefined ? incoming.duration_ms : previous.duration_ms, + start_ms: incoming.start_ms !== undefined ? incoming.start_ms : previous.start_ms, + input_preview: (incoming.input_preview || '').length > 0 ? incoming.input_preview : previous.input_preview, + tool_input: incoming.tool_input !== undefined ? incoming.tool_input : previous.tool_input, + stdout: incoming.stdout !== undefined ? incoming.stdout : previous.stdout, + stderr: incoming.stderr !== undefined ? incoming.stderr : previous.stderr, + tool_output: incoming.tool_output !== undefined ? incoming.tool_output : previous.tool_output, + result_preview: incoming.result_preview !== undefined ? incoming.result_preview : previous.result_preview, + error_preview: incoming.error_preview !== undefined ? incoming.error_preview : previous.error_preview, + exit_code: incoming.exit_code !== undefined ? incoming.exit_code : previous.exit_code + }; + } + private static mapTool(incoming: GeneralChatApiTool, eventType: string): RemoteToolStatusResponse { let status = incoming.status; if (eventType === 'permission.required') { diff --git a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets index 6eb3868fb4..1f0a99de0b 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets @@ -10,6 +10,10 @@ import { ChatMessage } from '../main/ets/model/RemoteModels'; import { AppShellViewModel } from '../main/ets/pages/viewmodel/AppShellViewModel'; import { AppNavigationBackAction } from '../main/ets/pages/navigation/AppRouteContract'; import { WideLayoutGeometry } from '../main/ets/pages/layout/WideLayoutGeometry'; +import { + FilePreviewLayout, + FilePreviewPlacement +} from '../main/ets/pages/policy/FilePreviewPlacementPolicy'; import { SidebarDirectoryPreviewPolicy } from '../main/ets/pages/policy/SidebarDirectoryPreviewPolicy'; import { AdaptiveLayoutInput } from '../main/ets/pages/policy/AdaptiveLayoutInput'; import { @@ -159,9 +163,20 @@ export default function architectureUnitTest() { }); it('keeps wide layout geometry pure and deterministic', 0, () => { + const focusPreview = new FilePreviewLayout(FilePreviewPlacement.WideFocusSplit); + const masterPreview = new FilePreviewLayout(FilePreviewPlacement.WideMasterPreviewFocus, 351); + const triplePreview = new FilePreviewLayout(FilePreviewPlacement.WideTriplePane); expect(WideLayoutGeometry.detailOffset(false, 24, 8)).assertEqual(24); expect(WideLayoutGeometry.detailOffset(true, 24, 8)).assertEqual(8); expect(WideLayoutGeometry.detailWidth(true, 900, 1200)).assertEqual(1200); + expect(WideLayoutGeometry.showsMasterRestoreButton(false, focusPreview)).assertTrue(); + expect(WideLayoutGeometry.showsMasterRestoreButton(true, triplePreview)).assertTrue(); + expect(WideLayoutGeometry.showsMasterRestoreButton(false, triplePreview)).assertFalse(); + expect(WideLayoutGeometry.restoreMasterClosesPreview(focusPreview)).assertTrue(); + expect(WideLayoutGeometry.masterPaneWidth(masterPreview, 344)).assertEqual(351); + expect(WideLayoutGeometry.showsMasterRestoreButton(false, masterPreview)).assertFalse(); + expect(WideLayoutGeometry.restoreMasterClosesPreview(masterPreview)).assertFalse(); + expect(WideLayoutGeometry.restoreMasterClosesPreview(triplePreview)).assertFalse(); expect(WideLayoutGeometry.areaLength('1080')).assertEqual(1080); expect(WideLayoutGeometry.areaLength('invalid')).assertEqual(0); }); 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 c1b4ded157..75101ddc15 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -4,12 +4,10 @@ import { ChatComposerPolicy, ComposerPrimaryAction } from '../main/ets/services/ import { ChatSurface } from '../main/ets/pages/state/ChatSurface'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, - REMOTE_CHAT_COMPOSER_CAPABILITIES, - REMOTE_CREATE_COMPOSER_CAPABILITIES + REMOTE_CHAT_COMPOSER_CAPABILITIES } from '../main/ets/pages/state/ChatComposerCapabilities'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; -import { RemoteCreateSessionState } from '../main/ets/pages/state/RemoteCreateSessionState'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; import { ConversationHeaderPolicy } from '../main/ets/pages/policy/ConversationHeaderPolicy'; @@ -27,7 +25,11 @@ 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 { ConversationUiMessage, ConversationUiToolStatus } from '../main/ets/pages/state/ConversationUiModels'; +import { + ConversationUiMessage, + ConversationUiMessageItem, + ConversationUiToolStatus +} from '../main/ets/pages/state/ConversationUiModels'; import { ConnectSheetLandingPolicy, ConnectSheetStatusPolicy, @@ -64,6 +66,13 @@ export default function conversationPresentationUnitTest() { }); describe('ThinkingPresentationPolicy', () => { + it('reserves enough width for every running dot and inter-dot gap', 0, () => { + expect(ThinkingPresentationPolicy.runningDotsWidth()).assertEqual(30); + expect(ThinkingPresentationPolicy.runningDotsWidth() >= + ThinkingPresentationPolicy.RUNNING_DOT_WIDTH * ThinkingPresentationPolicy.RUNNING_DOT_COUNT + + ThinkingPresentationPolicy.RUNNING_DOT_GAP * (ThinkingPresentationPolicy.RUNNING_DOT_COUNT - 1)).assertTrue(); + }); + it('treats active and running as in-progress thinking', 0, () => { expect(ThinkingPresentationPolicy.isRunning('active')).assertTrue(); expect(ThinkingPresentationPolicy.isRunning('running')).assertTrue(); @@ -241,6 +250,58 @@ export default function conversationPresentationUnitTest() { expect(groups[1].type).assertEqual('item'); }); + it('routes Task tools through the subagent item renderer instead of the generic tool list', 0, () => { + const task = toolStatus('task-1', 'Task', 'running'); + task.input_preview = '{"description":"Read the README introduction"}'; + const groups = ChatMessageStructurePolicy.structuredGroups([ + { type: 'tool', tool: task, content: 'Running task' } + ], 'item', true, 'active'); + expect(groups.length).assertEqual(1); + expect(groups[0].type).assertEqual('item'); + expect(groups[0].items.length).assertEqual(1); + expect(groups[0].tools.length).assertEqual(0); + expect(ChatMessageStructurePolicy.subagentTitle(groups[0].items[0])) + .assertEqual('Read the README introduction'); + }); + + it('nests marked subagent execution inside its Task while preserving interleaved parent tools', 0, () => { + const task = toolStatus('task-1', 'Task', 'running'); + const internalRead = toolStatus('read-1', 'Read', 'running'); + const directRead = toolStatus('read-parent', 'Read', 'completed'); + const groups = ChatMessageStructurePolicy.structuredGroups([ + { type: 'tool', tool: task }, + { type: 'tool', tool: directRead }, + { type: 'thinking', content: 'Inspect child files', is_subagent: true }, + { type: 'tool', tool: internalRead, is_subagent: true } + ], 'item', true, 'active'); + expect(groups.length).assertEqual(2); + expect(groups[0].items.length).assertEqual(1); + expect(groups[0].items[0].tool?.name || '').assertEqual('Task'); + expect(groups[0].items[0].subItems?.length || 0).assertEqual(2); + expect(groups[1].tools.length).assertEqual(1); + expect(groups[1].tools[0].id).assertEqual('read-parent'); + }); + + it('scopes legacy unmarked process output only while a Task is running', 0, () => { + const runningTask = toolStatus('task-running', 'Task', 'running'); + const childRead = toolStatus('child-read', 'Read', 'running'); + const scoped = ChatMessageStructurePolicy.scopeSubagentItems([ + { type: 'tool', tool: runningTask }, + { type: 'thinking', content: 'Legacy child thinking' }, + { type: 'tool', tool: childRead } + ]); + expect(scoped.length).assertEqual(1); + expect(scoped[0].subItems?.length || 0).assertEqual(2); + + const completedTask = toolStatus('task-done', 'Task', 'completed'); + const parentOutput = ChatMessageStructurePolicy.scopeSubagentItems([ + { type: 'tool', tool: completedTask }, + { type: 'thinking', content: 'Parent resumes' } + ]); + expect(parentOutput.length).assertEqual(2); + expect(parentOutput[0].subItems?.length || 0).assertEqual(0); + }); + it('keeps one plain assistant text item on the lightweight markdown path', 0, () => { expect(ChatMessageStructurePolicy.requiresStructuredRendering([ { type: 'text', content: 'Hello' } @@ -254,6 +315,46 @@ export default function conversationPresentationUnitTest() { ])).assertTrue(); }); + it('peels a trailing composite reply onto a stable streaming markdown leaf', 0, () => { + const items: ConversationUiMessageItem[] = [ + { type: 'thinking', content: 'Inspect' }, + { type: 'tool', tool: toolStatus('read-1', 'Read', 'completed') }, + { type: 'text', content: 'Growing answer' } + ]; + const processItems = ChatMessageStructurePolicy.withoutTrailingTopLevelText(items); + expect(processItems.length).assertEqual(2); + expect(ChatMessageStructurePolicy.trailingTopLevelText(items)).assertEqual('Growing answer'); + + const toolAfterText: ConversationUiMessageItem[] = [ + { type: 'text', content: 'Intermediate note' }, + { type: 'tool', tool: toolStatus('write-1', 'Write', 'running') } + ]; + expect(ChatMessageStructurePolicy.trailingTopLevelText(toolAfterText)).assertEqual(''); + expect(ChatMessageStructurePolicy.withoutTrailingTopLevelText(toolAfterText).length).assertEqual(2); + }); + + it('invalidates structured rendering when tool topology changes without keying growing reply text', 0, () => { + const first: ConversationUiMessageItem[] = [ + { type: 'thinking', content: 'Inspect' }, + { type: 'tool', tool: toolStatus('skill-1', 'Skill', 'running') } + ]; + const moreTools: ConversationUiMessageItem[] = [ + { type: 'thinking', content: 'Inspect' }, + { type: 'tool', tool: toolStatus('skill-1', 'Skill', 'completed') }, + { type: 'tool', tool: toolStatus('read-1', 'Read', 'running') } + ]; + const firstKey = ChatMessageStructurePolicy.structuredRenderKey(first, 'item', true, 'active'); + const moreToolsKey = ChatMessageStructurePolicy.structuredRenderKey(moreTools, 'item', true, 'active'); + expect(firstKey === moreToolsKey).assertFalse(); + + const replyA = moreTools.concat([{ type: 'text', content: 'A' }]); + const replyB = moreTools.concat([{ type: 'text', content: 'A growing answer' }]); + const processA = ChatMessageStructurePolicy.withoutTrailingTopLevelText(replyA); + const processB = ChatMessageStructurePolicy.withoutTrailingTopLevelText(replyB); + expect(ChatMessageStructurePolicy.structuredRenderKey(processA, 'item', true, 'active')) + .assertEqual(ChatMessageStructurePolicy.structuredRenderKey(processB, 'item', true, 'active')); + }); + it('does not render a top-level tool twice when structured items already contain it', 0, () => { const covered = toolStatus('read-1', 'Read', 'completed'); const extra = toolStatus('exec-1', 'ExecCommand', 'completed'); @@ -360,11 +461,37 @@ export default function conversationPresentationUnitTest() { expect(groups[3].tools[0].name).assertEqual('Write'); }); - it('expands a subagent card only while that task is running', 0, () => { - expect(ToolCollapsePolicy.defaultSubagentExpanded(true)).assertTrue(); - expect(ToolCollapsePolicy.defaultSubagentExpanded(false)).assertFalse(); - expect(ToolCollapsePolicy.subagentExpandedForStatus(true, true, false)).assertFalse(); - expect(ToolCollapsePolicy.subagentExpandedForStatus(false, true, true)).assertFalse(); + it('presents subagent progress as a plain label without repeating it as body copy', 0, () => { + const progress: ConversationUiMessageItem = { + type: 'subagent', + is_subagent: true, + content: '**Planning exploration of AGENTS subcrates**' + }; + expect(ChatMessageStructurePolicy.subagentTitle(progress)) + .assertEqual('Planning exploration of AGENTS subcrates'); + expect(ChatMessageStructurePolicy.subagentBody(progress)).assertEqual(''); + + const task = toolStatus('task-2', 'Task', 'running'); + task.input_preview = '{"description":"Inspect workspace structure"}'; + const taskProgress: ConversationUiMessageItem = { + type: 'tool', + tool: task, + content: 'Running “Inspect workspace structure”' + }; + expect(ChatMessageStructurePolicy.subagentTitle(taskProgress)).assertEqual('Inspect workspace structure'); + expect(ChatMessageStructurePolicy.subagentBody(taskProgress)).assertEqual(''); + + const structuredTask = toolStatus('task-3', 'Task', 'running'); + structuredTask.tool_input = JSON.parse('{"description":"Read the README first paragraph"}') as Object; + expect(ToolStatusPresentationPolicy.taskTitle(structuredTask)) + .assertEqual('Read the README first paragraph'); + + const codexTask = toolStatus('task-4', 'Task', 'running'); + codexTask.tool_input = JSON.parse( + '{"task_name":"read_readme","message":"Read the first README paragraph."}' + ) as Object; + expect(ToolStatusPresentationPolicy.taskTitle(codexTask)) + .assertEqual('Read the first README paragraph.'); }); it('keeps the summary row and lists members when the group is expanded', 0, () => { @@ -605,41 +732,6 @@ export default function conversationPresentationUnitTest() { expect(branded.subtitle).assertEqual('BitFun · feat/ui'); }); - it('treats create as a titled conversation chrome without actions or rename', 0, () => { - const create = ConversationHeaderPolicy.present(ChatSurface.Create, 'ignored', 'Studio', 'main', true); - expect(create.fallbackKey).assertEqual('remote.create.title'); - expect(create.title).assertEqual(''); - expect(create.subtitle).assertEqual(''); - expect(create.allowRename).assertFalse(); - expect(create.showActions).assertFalse(); - }); - }); - - describe('Create conversation projection', () => { - it('projects remote create onto the shared conversation view with create capabilities', 0, () => { - const remote = new RemotePageState(); - remote.desktopName = 'Studio'; - const create = new RemoteCreateSessionState(); - create.setDraft('hello from create'); - create.setSelectedModelId('model-1'); - create.isSubmitting = true; - create.errorText = 'device mismatch'; - const projection = ConversationViewState.project( - AppRoute.RemoteCreate, - remote, - new GeneralChatPageState(), - '', - create - ); - expect(projection.surface).assertEqual(ChatSurface.Create); - expect(projection.composerCapabilities.supportsAttachments).assertFalse(); - expect(projection.composerCapabilities.showAddButton).assertFalse(); - expect(projection.chatInput).assertEqual('hello from create'); - expect(projection.isBusy).assertTrue(); - expect(projection.inlineStatusText).assertEqual('device mismatch'); - expect(projection.timelineItems.length).assertEqual(0); - expect(projection.desktopName).assertEqual('Studio'); - }); }); describe('ModelServiceSettingsPolicy', () => { 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 eb80fc0dcc..112c9dc003 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -3,7 +3,7 @@ import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; import { ChatSessionController, ChatSessionSnapshot } from '../main/ets/services/ChatSessionController'; import { ChatComposerPolicy } from '../main/ets/services/ChatComposerPolicy'; import { ChatTimelineItem, ChatTimelineProjector } from '../main/ets/services/ChatTimelineProjector'; -import { ChatTimelineRevisionTracker } from '../main/ets/model/ChatTimelineModels'; +import { ChatTimelineRevisionTracker, ChatTimelineRowStore } from '../main/ets/model/ChatTimelineModels'; import { ChatTimelineStore } from '../main/ets/services/ChatTimelineStore'; import { ConversationEvent } from '../main/ets/services/ConversationEvent'; import { AsyncLifecycleGate } from '../main/ets/services/AsyncLifecycleGate'; @@ -84,7 +84,6 @@ import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/Voi import { AppShellState } from '../main/ets/pages/state/AppShellState'; import { ConversationCoreState } from '../main/ets/pages/state/ConversationCoreState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; -import { RemoteCreateSessionState } from '../main/ets/pages/state/RemoteCreateSessionState'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; import { ConversationController } from '../main/ets/pages/viewmodel/ConversationController'; @@ -276,16 +275,33 @@ export default function conversationStateUnitTest() { expect(items[0].type).assertEqual('assistant_message'); }); - it('keeps active turn when matching persisted assistant has no final text yet', 0, () => { + it('uses one active row when matching persisted assistant has no final text yet', 0, () => { const activeTurn = activeChatMessage('turn-final-wait-1', 'final answer', 'completed'); const pendingFinal = chatMessage('turn-final-wait-1_assistant', 'assistant', ''); pendingFinal.text = 'Still reasoning'; pendingFinal.thinking = 'Still reasoning'; const items = ChatTimelineProjector.project([pendingFinal], [], activeTurn, false); - expect(items.length).assertEqual(2); - expect(items[1].type).assertEqual('assistant_live_turn'); - expect(items[1].isFinalizing).assertEqual(true); + expect(items.length).assertEqual(1); + expect(items[0].type).assertEqual('assistant_live_turn'); + expect(items[0].isFinalizing).assertEqual(true); + expect(ChatTimelineRevisionTracker.itemKey(items[0])).assertEqual('assistant_turn:turn-final-wait-1'); + }); + + it('defensively reconciles duplicate assistant turn keys to one row', 0, () => { + const activeTurn = activeChatMessage('turn-row-duplicate-1', '', 'active'); + const pendingFinal = chatMessage('turn-row-duplicate-1_assistant', 'assistant', 'Still reasoning'); + pendingFinal.turnId = 'turn-row-duplicate-1'; + pendingFinal.thinking = 'Still reasoning'; + const persistedItems = ChatTimelineProjector.project([ + pendingFinal + ], [], chatMessage('', 'assistant', ''), false); + const activeItems = ChatTimelineProjector.project([], [], activeTurn, false); + const rows = new ChatTimelineRowStore().reconcile([persistedItems[0], activeItems[0]]); + + expect(rows.length).assertEqual(1); + expect(rows[0].type).assertEqual('assistant_live_turn'); + expect(ChatTimelineRevisionTracker.itemKey(rows[0])).assertEqual('assistant_turn:turn-row-duplicate-1'); }); it('offers retry only for the latest unresolved failed message', 0, () => { @@ -726,6 +742,42 @@ export default function conversationStateUnitTest() { expect(items[0].tool ? (items[0].tool.duration_ms || 0) : 0).assertEqual(50); }); + it('preserves Task invocation metadata when a completion snapshot omits it', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-1'); + const first = activeChatMessage('turn-task-title-1', ''); + first.items = [{ + type: 'tool', + tool: { + id: 'task-1', + name: 'Task', + status: 'running', + input_preview: '{"description":"Read the README introduction"}' + } + }]; + const second = activeChatMessage('turn-task-title-1', '', 'active', 2); + second.items = [{ + type: 'tool', + tool: { + id: 'task-1', + name: 'Task', + status: 'completed', + result_preview: 'Done' + } + }]; + + store.setActiveTurn(first); + store.setActiveTurn(second); + + const state = store.snapshot(); + const items = state.activeTurn && state.activeTurn.items ? state.activeTurn.items : []; + const tool = items.length > 0 ? items[0].tool : undefined; + expect(tool ? (tool.status || '') : '').assertEqual('completed'); + expect(tool ? (tool.input_preview || '') : '') + .assertEqual('{"description":"Read the README introduction"}'); + expect(tool ? (tool.result_preview || '') : '').assertEqual('Done'); + }); + it('clears active turn when persisted assistant id matches turn id', 0, () => { const store = new ChatTimelineStore(); store.reset('session-1'); @@ -895,24 +947,19 @@ export default function conversationStateUnitTest() { it('keeps composer state isolated while the visible route changes', 0, () => { const general = new GeneralChatPageState(); const remote = new RemotePageState(); - const remoteCreate = new RemoteCreateSessionState(); let route = AppRoute.ChatHome; const controller = new ConversationController( general, remote, - remoteCreate, { currentRoute: (): AppRoute => route } ); controller.setChatInput(AppRoute.ChatHome, 'general draft'); controller.setChatInput(AppRoute.RemoteChat, 'remote draft'); - controller.setChatInput(AppRoute.RemoteCreate, 'create draft'); expect(controller.visibleChatInput()).assertEqual('general draft'); route = AppRoute.RemoteChat; expect(controller.visibleChatInput()).assertEqual('remote draft'); - route = AppRoute.RemoteCreate; - expect(controller.visibleChatInput()).assertEqual('create draft'); expect(general.chatInput).assertEqual('general draft'); expect(remote.chatInput).assertEqual('remote draft'); }); @@ -920,22 +967,18 @@ export default function conversationStateUnitTest() { it('clears voice state for every conversation surface on teardown', 0, () => { const general = new GeneralChatPageState(); const remote = new RemotePageState(); - const remoteCreate = new RemoteCreateSessionState(); const controller = new ConversationController( general, remote, - remoteCreate, - { currentRoute: (): AppRoute => AppRoute.RemoteCreate } + { currentRoute: (): AppRoute => AppRoute.RemoteChat } ); controller.setVoiceListening(AppRoute.ChatHome, true); controller.setVoiceListening(AppRoute.RemoteChat, true); - controller.setVoiceListening(AppRoute.RemoteCreate, true); controller.clearAllVoiceListening(); expect(general.isVoiceListening).assertFalse(); expect(remote.isVoiceListening).assertFalse(); - expect(remoteCreate.isVoiceListening).assertFalse(); }); }); diff --git a/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets index 63d034c59c..d38f9b20bc 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets @@ -225,6 +225,7 @@ export default function deviceDirectoryUnitTest() { viewModel.syncDevices([ { deviceId: 'desk-studio', deviceName: 'Studio', online: true } ]); + state.select('desk-studio'); expect(state.selectedDeviceId).assertEqual('desk-studio'); viewModel.syncDevices([ { deviceId: 'desk-studio', deviceName: '', online: false } @@ -248,7 +249,7 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-other', deviceName: 'Other', online: false } ]); expect(state.find('desk-live')?.online).assertTrue(); - expect(state.selectedDeviceId).assertEqual('desk-live'); + expect(state.selectedDeviceId).assertEqual(''); // The override must not outlive the link it is based on, or a device that // really did go away keeps a lit row forever. expect(state.find('desk-other')?.online).assertFalse(); @@ -257,7 +258,7 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-live', deviceName: 'Studio', online: false } ]); expect(state.find('desk-live')?.online).assertFalse(); - expect(state.selectedDeviceId).assertEqual('desk-live'); + expect(state.selectedDeviceId).assertEqual(''); }); it('keeps an explicit device selection across syncDevices', 0, async () => { @@ -279,7 +280,7 @@ export default function deviceDirectoryUnitTest() { ]); expect(state.workspaceExpanded('desk-other', '/workspace/project')).assertTrue(); expect(state.workspaceStatus('desk-other', '/workspace/project/')).assertEqual('loading'); - expect(state.selectedDeviceId).assertEqual('desk-active'); + expect(state.selectedDeviceId).assertEqual(''); await viewModel.selectDevice('desk-other'); expect(state.selectedDeviceId).assertEqual('desk-other'); viewModel.syncDevices([ diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets index f12bdf5a23..93a17e7478 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets @@ -835,6 +835,7 @@ export class FakeRemoteSessionClient implements RemoteSessionClient { shouldFailList: boolean = false; shouldFailCreate: boolean = false; shouldFailDelete: boolean = false; + emitAllocationBeforeCreateReturn: boolean = false; async listSessions(limit: number, offset: number, query: string, agentType: string): Promise { this.listRequests.push(`${limit}:${offset}:${query}:${agentType}`); @@ -848,13 +849,24 @@ export class FakeRemoteSessionClient implements RemoteSessionClient { return result; } - async createSession(options: CreateSessionOptions): Promise { + async createSession( + options: CreateSessionOptions, + onAllocated?: (session: SessionSummary) => Promise + ): Promise { this.createRequests.push( `${options.agentType}:${options.title}:${options.instruction}:${options.workspacePath || ''}` ); if (this.shouldFailCreate) { throw new Error('Expected session create failure.'); } + if (this.emitAllocationBeforeCreateReturn && onAllocated) { + await onAllocated({ + sessionId: this.createdSession.sessionId, + title: this.createdSession.title, + workspacePath: this.createdSession.workspacePath, + agentType: this.createdSession.agentType + }); + } return this.createdSession; } 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 97a5fc8e53..a53e28830b 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -101,7 +101,6 @@ import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/Voi import { AppShellState } from '../main/ets/pages/state/AppShellState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; -import { RemoteCreateSessionState } from '../main/ets/pages/state/RemoteCreateSessionState'; import { RemoteSessionViewModel } from '../main/ets/pages/viewmodel/RemoteSessionViewModel'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; import { @@ -746,6 +745,8 @@ export default function remoteControllersUnitTest() { expect(creased.conversationPaneWidth).assertEqual(428); expect(creased.conversationPreviewGap).assertEqual(16); expect(creased.previewPaneWidth).assertEqual(484); + expect(creased.masterConversationGapIsCrease).assertTrue(); + expect(creased.conversationPreviewGapIsCrease).assertTrue(); }); it('shrinks the flat master pane at the triple-pane threshold without narrowing content panes', 0, () => { @@ -791,7 +792,7 @@ export default function remoteControllersUnitTest() { expect(layout.previewPaneWidth).assertEqual(1094); }); - it('keeps tri-fold preview on a crease instead of splitting the full window in half', 0, () => { + it('keeps tri-fold preview on the right panel until the user expands it', 0, () => { const single = FilePreviewPlacementPolicy.resolveLayout(true, true, 1107, [ new ConversationLayoutCrease(351, 44) ]); @@ -799,15 +800,30 @@ export default function remoteControllersUnitTest() { expect(single.conversationPaneWidth).assertEqual(351); expect(single.conversationPreviewGap).assertEqual(44); expect(single.previewPaneWidth).assertEqual(712); + expect(single.conversationPreviewGapIsCrease).assertTrue(); const dual = FilePreviewPlacementPolicy.resolveLayout(true, true, 1107, [ new ConversationLayoutCrease(351, 44), new ConversationLayoutCrease(773, 44) ]); - expect(dual.placement).assertEqual(FilePreviewPlacement.WideFocusSplit); - expect(dual.conversationPaneWidth).assertEqual(773); + expect(dual.placement).assertEqual(FilePreviewPlacement.WideTriplePane); + expect(dual.masterPaneWidth).assertEqual(351); + expect(dual.masterConversationGap).assertEqual(44); + expect(dual.conversationPaneWidth).assertEqual(378); expect(dual.conversationPreviewGap).assertEqual(44); expect(dual.previewPaneWidth).assertEqual(290); + expect(dual.masterConversationGapIsCrease).assertTrue(); + expect(dual.conversationPreviewGapIsCrease).assertTrue(); + + const expanded = FilePreviewPlacementPolicy.resolveLayout(true, true, 1107, [ + new ConversationLayoutCrease(351, 44), + new ConversationLayoutCrease(773, 44) + ], FilePreviewPlacementPolicy.MIN_MASTER_WIDTH, true); + expect(expanded.placement).assertEqual(FilePreviewPlacement.WideMasterPreviewFocus); + expect(expanded.masterPaneWidth).assertEqual(351); + expect(expanded.masterConversationGap).assertEqual(44); + expect(expanded.conversationPaneWidth).assertEqual(0); + expect(expanded.previewPaneWidth).assertEqual(712); }); it('falls back from crease-aligned focus when one side is too narrow', 0, () => { @@ -821,84 +837,6 @@ export default function remoteControllersUnitTest() { }); }); - describe('RemoteCreateSessionState', () => { - it('keeps selected device and workspace labels aligned with refreshed choices', 0, () => { - const state = new RemoteCreateSessionState(); - state.prepare('desktop-b', 'stale device label'); - state.selectWorkspace({ - name: 'stale workspace label', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' - }); - state.setDevices([ - { deviceId: 'desktop-a', deviceName: 'Desktop A', online: true }, - { deviceId: 'desktop-b', deviceName: 'Desktop B', online: true } - ]); - state.setWorkspaces([ - { name: 'BitFun', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' }, - { name: 'flashgrep', path: '/workspace/flashgrep', lastOpened: '', workspaceKind: 'normal' } - ]); - - expect(state.selectedDeviceName).assertEqual('Desktop B'); - expect(state.selectedWorkspaceName).assertEqual('BitFun'); - }); - - it('freezes the selected creation target and defaults workspace sessions to Standard Harness', 0, () => { - const state = new RemoteCreateSessionState(); - state.prepare('desktop-b', 'Desktop B', '', true); - state.setWorkspaces([{ - name: 'BitFun', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' - }]); - state.selectWorkspace({ - name: 'BitFun', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' - }); - - const context = state.submissionContext(); - - expect(context.deviceId).assertEqual('desktop-b'); - expect(context.workspacePath).assertEqual('/workspace/BitFun'); - expect(context.agentType).assertEqual('agentic'); - expect(state.supportsHarnessProfiles).assertTrue(); - expect(state.selectedHarnessProfile).assertEqual('balanced'); - }); - - it('maps Minimal and Ultimate Harness choices to their runtime agent modes', 0, () => { - const state = new RemoteCreateSessionState(); - state.prepare('desktop-b', 'Desktop B', '', true); - state.selectWorkspace({ - name: 'BitFun', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' - }); - - state.setSelectedHarnessProfile('minimal'); - expect(state.submissionContext().agentType).assertEqual('minimal'); - state.setSelectedHarnessProfile('ultimate'); - expect(state.submissionContext().agentType).assertEqual('Ultra'); - }); - - it('refreshes Harness support and defaults to Standard when the creation target changes', 0, () => { - const state = new RemoteCreateSessionState(); - state.prepare('desktop-a', 'Desktop A', '', true); - state.setSelectedHarnessProfile('ultimate'); - - state.selectDevice({ deviceId: 'desktop-b', deviceName: 'Desktop B', online: true }, false); - expect(state.supportsHarnessProfiles).assertFalse(); - expect(state.selectedHarnessProfile).assertEqual('balanced'); - - state.selectDevice({ deviceId: 'desktop-c', deviceName: 'Desktop C', online: true }, true); - expect(state.supportsHarnessProfiles).assertTrue(); - expect(state.selectedHarnessProfile).assertEqual('balanced'); - }); - - it('keeps the chat option on the assistant agent so the desktop binds its assistant workspace', 0, () => { - const state = new RemoteCreateSessionState(); - state.prepare('desktop-b', 'Desktop B'); - state.selectWorkspace(undefined); - - const context = state.submissionContext(); - - expect(context.workspacePath).assertEqual(''); - expect(context.agentType).assertEqual('Claw'); - }); - }); - describe('RemoteFileDownloadController', () => { it('owns remote file download progress and delayed downloading marker cleanup', 0, async () => { const client = new FakeRemoteFileDownloadClient(); @@ -1368,6 +1306,45 @@ export default function remoteControllersUnitTest() { expect(created?.workspacePath || '').assertEqual('/other'); }); + it('hands an allocated session to the conversation before its initial turn settles', 0, async () => { + const client = new FakeRemoteSessionClient(); + client.emitAllocationBeforeCreateReturn = true; + client.createdSession = { + sessionId: 'allocated-session', + title: 'Allocated Session', + workspacePath: '/workspace', + agentType: 'code', + initialTurnId: 'turn-ready' + }; + const activeSessions: SessionSummary[] = []; + const openedSessions: SessionSummary[] = []; + const controller = new RemoteSessionController(client, 8, { + onSessions: (_sessions: RemoteSession[], _hasMore: boolean) => {}, + onActiveSession: (session: SessionSummary) => { + activeSessions.push(session); + }, + onStatusText: (_statusText: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onLoading: (_isLoading: boolean) => {}, + onSessionError: (_errorText: string) => {}, + onReconnecting: () => {}, + onConnected: () => {}, + onConnectionFailed: (_err: Object) => {}, + onStartHeartbeat: () => {} + }); + + await controller.create('code', false, true, async (session: SessionSummary): Promise => { + openedSessions.push(session); + }, 'prompt', 'model-a', '/workspace'); + + expect(activeSessions.length).assertEqual(2); + expect(activeSessions[0].sessionId).assertEqual('allocated-session'); + expect(activeSessions[0].initialTurnId || '').assertEqual(''); + expect(activeSessions[1].initialTurnId || '').assertEqual('turn-ready'); + expect(openedSessions.length).assertEqual(1); + expect(openedSessions[0].initialTurnId || '').assertEqual(''); + }); + it('returns no created session when the remote create command fails', 0, async () => { const client = new FakeRemoteSessionClient(); client.shouldFailCreate = true; @@ -1498,6 +1475,7 @@ export default function remoteControllersUnitTest() { new FakeRemoteFileSaver() ); const reconciled: SessionSummary[] = []; + const routedSessions: string[] = []; const viewModel = new RemoteSessionViewModel(pageState, sessions, chat, models, files, { remoteAvailable: (): boolean => true, isConnected: (): boolean => true, @@ -1509,6 +1487,7 @@ export default function remoteControllersUnitTest() { onRouteHome: (): void => {}, onStopPolling: (): void => {}, onStartPolling: (): void => {}, + onNudgePolling: (): void => {}, onResetTimeline: (_sessionId: string): void => {}, onClearRemoteFiles: (): void => {}, onKnownStateReset: (): void => {}, @@ -1520,12 +1499,23 @@ export default function remoteControllersUnitTest() { onSelectWorkspace: async (_path: string): Promise => {} }); - await viewModel.createSessionInWorkspace('/other'); + await viewModel.createSessionInWorkspace( + '/other', + 'introduce this project', + 'code', + (sessionId: string): void => { + routedSessions.push(sessionId); + }, + '', + false + ); expect(reconciled.length).assertEqual(1); expect(reconciled[0].sessionId).assertEqual('created-in-other'); expect(reconciled[0].workspacePath).assertEqual('/other'); expect(sessionClient.listRequests.length).assertEqual(0); + expect(routedSessions.length).assertEqual(1); + expect(routedSessions[0]).assertEqual('created-in-other'); }); }); @@ -2436,10 +2426,8 @@ export default function remoteControllersUnitTest() { expect(AppRouteContract.isRemoteRoute(AppRoute.ChatHome)).assertFalse(); expect(AppRouteContract.isRemoteRoute(AppRoute.GeneralChat)).assertFalse(); expect(AppRouteContract.isRemoteRoute(AppRoute.RemoteHome)).assertTrue(); - expect(AppRouteContract.isRemoteRoute(AppRoute.RemoteCreate)).assertTrue(); expect(AppRouteContract.isRemoteRoute(AppRoute.RemoteChat)).assertTrue(); expect(AppRouteContract.isGeneralComposerRoute(AppRoute.ChatHome)).assertTrue(); - expect(AppRouteContract.isGeneralComposerRoute(AppRoute.RemoteCreate)).assertFalse(); }); it('resumes an in-flight remote session instead of landing on the picker', 0, () => { @@ -2665,7 +2653,7 @@ export default function remoteControllersUnitTest() { it('buckets by day only when sorting by time', 0, () => { const now = new Date(); const recent = session('recent-1', 'Today', 'code', '/workspace/bitfun'); - recent.updatedAt = new Date(now.getTime() - 60 * 60 * 1000).toISOString(); + recent.updatedAt = now.toISOString(); const old = session('old-1', 'Older', 'code', '/workspace/bitfun'); old.updatedAt = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); 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 b4bc631155..a29ea0232c 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -325,6 +325,53 @@ class ScriptedAccountSessionManager extends RemoteSessionManager { } } +/** Holds one switch so a newer device choice can finish first. */ +class GatedAccountSessionManager extends ScriptedAccountSessionManager { + private releaseHeldSwitch: () => void = () => {}; + private markHeldSwitchStarted: () => void = () => {}; + private readonly heldSwitch: Promise; + readonly heldSwitchStarted: Promise; + + constructor() { + super(); + this.heldSwitch = new Promise((resolve: () => void) => { + this.releaseHeldSwitch = resolve; + }); + this.heldSwitchStarted = new Promise((resolve: () => void) => { + this.markHeldSwitchStarted = resolve; + }); + } + + async connectAccountDevice( + _accountClient: CloudAccountClient, + _relayUrl: string, + session: CloudAccountSession, + targetDeviceId: string + ): Promise { + this.dialled.push(targetDeviceId); + if (targetDeviceId === 'desk-a') { + this.markHeldSwitchStarted(); + await this.heldSwitch; + } + return { + workspace: { + path: `/w/${targetDeviceId}`, + name: targetDeviceId, + hasWorkspace: true, + gitBranch: 'main', + workspaceKind: 'normal' + }, + sessions: [], + hasMoreSessions: false, + authenticatedUserId: session.userId + }; + } + + release(): void { + this.releaseHeldSwitch(); + } +} + /** HUKS and preferences are not reachable from a local unit run. */ class InMemoryCloudAccountSessionStore extends CloudAccountSessionStore { readonly saved: PersistedCloudAccountSession[] = []; @@ -391,16 +438,22 @@ class WatchLoginFailureAccountClient extends CloudAccountClient { class AccountDeviceSwitchHarness { readonly remoteState: RemotePageState = new RemotePageState(); - readonly sessionManager: ScriptedAccountSessionManager = new ScriptedAccountSessionManager(); + readonly sessionManager: ScriptedAccountSessionManager; readonly sessionStore: InMemoryCloudAccountSessionStore = new InMemoryCloudAccountSessionStore(); heartbeatStarts: number = 0; heartbeatStops: number = 0; + timelineResets: number = 0; + knownRemoteStateResets: number = 0; cachedRemoteClears: number = 0; settingsClosings: number = 0; readonly accountDeviceSnapshots: CloudAccountDevice[][] = []; readonly controller: SettingsController; - constructor(client: CloudAccountClient = new CloudAccountClient()) { + constructor( + client: CloudAccountClient = new CloudAccountClient(), + sessionManager: ScriptedAccountSessionManager = new ScriptedAccountSessionManager() + ) { + this.sessionManager = sessionManager; const hooks: CloudAccountSettingsHooks = { deviceId: (): string => 'phone', remoteAvailable: (): boolean => true, @@ -410,8 +463,8 @@ class AccountDeviceSwitchHarness { stopPolling: (): void => {}, stopHeartbeat: (): void => { this.heartbeatStops += 1; }, startHeartbeat: (): void => { this.heartbeatStarts += 1; }, - resetTimeline: (): void => {}, - resetKnownRemoteState: (): void => {}, + resetTimeline: (): void => { this.timelineResets += 1; }, + resetKnownRemoteState: (): void => { this.knownRemoteStateResets += 1; }, clearCachedRemoteData: async (): Promise => { this.cachedRemoteClears += 1; }, closeSettings: (): void => { this.settingsClosings += 1; }, closeConnectSheet: (): void => {}, @@ -1068,6 +1121,23 @@ export default function transportAndGeneralChatUnitTest() { expect(outcome?.passwordRequired).assertTrue(); }); + it('asks for account confirmation when the watch is already registered', 0, async () => { + const harness = new AccountDeviceSwitchHarness(new ProvisioningFailureAccountClient(409)); + // An expired QR pairing projects an empty username into the shared page + // state during startup. The cloud account identity must survive that UI + // projection because its encrypted session is still valid. + harness.remoteState.setAccountPairing(false, ''); + + const outcome = await harness.controller.provisionWatchCredential( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'HarmonyOS Watch', + '12345678-1234-4234-8234-123456789abc' + ); + + expect(outcome !== undefined).assertTrue(); + expect(outcome?.passwordRequired).assertTrue(); + }); + it('does not treat an authorization refusal as a legacy relay', 0, async () => { const harness = new AccountDeviceSwitchHarness(new ProvisioningFailureAccountClient(403)); @@ -1082,6 +1152,7 @@ export default function transportAndGeneralChatUnitTest() { it('reports a rejected compatibility login instead of deferring to the desktop', 0, async () => { const harness = new AccountDeviceSwitchHarness(new WatchLoginFailureAccountClient()); + harness.remoteState.setAccountPairing(false, ''); let statusCode = 0; try { @@ -1179,6 +1250,71 @@ export default function transportAndGeneralChatUnitTest() { expect(harness.cachedRemoteClears).assertEqual(1); }); + it('atomically clears account-owned conversation state on logout', 0, async () => { + const harness = new AccountDeviceSwitchHarness(); + harness.remoteState.setControlTarget('account_device', 'desk-a', 'Desktop A'); + harness.remoteState.setConnectionState('connected'); + harness.remoteState.setActiveSession({ + sessionId: 'session-a', title: 'Previous account', workspacePath: '/repo', agentType: 'code' + }); + harness.remoteState.setStatusText(RemoteI18n.t('status.messagesSynced')); + harness.remoteState.setBusy(true); + harness.remoteState.setChatInput('unsent account draft'); + + await harness.controller.logoutCloudAccount(); + + expect(harness.controller.hasCloudAccountSession()).assertFalse(); + expect(harness.remoteState.accountUserId).assertEqual(''); + expect(harness.remoteState.controlTargetType).assertEqual('none'); + expect(harness.remoteState.connectionState).assertEqual('disconnected'); + expect(harness.remoteState.activeSession.sessionId).assertEqual(''); + expect(harness.remoteState.statusText).assertEqual(''); + expect(harness.remoteState.isBusy).assertFalse(); + expect(harness.remoteState.chatInput).assertEqual(''); + expect(harness.timelineResets).assertEqual(1); + expect(harness.knownRemoteStateResets).assertEqual(1); + }); + + it('clears an in-flight account switch even after its target was temporarily removed', 0, async () => { + const harness = new AccountDeviceSwitchHarness(); + harness.remoteState.clearControlTarget(); + harness.remoteState.setConnectionState('reconnecting'); + harness.remoteState.setLoadingHome(true); + harness.remoteState.setBusy(true); + harness.remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); + harness.remoteState.setActiveSession({ + sessionId: 'stale-session', title: 'Stale', workspacePath: '/stale', agentType: 'code' + }); + + await harness.controller.logoutCloudAccount(); + + expect(harness.remoteState.connectionState).assertEqual('disconnected'); + expect(harness.remoteState.isLoadingHome).assertFalse(); + expect(harness.remoteState.isBusy).assertFalse(); + expect(harness.remoteState.statusText).assertEqual(''); + expect(harness.remoteState.activeSession.sessionId).assertEqual(''); + expect(harness.timelineResets).assertEqual(1); + }); + + it('does not leave a separately paired room half-connected after account logout', 0, async () => { + const harness = new AccountDeviceSwitchHarness(); + harness.remoteState.setControlTarget('room', 'room-device', 'Paired Desktop'); + harness.remoteState.setConnectionState('connected'); + harness.remoteState.setActiveSession({ + sessionId: 'room-session', title: 'Paired room', workspacePath: '/room', agentType: 'code' + }); + harness.remoteState.setStatusText('Room is connected'); + + await harness.controller.logoutCloudAccount(); + + expect(harness.remoteState.accountUserId).assertEqual(''); + expect(harness.remoteState.controlTargetType).assertEqual('none'); + expect(harness.remoteState.connectionState).assertEqual('disconnected'); + expect(harness.remoteState.activeSession.sessionId).assertEqual(''); + expect(harness.remoteState.statusText).assertEqual(''); + expect(harness.timelineResets).assertEqual(1); + }); + it('puts the phone back on the previous desktop when a device switch fails', 0, async () => { const harness = new AccountDeviceSwitchHarness(); harness.sessionManager.unreachable.add('desk-b'); @@ -1229,6 +1365,24 @@ export default function transportAndGeneralChatUnitTest() { expect(harness.settingsClosings).assertEqual(0); }); + + it('keeps the newest device when an older switch completes late', 0, async () => { + const manager = new GatedAccountSessionManager(); + const harness = new AccountDeviceSwitchHarness(new CloudAccountClient(), manager); + + const older = harness.controller.selectCloudAccountDevice(harness.device('desk-a'), false); + await manager.heldSwitchStarted; + await harness.controller.selectCloudAccountDevice(harness.device('desk-b'), false); + manager.release(); + await older; + + expect(harness.remoteState.controlTargetDeviceId).assertEqual('desk-b'); + expect(harness.remoteState.workspacePath).assertEqual('/w/desk-b'); + expect(harness.remoteState.connectionState).assertEqual('connected'); + expect(harness.sessionStore.saved.length).assertEqual(1); + expect(harness.sessionStore.saved[0].targetDeviceId).assertEqual('desk-b'); + expect(manager.dialled.join(',')).assertEqual('desk-a,desk-b'); + }); }); describe('GeneralChatModelSelectionPolicy', () => { @@ -1586,6 +1740,29 @@ export default function transportAndGeneralChatUnitTest() { expect(projection.tools.length).assertEqual(1); }); + it('keeps Task input metadata when a completion event only carries the result', 0, () => { + let projection = GeneralChatEventMapper.emptyProjection('chat-1', 'turn-1'); + projection = GeneralChatEventMapper.apply(projection, { + event_id: 'task-started', version: 1, session_id: 'chat-1', turn_id: 'turn-1', + type: 'tool.started', + tool: { + id: 'task-1', name: 'Task', status: 'running', + input_preview: '{"description":"Inspect the workspace"}' + } + }); + projection = GeneralChatEventMapper.apply(projection, { + event_id: 'task-completed', version: 2, session_id: 'chat-1', turn_id: 'turn-1', + type: 'tool.completed', + tool: { id: 'task-1', name: 'Task', status: 'completed', result_preview: 'Done' } + }); + + expect(projection.tools[0].input_preview || '') + .assertEqual('{"description":"Inspect the workspace"}'); + expect(projection.items[0].tool?.input_preview || '') + .assertEqual('{"description":"Inspect the workspace"}'); + expect(projection.items[0].tool?.result_preview || '').assertEqual('Done'); + }); + it('projects permission and capability states and ignores late terminal events', 0, () => { let projection = GeneralChatEventMapper.emptyProjection('chat-1', 'turn-1'); projection = GeneralChatEventMapper.apply(projection, { diff --git a/src/apps/mobile/harmonyos/entry/src/test/WatchProvisionUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/WatchProvisionUnit.test.ets index 829844e2b1..6a5879e6be 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/WatchProvisionUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/WatchProvisionUnit.test.ets @@ -1,4 +1,5 @@ import { describe, expect, it } from '@ohos/hypium'; +import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; import { WatchProvisionController, WatchProvisionOutcome, @@ -112,5 +113,27 @@ export default function watchProvisionUnitTest() { expect(display.phase).assertEqual('password'); expect(display.message.length > 0).assertTrue(); }); + + it('does not report a phone account failure as a desktop outage', 0, async () => { + const display = new FakeWatchProvisionDisplay(); + const port: WatchProvisionPort = { + canProvision: (): boolean => true, + provision: async ( + _deviceId: string, + _deviceName: string, + _requestId: string, + _password: string + ): Promise => { + throw new Error('relay unavailable'); + } + }; + const controller = new WatchProvisionController(display, port); + controller.onRequestPayload(watchRequest()); + + await controller.approve(); + + expect(display.phase).assertEqual('failed'); + expect(display.message).assertEqual(RemoteI18n.t('watchProvision.errors.accountUnavailable')); + }); }); }