diff --git a/scripts/check-harmonyos-architecture.mjs b/scripts/check-harmonyos-architecture.mjs index 2ccdeff49b..46eed36b8c 100644 --- a/scripts/check-harmonyos-architecture.mjs +++ b/scripts/check-harmonyos-architecture.mjs @@ -132,7 +132,6 @@ const extractedCloudAccountMethods = [ 'loginCloudAccount', 'restoreCloudAccountSession', 'loadGeneralChatAccountModels', - 'syncCloudAccount', 'applyCloudAccountSession', 'logoutCloudAccount', 'listCloudAccountDevices', diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index ce677630c4..25e9dcddc1 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -82,6 +82,17 @@ static ACCOUNT_CONTEXT_TRANSITIONS: AtomicUsize = AtomicUsize::new(0); static DEVICE_ROUTING_LIFECYCLE_LOCK: tokio::sync::RwLock<()> = tokio::sync::RwLock::const_new(()); static DEVICE_ROUTING_CONNECTION_ID: AtomicU64 = AtomicU64::new(0); +/// Ceiling on device RPCs executing at once. +/// +/// RPCs run off the routing loop rather than on it, so without a bound a phone +/// that fans out a screenful of `list_sessions` would put all of them on the +/// webview bridge at once. The bound exists to keep that burst from crowding +/// out the next device's first request, not because concurrency is unsafe: +/// each RPC holds its own routing lease and answers its own correlation id. +const MAX_CONCURRENT_DEVICE_RPCS: usize = 8; +static DEVICE_RPC_SLOTS: tokio::sync::Semaphore = + tokio::sync::Semaphore::const_new(MAX_CONCURRENT_DEVICE_RPCS); + #[derive(Clone, Debug, Eq, PartialEq)] struct DeviceRoutingOwner { account_generation: u64, @@ -902,6 +913,7 @@ pub(crate) async fn provision_dispatch_account_device( &session, &identity.device_id, &identity.device_name, + "desktop", uuid::Uuid::new_v4(), ) .await @@ -1254,6 +1266,7 @@ async fn register_delegated_identity_providers() { &context.session, &device_id, &device_name, + "watch", request_id, ) .await @@ -2881,7 +2894,10 @@ pub async fn account_connect_devices() -> Result, String> } } Ok(cmd) if source_device_id == "rpc" => { - let Some(_routing_effect) = + // The lease is taken here, on the loop, so a + // retiring loop still notices it has been + // replaced and stops reading events at once. + let Some(routing_effect) = lock_current_device_routing(&event_owner).await else { break 'routing_events; @@ -2892,34 +2908,56 @@ pub async fn account_connect_devices() -> Result, String> log::info!( "RPC request received from relay: corr={correlation_id}" ); - let execution = execute_local_remote_command(&cmd).await; - if !device_routing_owner_is_current(&event_owner).await { - break 'routing_events; - } - match execution { - Ok(resp_value) => { - send_rpc_envelope( - &event_owner, - &event_session, - &correlation_id, - resp_value, - ) - .await; + // Spawned rather than awaited. Most commands + // are answered by the webview, which can take + // up to DEFAULT_INVOKE_TIMEOUT (120s) to reply; + // awaiting here meant one slow command stalled + // every device behind it, so a `ping` from the + // watch could take 40s to come back for no + // reason of its own. Each RPC carries its own + // correlation id, so nothing about the reply + // path depends on them finishing in order. + let rpc_owner = event_owner.clone(); + let rpc_session = event_session.clone(); + tokio::spawn(async move { + // Held for the whole call: teardown takes + // the write lease, so an in-flight RPC now + // keeps the connection from being replaced + // out from under its own reply. + let _routing_effect = routing_effect; + let Ok(_slot) = DEVICE_RPC_SLOTS.acquire().await else { + return; + }; + let execution = execute_local_remote_command(&cmd).await; + // Returning drops this reply only. The loop + // re-checks ownership at the top of every + // iteration, so a stale connection is still + // retired there — just not from in here. + if !device_routing_owner_is_current(&rpc_owner).await { + return; } - Err(e) => { - log::warn!("RPC: execute command failed: {e}"); - send_rpc_error( - &event_owner, - &event_session, - &correlation_id, - format!("RPC execute failed: {e}"), - ) - .await; + match execution { + Ok(resp_value) => { + send_rpc_envelope( + &rpc_owner, + &rpc_session, + &correlation_id, + resp_value, + ) + .await; + } + Err(e) => { + log::warn!("RPC: execute command failed: {e}"); + send_rpc_error( + &rpc_owner, + &rpc_session, + &correlation_id, + format!("RPC execute failed: {e}"), + ) + .await; + } } - } - if !device_routing_owner_is_current(&event_owner).await { - break 'routing_events; - } + }); } Ok(cmd) => { let _ = cmd; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets index 961dd743b7..7085e03a0d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets @@ -12,6 +12,7 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['common.keep', '保留'], ['common.loading', '加载中...'], ['common.open', '打开'], + ['common.other', '其他'], ['common.ready', '就绪'], ['common.refresh', '刷新'], ['common.retry', '重试'], @@ -227,9 +228,6 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['remote.settings.accountSigningIn', '正在登录…'], ['remote.settings.accountLoginFailed', '云账号登录失败,请检查 relay 地址和账号密码。'], ['remote.settings.relayUrlPlaceholder', 'Relay 地址,例如 https://relay.example.com'], - ['remote.settings.accountSync', '同步云端会话'], - ['remote.settings.accountSyncSuccess', '已同步 {0} 个会话'], - ['remote.settings.accountSyncFailed', '云端会话同步失败,请稍后重试。'], ['remote.settings.accountLogout', '退出登录'], ['remote.settings.accountLoggingOut', '正在退出…'], ['remote.settings.deviceManagement', '设备管理'], @@ -391,17 +389,8 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['chat.loadOlder', '加载更早消息'], ['chat.inputPlaceholder', '向 BitFun 提问'], ['chat.voiceListeningPlaceholder', '正在听,请说话...'], - ['chat.quickExplain', '解释当前状态'], - ['chat.quickExplainPrompt', '请解释当前任务状态和下一步计划。'], - ['chat.quickContinue', '继续执行'], - ['chat.quickContinuePrompt', '继续执行当前任务。'], - ['chat.quickSummary', '总结结果'], - ['chat.quickSummaryPrompt', '总结目前完成的内容和剩余风险。'], + ['chat.queuedAfterRunningTurn', '已排队,当前任务结束后执行'], ['chat.image', '图片'], - ['chat.attachments', '附件'], - ['chat.pickImage', '选择图片'], - ['chat.pickImageDesc', '从相册选择图片发送给 BitFun'], - ['chat.quickPrompts', '快捷指令'], ['chat.thinkingRunning', '运行中'], ['chat.thinkingDone', '已完成'], ['chat.thinkingInProgress', '正在思考'], @@ -497,6 +486,7 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['status.sessionUnarchived', '会话已取消归档'], ['status.sessionExported', '已复制为 Markdown'], ['status.messagesSynced', '消息已同步'], + ['status.messagesRestored', '已恢复本地会话记录'], ['status.switchingModel', '正在切换模型'], ['status.modelSwitched', '模型已切换'], ['status.loadOlderMessages', '加载更早消息'], @@ -548,6 +538,7 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['errors.remoteUrlInvalid', '远程连接链接格式不正确,请重新扫描二维码或粘贴完整链接。'], ['errors.permissionDenied', '没有获得所需权限,请允许扫码或剪贴板访问后重试。'], ['errors.operationFailed', '操作失败,请稍后重试。'], + ['errors.payloadTooLarge', '这条消息带的附件超过了中继允许的大小,请减少图片数量或选择更小的图片。'], ['errors.imageTooLargeAfterCompression', '图片压缩后仍超过 {0} MB,请选择较小图片。'], ['errors.imageCompressionFailed', '图片压缩失败,请选择较小图片。'], ['errors.voicePermissionDenied', '没有获得麦克风权限,无法语音输入。'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index dc229cc3ad..cef4f86733 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -356,8 +356,11 @@ export interface RemoteToolStatusResponse { } export interface RemoteQuestionAnswerPayload { - answer: string; - '0': string; + answer?: string; + '0'?: string | string[]; + '1'?: string | string[]; + '2'?: string | string[]; + '3'?: string | string[]; } export interface ChatMessageItemResponse { 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 63fecd397e..8b7cea8c38 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 @@ -86,7 +86,6 @@ export interface SettingsPresentationActions { readonly reconnect: () => void; readonly openAccount: () => void; readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; - readonly cloudSync: () => Promise; readonly cloudLogout: () => Promise; readonly cloudListDevices: () => Promise; readonly getPermissionMode: () => Promise; @@ -136,7 +135,7 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions { }, onSettings: { close: () => {}, addConnection: () => {}, disconnect: () => {}, reconnect: () => {}, openAccount: () => {}, - cloudLogin: async () => '', cloudSync: async () => '', cloudLogout: async () => {}, + cloudLogin: async () => '', cloudLogout: async () => {}, cloudListDevices: async () => [], getPermissionMode: async () => 'ask', setPermissionMode: async (mode: RemotePermissionMode) => mode, testGeneral: async () => '', saveGeneral: async () => '' 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 734b9f7ea4..018255e63e 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 @@ -3,7 +3,7 @@ import { emptyAppRootPresentationActions } from '../actions/AppRootPresentationActions'; import { AppRouteContract, ConversationSource } from '../navigation/AppRouteContract'; -import { AppShellState } from '../state/AppShellState'; +import { AppShellState, CONNECT_INTENT_AUTO } from '../state/AppShellState'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; import { AppSidebar } from './AppSidebar'; @@ -120,7 +120,6 @@ export struct AppSettingsSurface { onOpenAccount: this.actions.onSettings.openAccount, onAddConnection: this.actions.onSettings.addConnection, cloudLogin: this.actions.onSettings.cloudLogin, - cloudSync: this.actions.onSettings.cloudSync, cloudLogout: this.actions.onSettings.cloudLogout, cloudListDevices: this.actions.onSettings.cloudListDevices, getPermissionMode: this.actions.onSettings.getPermissionMode, @@ -152,6 +151,7 @@ export struct AppSettingsSurface { export struct AppConnectSurface { @Param remotePageState: RemotePageState = new RemotePageState(); @Param deviceId: string = ''; + @Param openIntent: string = CONNECT_INTENT_AUTO; @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); build() { @@ -169,7 +169,7 @@ export struct AppConnectSurface { controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, requiresAccountAuth: this.remotePageState.requiresAccountAuth, accountUsername: this.remotePageState.accountUsername, - startWithScanner: true, + openIntent: this.openIntent, onBack: this.actions.onConnect.back, onConnect: this.actions.onConnect.connect, onRemoteUrlChange: this.actions.onConnect.urlChanged, 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 cd5564c45f..394d50871b 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 @@ -403,6 +403,7 @@ export struct AppRootPresentation { AppConnectSurface({ remotePageState: this.remotePageState, deviceId: this.deviceId, + openIntent: this.shellState.connectSheetIntent, actions: this.actions }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets index 1d71ccca67..770503e23c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets @@ -24,7 +24,14 @@ export struct AppShell { build() { Stack({ alignContent: Alignment.Start }) { Column() { - this.sidebar() + // The drawer is a compact affordance. Wide layouts put the same session + // list in the master pane instead, and this copy stayed mounted behind + // an opacity of 0 — invisible, but rebuilt on every selection change, + // which on a tablet doubled the cost of switching sessions. Compact + // keeps it mounted so opening and closing still cross-fades. + if (!this.useWideLayout || this.shellState.showSidebar) { + this.sidebar() + } } .width(this.sidebarWidth()) .height('100%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets index 6edfc38e4a..b2f27d0483 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets @@ -6,19 +6,26 @@ export class ChatComposerCapabilities { readonly requiresRemoteConnection: boolean; readonly showAddButton: boolean; readonly showVoiceInput: boolean; + // Whether a message typed while a turn is still running can be handed over + // right away. The desktop relay queues it server-side and lets the current + // turn yield to it, so on that surface the draft outranks the stop button. + // Where nothing queues, holding the draft back is the honest behaviour. + readonly supportsMidRunSend: boolean; constructor( surface: ChatSurface, supportsAttachments: boolean, requiresRemoteConnection: boolean, showAddButton: boolean = true, - showVoiceInput: boolean = true + showVoiceInput: boolean = true, + supportsMidRunSend: boolean = false ) { this.surface = surface; this.supportsAttachments = supportsAttachments; this.requiresRemoteConnection = requiresRemoteConnection; this.showAddButton = showAddButton; this.showVoiceInput = showVoiceInput; + this.supportsMidRunSend = supportsMidRunSend; } } @@ -26,7 +33,7 @@ export const GENERAL_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities = new ChatComposerCapabilities(ChatSurface.General, false, false); export const REMOTE_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities = - new ChatComposerCapabilities(ChatSurface.Remote, true, true); + new ChatComposerCapabilities(ChatSurface.Remote, true, true, true, true, true); export const REMOTE_CREATE_COMPOSER_CAPABILITIES: ChatComposerCapabilities = new ChatComposerCapabilities(ChatSurface.Remote, false, true, false, true); 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 79ad92a1cc..e2dbc9f92c 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 @@ -1,6 +1,6 @@ import { ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, toConversationUiMessage } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; +import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; import { ChatSurface } from './ChatSurface'; import { CARD, INK, LINE, MUTED, RED } from './Theme'; import { ChatMessageBubble } from './ChatMessageBubble'; @@ -9,6 +9,11 @@ import { RemoteLogger } from '../../services/RemoteLogger'; @ComponentV2 export struct ChatTimeline { private readonly listScroller: Scroller = new Scroller(); + // Whether new content should pull the viewport down with it. True until the + // user scrolls up to read back through the transcript: at that point taking + // the viewport away from them would be worse than letting the reply grow off + // screen, and the chevron button below gives them the way back. + @Local stickToBottom: boolean = true; @Param surface: ChatSurface = ChatSurface.Remote; @Param timelineItems: ChatTimelineItem[] = []; @Param timelineRevision: number = 0; @@ -34,6 +39,27 @@ export struct ChatTimeline { @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; + // Says whether an open reuses this subtree or rebuilds it. A rebuild means + // every bubble is constructed from nothing, and the cost of that is the + // difference between the two readings. + aboutToAppear(): void { + RemoteLogger.info(`chat timeline mounted surface=${this.surface} items=${this.timelineItems.length}`); + } + + aboutToDisappear(): void { + RemoteLogger.info(`chat timeline disposed items=${this.timelineItems.length}`); + } + + // `stackFromEnd` covers content that grows inside the last item, but a whole + // new bubble arriving is a layout change the list does not chase on its own. + @Monitor('timelineRevision') + onTimelineChanged(): void { + if (!this.stickToBottom) { + return; + } + this.listScroller.scrollEdge(Edge.Bottom); + } + build() { Stack({ alignContent: Alignment.Bottom }) { List({ space: 12, scroller: this.listScroller }) { @@ -52,15 +78,27 @@ export struct ChatTimeline { } ForEach(this.timelineItems, (item: ChatTimelineItem) => { this.TimelineItem(item) - }, (item: ChatTimelineItem) => `${this.timelineRevision}-${item.id}`) + }, (item: ChatTimelineItem) => ChatTimelineRevisionTracker.itemSignature(item)) } .width('100%') .height('100%') .padding({ left: 20, right: 20, top: 0, bottom: 12 }) - .stackFromEnd(false) + // A streaming reply grows in place rather than arriving as a new item, so + // the bottom of the list has to be the anchor — otherwise the text the + // agent is writing right now renders below the fold and never comes back. + .stackFromEnd(true) + // Keeps the read position when older messages are prepended above. + .maintainVisibleContentPosition(true) .scrollBar(BarState.Off) + .onDidScroll((_scrollOffset: number, scrollState: ScrollState) => { + if (this.listScroller.isAtEnd()) { + this.stickToBottom = true; + } else if (scrollState !== ScrollState.Idle) { + this.stickToBottom = false; + } + }) - if (this.surface === ChatSurface.Remote && this.timelineItems.length > 2) { + if (this.surface === ChatSurface.Remote && !this.stickToBottom) { Stack({ alignContent: Alignment.Center }) { SymbolGlyph($r('sys.symbol.chevron_down')) .fontSize(18) @@ -73,6 +111,7 @@ export struct ChatTimeline { .shadow({ radius: 14, color: '#14000000', offsetY: 5 }) .margin({ bottom: 4 }) .onClick(() => { + this.stickToBottom = true; this.listScroller.scrollEdge(Edge.Bottom); }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CompactMenuButton.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CompactMenuButton.ets index ab6f189da4..b1ddaecdc1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CompactMenuButton.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CompactMenuButton.ets @@ -1,5 +1,6 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK } from './Theme'; +import { TemplateIcon } from './TemplateIcon'; +import { CARD } from './Theme'; @ComponentV2 export struct CompactMenuButton { @@ -8,12 +9,11 @@ export struct CompactMenuButton { build() { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.gpt_home_menu_glyph')) - .width(22) - .height(14) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(INK) + TemplateIcon({ + src: $r('app.media.gpt_home_menu_glyph'), + iconWidth: 22, + iconHeight: 14 + }) } .width(this.controlSize) .height(this.controlSize) 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 2c0aceb715..5e32670d56 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 @@ -1,5 +1,5 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ChatComposerPolicy } from '../../services/ChatComposerPolicy'; +import { ChatComposerPolicy, ComposerPrimaryAction } from '../../services/ChatComposerPolicy'; import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; import { ChatSurface } from './ChatSurface'; import { @@ -19,6 +19,13 @@ export enum ComposerPresentation { const COMPOSER_ACTION_SIZE: number = 40; const COMPOSER_INPUT_HEIGHT: number = 42; const COMPOSER_EXPANDED_INPUT_HEIGHT: number = 74; +const COMPOSER_COLLAPSED_HEIGHT: number = 52; +const COMPOSER_EXPANDED_HEIGHT: number = 126; +const COMPOSER_IMAGE_CARD_SIZE: number = 64; +const COMPOSER_IMAGE_STRIP_TOP_GAP: number = 6; +/** Strip height plus its top gap and the parent Column's 2vp row spacing. */ +const COMPOSER_IMAGE_STRIP_BLOCK: number = + COMPOSER_IMAGE_CARD_SIZE + COMPOSER_IMAGE_STRIP_TOP_GAP + 2; @ComponentV2 export struct ComposerBar { @@ -28,7 +35,6 @@ export struct ComposerBar { @Param chatInput: string = ''; @Local inputText: string = ''; @Local inputFocused: boolean = false; - @Param showQuickActions: boolean = false; @Param selectedImages: ConversationUiSelectedImage[] = []; @Param isBusy: boolean = false; @Param canStop: boolean = false; @@ -42,7 +48,6 @@ export struct ComposerBar { @Param selectedModelId: string = ''; @Local showModelSelectorSheet: boolean = false; @Local showModelSelectorPopover: boolean = false; - @Event onToggleQuickActions: () => void = () => {}; @Event onPickImages: () => void = () => {}; @Event onRemoveImage: (imageId: string) => void = (_imageId: string) => {}; @Event onSend: () => void = () => {}; @@ -68,10 +73,7 @@ export struct ComposerBar { } build() { - Column({ space: 8 }) { - if (this.selectedImages.length > 0) { - this.SelectedImageStrip() - } + Column() { this.AdaptiveComposer() } .width('100%') @@ -90,6 +92,9 @@ export struct ComposerBar { @Builder AdaptiveComposer() { Column({ space: 2 }) { + if (this.selectedImages.length > 0) { + this.SelectedImageStrip() + } Row({ space: this.isComposerExpanded() ? 0 : 5 }) { if (this.shouldShowAddButton()) { Row() { @@ -135,7 +140,7 @@ export struct ComposerBar { } } .width('100%') - .height(this.isComposerExpanded() ? 126 : 52) + .height(this.composerHeight()) .padding({ left: 8, right: 8, @@ -143,8 +148,7 @@ export struct ComposerBar { bottom: this.isComposerExpanded() ? 2 : 0 }) .backgroundColor(CARD) - .borderRadius(this.isComposerExpanded() ? 18 : - (this.presentation === ComposerPresentation.Floating ? 18 : 26)) + .borderRadius(this.composerRadius()) .shadow({ radius: this.presentation === ComposerPresentation.Floating ? 18 : 10, color: this.presentation === ComposerPresentation.Floating ? '#18000000' : '#0D000000', @@ -302,10 +306,8 @@ export struct ComposerBar { if (this.isVoiceListening) { return; } - if (this.capabilities.supportsAttachments) { - this.onToggleQuickActions(); - return; - } + // Images are the only thing a message can carry, so a menu in front of the + // picker would be a menu of one. Straight to the album, same as local chat. this.onPickImages(); }) } @@ -362,26 +364,21 @@ export struct ComposerBar { PrimaryActionButton() { Button() { Stack({ alignContent: Alignment.Center }) { - if (this.isVoiceListening || this.canStop) { + if (this.primaryAction() === ComposerPrimaryAction.Stop) { Text('') .width(13) .height(13) .backgroundColor(CARD) .borderRadius(3) - } else if (this.hasComposedContent()) { - SymbolGlyph($r('sys.symbol.arrow_up')) - .fontSize(23) - .fontWeight(FontWeight.Medium) - .fontColor([INK]) - .opacity(this.canSend() ? 1 : 0.38) - } else if (this.capabilities.showVoiceInput) { + } else if (this.primaryAction() === ComposerPrimaryAction.Voice || + this.primaryAction() === ComposerPrimaryAction.VoiceBlocked) { this.MicrophoneGlyph() } else { SymbolGlyph($r('sys.symbol.arrow_up')) .fontSize(23) .fontWeight(FontWeight.Medium) .fontColor([INK]) - .opacity(0.38) + .opacity(this.primaryAction() === ComposerPrimaryAction.Send ? 1 : 0.38) } } .width(40) @@ -393,20 +390,23 @@ export struct ComposerBar { .type(ButtonType.Circle) .backgroundColor(this.actionBackgroundColor()) .borderRadius(20) + // Driven off the same decision that drew the glyph, so the button can never + // do something other than what it is showing. .onClick(() => { - if (this.isVoiceListening) { - this.onVoiceInput(); - return; - } - if (this.canStop) { - this.onStop(); + const action = this.primaryAction(); + if (action === ComposerPrimaryAction.Stop) { + if (this.isVoiceListening) { + this.onVoiceInput(); + } else if (this.canStop) { + this.onStop(); + } return; } - if (this.canSend()) { + if (action === ComposerPrimaryAction.Send) { this.onSend(); return; } - if (this.canUseVoice()) { + if (action === ComposerPrimaryAction.Voice) { this.onVoiceInput(); } }) @@ -466,19 +466,24 @@ export struct ComposerBar { this.SelectedImageCard(image) }, (image: ConversationUiSelectedImage) => image.id) } - .padding({ right: 4 }) + .padding({ left: 4, right: 4 }) } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) + // Scroll centres content that is narrower than the viewport, which parks a + // lone thumbnail in the middle of the composer instead of at its left edge. + .align(Alignment.Start) .width('100%') + .height(COMPOSER_IMAGE_CARD_SIZE) + .margin({ top: COMPOSER_IMAGE_STRIP_TOP_GAP }) } @Builder SelectedImageCard(image: ConversationUiSelectedImage) { Stack({ alignContent: Alignment.TopEnd }) { Image(image.data_url) - .width(64) - .height(64) + .width(COMPOSER_IMAGE_CARD_SIZE) + .height(COMPOSER_IMAGE_CARD_SIZE) .objectFit(ImageFit.Cover) .borderRadius(14) Text('×') @@ -494,37 +499,56 @@ export struct ComposerBar { this.onRemoveImage(image.id); }) } - .width(64) - .height(64) + .width(COMPOSER_IMAGE_CARD_SIZE) + .height(COMPOSER_IMAGE_CARD_SIZE) } - private canSend(): boolean { - return ChatComposerPolicy.canSend( + private canUseVoice(): boolean { + return this.capabilities.showVoiceInput && + ChatComposerPolicy.canUseVoice(this.inputText, this.selectedImages.length, this.isBusy); + } + + // Dictation runs inside this app rather than in a system dialog, so listening + // outranks the draft here in a way a running turn does not. + private primaryAction(): ComposerPrimaryAction { + return ChatComposerPolicy.primaryAction( this.inputText, this.selectedImages.length, this.isBusy, + this.isVoiceListening, + this.canStop, + this.capabilities.supportsMidRunSend, this.capabilities.requiresRemoteConnection, - this.connectionState + this.connectionState, + this.capabilities.showVoiceInput ); } - private canUseVoice(): boolean { - return this.capabilities.showVoiceInput && - ChatComposerPolicy.canUseVoice(this.inputText, this.selectedImages.length, this.isBusy); + private shouldShowAddButton(): boolean { + return this.capabilities.showAddButton && + (this.capabilities.supportsAttachments || this.capabilities.surface === ChatSurface.General); } - private hasComposedContent(): boolean { - return this.inputText.trim().length > 0 || this.selectedImages.length > 0; + // Attachments live inside the card, so the card has to grow to hold them. + private composerHeight(): number { + const base = this.isComposerExpanded() ? COMPOSER_EXPANDED_HEIGHT : COMPOSER_COLLAPSED_HEIGHT; + return this.selectedImages.length > 0 ? base + COMPOSER_IMAGE_STRIP_BLOCK : base; } - private shouldShowAddButton(): boolean { - return this.capabilities.showAddButton && - (this.capabilities.supportsAttachments || this.capabilities.surface === ChatSurface.General); + // The pill radius only reads as a pill while the card is one row tall. + private composerRadius(): number { + if (this.isComposerExpanded() || this.selectedImages.length > 0) { + return 18; + } + return this.presentation === ComposerPresentation.Floating ? 18 : 26; } private isComposerExpanded(): boolean { - return this.inputFocused || this.showQuickActions || this.showModelSelectorSheet || - this.showModelSelectorPopover || this.inputText.indexOf('\n') >= 0; + return ChatComposerPolicy.isExpanded( + this.inputText, + this.inputFocused, + this.isModelSelectorExpanded() + ); } private isModelSelectorExpanded(): boolean { @@ -590,13 +614,13 @@ export struct ComposerBar { RemoteI18n.t('chat.inputPlaceholder'); } + // Follows the offered action rather than the raw flags: while a turn runs and + // the user has typed something, the button is a send arrow, and a send arrow + // on a stop-red circle reads as "this will cancel". private actionBackgroundColor(): ResourceColor { - if (this.canStop) { - return RED; - } - if (this.isVoiceListening) { - return GREEN; + if (this.primaryAction() !== ComposerPrimaryAction.Stop) { + return '#00000000'; } - return '#00000000'; + return this.isVoiceListening ? GREEN : RED; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets index b01a2402db..986cc21b8e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets @@ -4,6 +4,8 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ConnectAccountDevicePage } from './ConnectAccountDevicePage'; import { ConnectManualPairingOverlay } from './ConnectManualPairingOverlay'; +import { CONNECT_INTENT_AUTO } from '../state/AppShellState'; +import { ConnectOpenIntentPolicy } from '../policy/ConnectOpenIntentPolicy'; import { ACCENT, CARD, CONNECT_HERO_ACCENT, CONNECT_HERO_BG, CONNECT_HERO_SECONDARY, CONNECT_HERO_SURFACE, CONNECT_SCAN_ACCENT, GREEN, INK, LINE, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; @@ -28,7 +30,7 @@ export struct ConnectView { @Param controlTargetDeviceId: string = ''; @Param requiresAccountAuth: boolean = false; @Param accountUsername: string = ''; - @Param startWithScanner: boolean = true; + @Param openIntent: string = CONNECT_INTENT_AUTO; @Event onBack: () => void = () => {}; @Event onConnect: (password?: string) => void = (_password?: string) => {}; @Event onRemoteUrlChange: (value: string) => void = (_value: string) => {}; @@ -46,11 +48,12 @@ export struct ConnectView { @Local requestingCameraPermission: boolean = false; aboutToAppear(): void { - if (this.isAccountAuthenticated()) { - this.pairingStep = 'account'; - } else if (this.startWithScanner && this.remoteUrl.trim().length === 0) { - this.pairingStep = 'scan'; - } + this.pairingStep = ConnectOpenIntentPolicy.initialStep( + this.openIntent, + this.isAccountAuthenticated(), + this.remoteUrl, + this.pairingStep + ); } aboutToDisappear(): void { @@ -261,7 +264,8 @@ export struct ConnectView { .borderRadius(24) .position({ x: 28, y: 18 }) .onClick(() => { - if (this.currentStep() === 'scan' && this.remoteUrl.trim().length === 0 && !this.showManualPairing) { + if (ConnectOpenIntentPolicy.backStaysInSheet( + this.openIntent, this.currentStep(), this.remoteUrl, this.showManualPairing)) { this.stopInlineScan(); this.pairingStep = this.isAccountAuthenticated() ? 'account' : 'intro'; return; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationUiModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationUiModels.ets index 0e656fc1a6..5de53cba77 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationUiModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationUiModels.ets @@ -100,8 +100,11 @@ export interface ConversationUiMessage { } export interface ConversationUiQuestionAnswer { - answer: string; - '0': string; + answer?: string; + '0'?: string | string[]; + '1'?: string | string[]; + '2'?: string | string[]; + '3'?: string | string[]; } export function toConversationUiSession(source: SessionSummary): ConversationUiSession { @@ -205,5 +208,22 @@ export function toConversationUiMessage(source: ChatMessage): ConversationUiMess } export function toRemoteQuestionAnswer(source: ConversationUiQuestionAnswer): RemoteQuestionAnswerPayload { - return { answer: source.answer, '0': source.answer }; + const result: Record = {}; + const values = source as Record; + if (source.answer !== undefined) { + result['answer'] = source.answer as Object; + } + if (values['0'] !== undefined) { + result['0'] = values['0']; + } + if (values['1'] !== undefined) { + result['1'] = values['1']; + } + if (values['2'] !== undefined) { + result['2'] = values['2']; + } + if (values['3'] !== undefined) { + result['3'] = values['3']; + } + return result as RemoteQuestionAnswerPayload; } 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 db3f0fc7f1..a551a782c1 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 @@ -2,6 +2,7 @@ import { KeyboardAvoidMode } from '@kit.ArkUI'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; +import { RemoteLogger } from '../../services/RemoteLogger'; import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; import { ChatSurface } from './ChatSurface'; import { ChatStatusBar } from './ChatStatusBar'; @@ -90,17 +91,17 @@ export struct ConversationView { @Event onSend: () => void = () => {}; @Event onVoiceInput: () => void = () => {}; @Event onChatInputChange: (value: string) => void = (_value: string) => {}; - @Event onToggleQuickActions: () => void = () => {}; - @Local showQuickActions: boolean = false; @Local showHeaderActions: boolean = false; private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; aboutToAppear(): void { + RemoteLogger.info(`conversation view mounted surface=${this.surface}`); this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); } aboutToDisappear(): void { + RemoteLogger.info('conversation view disposed'); this.getUIContext().setKeyboardAvoidMode(this.previousKeyboardAvoidMode); } @@ -140,10 +141,6 @@ export struct ConversationView { .animation({ duration: 220, curve: Curve.EaseInOut }) } .width('100%').height('100%').backgroundColor(PAGE_BG) - if (this.showQuickActions && this.composerCapabilities.supportsAttachments) { - this.MenuBackdrop(() => { this.showQuickActions = false; }) - this.QuickActionsMenu() - } } .width('100%') .height('100%') @@ -173,7 +170,6 @@ export struct ConversationView { this.onBack(); }, onOpenActions: () => { - this.showQuickActions = false; this.showHeaderActions = !this.showHeaderActions; }, onActionsMenuStateChange: (visible: boolean) => { @@ -202,7 +198,6 @@ export struct ConversationView { this.onRestoreSidebar(); }, onOpenActions: () => { - this.showQuickActions = false; this.showHeaderActions = !this.showHeaderActions; }, onActionsMenuStateChange: (visible: boolean) => { @@ -330,10 +325,6 @@ export struct ConversationView { presentation: this.composerPresentation, capabilities: this.composerCapabilities, chatInput: this.chatInput, - showQuickActions: this.showQuickActions, - onToggleQuickActions: () => { - this.showQuickActions = !this.showQuickActions; - }, selectedImages: this.selectedImages, isBusy: this.isBusy, canStop: this.canStop, @@ -365,97 +356,6 @@ export struct ConversationView { }) } - @Builder - AttachmentPanel() { - Column({ space: 8 }) { - Row() { - Text(RemoteI18n.t('chat.attachments')) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - Text(RemoteI18n.t('common.close')) - .fontSize(12) - .fontColor(MUTED) - .onClick(() => { - this.showQuickActions = false; - }) - } - .width('100%') - this.ImageActionRow() - Text(RemoteI18n.t('chat.quickPrompts')) - .fontSize(11) - .fontColor(MUTED) - .width('100%') - Row({ space: 8 }) { - this.QuickActionChip(RemoteI18n.t('chat.quickExplain'), RemoteI18n.t('chat.quickExplainPrompt')) - this.QuickActionChip(RemoteI18n.t('chat.quickContinue'), RemoteI18n.t('chat.quickContinuePrompt')) - this.QuickActionChip(RemoteI18n.t('chat.quickSummary'), RemoteI18n.t('chat.quickSummaryPrompt')) - } - .width('100%') - } - .width('100%') - .padding({ left: 18, right: 18, top: 12, bottom: 10 }) - } - - @Builder - MenuBackdrop(onClose: () => void) { - Text('') - .width('100%').height('100%') - .backgroundColor(this.composerPresentation === ComposerPresentation.Floating ? '#00000000' : '#18000000') - .zIndex(5) - .onClick(onClose) - } - - @Builder - QuickActionsMenu() { - if (this.composerPresentation === ComposerPresentation.Floating) { - this.QuickActionsPopover() - } else { - this.QuickActionsBottomSheet() - } - } - - @Builder - QuickActionsPopover() { - Column() { - this.AttachmentPanel() - } - .width(360) - .backgroundColor(CARD) - .border({ width: 1, color: LINE }) - .borderRadius(18) - .shadow({ radius: 20, color: '#1A000000', offsetY: 8 }) - .position({ left: 80 + this.contentHorizontalOffset, bottom: 86 }) - .zIndex(6) - .transition(TransitionEffect.translate({ x: 0, y: 14 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseOut })) - } - - @Builder - QuickActionsBottomSheet() { - Column() { - Text('') - .width(36) - .height(4) - .backgroundColor(LINE) - .borderRadius(2) - .margin({ top: 10 }) - this.AttachmentPanel() - } - .width('100%') - .backgroundColor(CARD) - .border({ width: { top: 1 }, color: LINE }) - .borderRadius({ topLeft: 18, topRight: 18 }) - .position({ left: 0, bottom: 0 }) - .zIndex(6) - .alignItems(HorizontalAlign.Center) - .transition(TransitionEffect.translate({ x: 0, y: 24 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseOut })) - } - @Builder HeaderActionsPopover() { Column() { @@ -525,63 +425,6 @@ export struct ConversationView { } } - @Builder - ImageActionRow() { - Row({ space: 10 }) { - Text('+') - .width(32) - .height(32) - .fontSize(21) - .fontColor(INK) - .textAlign(TextAlign.Center) - .backgroundColor(SOFT) - .borderRadius(16) - Column({ space: 3 }) { - Text(RemoteI18n.t('chat.pickImage')) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Text(RemoteI18n.t('chat.pickImageDesc')) - .fontSize(11) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - Text('>') - .fontSize(20) - .fontColor(MUTED) - } - .width('100%') - .height(48) - .padding({ left: 12, right: 12 }) - .backgroundColor(CARD) - .borderRadius(14) - .border({ width: 1, color: LINE }) - .onClick(() => { - this.showQuickActions = false; - this.onPickImages(); - }) - } - - @Builder - QuickActionChip(label: string, prompt: string) { - Text(label) - .fontSize(11) - .fontColor(INK) - .textAlign(TextAlign.Center) - .layoutWeight(1) - .height(30) - .backgroundColor(CARD) - .borderRadius(15) - .border({ width: 1, color: LINE }) - .onClick(() => { - this.onChatInputChange(prompt); - this.showQuickActions = false; - }) - } - @Builder SuggestionIcon(kind: string) { Stack({ alignContent: Alignment.Center }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets index 0382f2db61..8deb0fb9c4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets @@ -1,6 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { CompactMenuButton } from './CompactMenuButton'; +import { TemplateIcon } from './TemplateIcon'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 @@ -71,12 +72,11 @@ export struct GeneralChatHeader { }) } else if (this.showBackButton) { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_back')) - .width(15) - .height(23) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(INK) + TemplateIcon({ + src: $r('app.media.remote_ref_back'), + iconWidth: 15, + iconHeight: 23 + }) } .width(44) .height(44) @@ -103,12 +103,11 @@ export struct GeneralChatHeader { private TrailingControl() { if (this.showActions) { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_more')) - .width(23) - .height(7) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(INK) + TemplateIcon({ + src: $r('app.media.remote_ref_more'), + iconWidth: 23, + iconHeight: 7 + }) } .width(44) .height(44) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets index 539ea93aaa..40c69d054d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets @@ -2,6 +2,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConversationUiSession } from './ConversationUiModels'; import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION_TEXT, SOFT } from './Theme'; import { CompactMenuButton } from './CompactMenuButton'; +import { TemplateIcon } from './TemplateIcon'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 @@ -86,12 +87,11 @@ export struct RemoteChatHeader { }) } else if (this.showBackButton) { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_back')) - .width(15) - .height(23) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(INK) + TemplateIcon({ + src: $r('app.media.remote_ref_back'), + iconWidth: 15, + iconHeight: 23 + }) } .width(44) .height(44) @@ -118,12 +118,11 @@ export struct RemoteChatHeader { @Builder private ActionsControl() { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_more')) - .width(23) - .height(7) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(INK) + TemplateIcon({ + src: $r('app.media.remote_ref_more'), + iconWidth: 23, + iconHeight: 7 + }) } .width(44) .height(44) 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 3396da0e91..c3cda2916a 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 @@ -1,5 +1,5 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; +import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { RemotePermissionMode } from '../../model/RemoteModels'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; @@ -23,7 +23,6 @@ export struct RemoteControlSettingsSheet { @Event onOpenAccount: () => void = () => {}; @Event onAddConnection: () => void = () => {}; @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; - @Event cloudSync: () => Promise = async (): Promise => '0'; @Event cloudLogout: () => Promise = async (): Promise => {}; @Event cloudListDevices: () => Promise = async (): Promise => []; @Event getPermissionMode: () => Promise = async (): Promise => 'ask'; @@ -33,8 +32,6 @@ export struct RemoteControlSettingsSheet { @Event onReconnect: () => void = () => {}; @Local showProfile: boolean = false; @Local showLogin: boolean = false; - @Local cloudSyncBusy: boolean = false; - @Local cloudSyncStatus: string = ''; @Local accountDevices: CloudAccountDevice[] = []; @Local accountDevicesBusy: boolean = false; @Local accountDevicesError: string = ''; @@ -553,32 +550,6 @@ export struct RemoteControlSettingsSheet { .lineHeight(20) .fontColor(MUTED) .width('100%') - - Text(RemoteI18n.t('remote.settings.accountSync')) - .height(42) - .width('100%') - .fontSize(15) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center) - .borderRadius(21) - .opacity(this.cloudSyncBusy ? 0.52 : 1) - .onClick(async () => { - if (this.cloudSyncBusy) return; - this.cloudSyncBusy = true; - this.cloudSyncStatus = ''; - try { - const count = await this.cloudSync(); - this.cloudSyncStatus = RemoteI18n.f('remote.settings.accountSyncSuccess', count); - } catch (err) { - this.cloudSyncStatus = err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed'); - } finally { - this.cloudSyncBusy = false; - } - }) - if (this.cloudSyncStatus.length > 0) { - Text(this.cloudSyncStatus).fontSize(13).fontColor(MUTED).width('100%') - } } .width('100%') .padding({ left: 18, right: 18, top: 16, bottom: 16 }) @@ -613,7 +584,6 @@ export struct RemoteControlSettingsSheet { this.logoutBusy = true; try { await this.cloudLogout(); - this.cloudSyncStatus = ''; this.showProfile = false; this.showLogin = true; } finally { 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 index a11ab7ee5e..130cfd836a 100644 --- 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 @@ -8,6 +8,7 @@ import { ComposerBar, ComposerPresentation } from './ComposerBar'; import { ConversationUiModelCatalog } from './ConversationUiModels'; import { REMOTE_CREATE_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; import { SidebarToggleButton } from './SidebarToggleButton'; +import { TemplateIcon } from './TemplateIcon'; @ComponentV2 export struct RemoteCreateSessionView { @@ -93,12 +94,11 @@ export struct RemoteCreateSessionView { Column() { Row() { Button() { - Image($r('app.media.remote_ref_back')) - .width(15) - .height(23) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(INK) + TemplateIcon({ + src: $r('app.media.remote_ref_back'), + iconWidth: 15, + iconHeight: 23 + }) } .width(44) .height(44) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index bfca15020c..5f74954afe 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -1,11 +1,15 @@ import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { TimeFormat } from '../../services/TimeFormat'; import { CARD, INK, MUTED, SOFT } from './Theme'; import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy'; import { SessionDetailsView } from './SessionDetailsView'; -import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; +import { + SessionListInputs, + SessionListProjection, + SessionListProjectionCache, + SessionListProjector +} from '../policy/SessionListProjection'; @ComponentV2 export struct RemoteSessionList { @@ -47,6 +51,11 @@ export struct RemoteSessionList { @Local detailsSessionId: string = ''; @Local showSessionDetails: boolean = false; @Local optimisticSelectedSessionId: string = ''; + // Selecting a row reruns this whole builder tree, and the tree asks for the + // grouped lists far more often than it changes them. The cache answers from + // the last projection unless the sessions, the workspaces or a filter moved — + // none of which a selection touches. + private readonly projectionCache: SessionListProjectionCache = new SessionListProjectionCache(); @Monitor('isBusy', 'workspacePath') onWorkspaceContextChanged(): void { @@ -414,21 +423,34 @@ export struct RemoteSessionList { } } + /** + * The grouped session list, computed at most once per change to its inputs. + * + * Every derived accessor below reads through here rather than deriving + * anything itself, so calling them repeatedly across the builder tree — which + * is what the tree does — costs a map lookup instead of a full scan. + */ + private view(): SessionListProjection { + const inputs: SessionListInputs = { + sessions: this.sessions, + query: this.query, + sortMode: this.sortMode, + workspaceName: this.workspaceName, + workspacePath: this.workspacePath, + workspaceKind: this.workspaceKind, + recentWorkspaces: this.recentWorkspaces, + workspaceFilter: this.workspaceFilter, + agentFilter: this.agentFilter, + statusFilter: this.statusFilter, + showWorkspaceMetadata: this.showWorkspaceMetadata, + showUpdatedMetadata: this.showUpdatedMetadata, + showStatusMetadata: this.showStatusMetadata + }; + return this.projectionCache.get(inputs); + } + private projectEntries(): RecentWorkspaceEntry[] { - const entries: RecentWorkspaceEntry[] = []; - if ((this.workspacePath.length > 0 || this.workspaceName.length > 0) && !this.isAssistantWorkspace(this.workspacePath)) { - entries.push({ path: this.workspacePath, name: this.workspaceName, lastOpened: '', workspaceKind: 'normal' }); - } - this.recentWorkspaces.forEach((item: RecentWorkspaceEntry) => { - if (item.path.length > 0 && !this.isAssistantWorkspace(item.path) && - !entries.some((entry: RecentWorkspaceEntry) => entry.path === item.path)) { - entries.push(item); - } - }); - if (!this.hasActiveListFilter()) { - return entries; - } - return entries.filter((item: RecentWorkspaceEntry) => this.projectSessions(item.path).length > 0); + return this.view().projects; } private visibleProjectEntries(): RecentWorkspaceEntry[] { @@ -445,76 +467,31 @@ export struct RemoteSessionList { } private visibleChatSessions(): RemoteSession[] { - return this.filteredSessions().filter((item: RemoteSession) => this.isAssistantSession(item)); + return this.view().chats; } private sessionsByTime(): RemoteSession[] { - return this.filteredSessions().slice().sort((left: RemoteSession, right: RemoteSession) => { - return this.sessionTimestamp(right) - this.sessionTimestamp(left); - }); + return this.view().byTime; } private sessionsForTimeBucket(bucket: string): RemoteSession[] { - return this.sessionsByTime().filter((item: RemoteSession) => { - return this.timeBucket(item) === bucket; - }); - } - - private timeBucket(item: RemoteSession): string { - const timestamp = this.sessionTimestamp(item); - if (timestamp <= 0) { - return 'earlier'; - } - const sessionDate = new Date(timestamp); - const now = new Date(); - if (this.sameCalendarDay(sessionDate, now)) { - return 'today'; - } - const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); - return this.sameCalendarDay(sessionDate, yesterday) ? 'yesterday' : 'earlier'; - } - - private sessionTimestamp(item: RemoteSession): number { - const updated = TimeFormat.timestampMs(item.updatedAt || ''); - if (!Number.isNaN(updated) && updated > 0) { - return updated; + const projection = this.view(); + if (bucket === 'today') { + return projection.today; } - const created = TimeFormat.timestampMs(item.createdAt || ''); - return !Number.isNaN(created) && created > 0 ? created : 0; - } - - private sameCalendarDay(left: Date, right: Date): boolean { - return left.getFullYear() === right.getFullYear() && - left.getMonth() === right.getMonth() && - left.getDate() === right.getDate(); + return bucket === 'yesterday' ? projection.yesterday : projection.earlier; } private projectSessions(path: string): RemoteSession[] { - return this.filteredSessions().filter((item: RemoteSession) => { - return !this.isAssistantSession(item) && ConversationSessionFilterPolicy.workspacePathsEqual( - item.workspacePath || this.workspacePath, - path - ); - }); + return SessionListProjector.sessionsForProject(this.view(), path); } private filteredSessions(): RemoteSession[] { - return this.sessions.filter((item: RemoteSession) => { - return ConversationSessionFilterPolicy.matches( - item, - this.query, - this.workspacePath, - this.workspaceFilter, - this.agentFilter, - this.statusFilter, - this.isAssistantSession(item) - ); - }); + return this.view().filtered; } private hasActiveListFilter(): boolean { - return this.query.trim().length > 0 || this.workspaceFilter.length > 0 || - this.agentFilter.length > 0 || this.statusFilter.length > 0; + return this.view().hasActiveFilter; } private visibleProjectSessions(path: string): RemoteSession[] { @@ -534,25 +511,6 @@ export struct RemoteSessionList { return Math.min(3, this.visibleChatSessions().length - this.chatVisibleCount); } - private isAssistantSession(item: RemoteSession): boolean { - const agentType = (item.agentType || '').toLowerCase(); - return agentType === 'claw' || agentType === 'assistant' || agentType === 'chat' || - this.isAssistantWorkspace(item.workspacePath || ''); - } - - private isAssistantWorkspace(path: string): boolean { - if (path.length === 0) { - return this.workspaceKind.toLowerCase() === 'assistant'; - } - if (path === this.workspacePath && this.workspaceKind.toLowerCase() === 'assistant') { - return true; - } - return this.recentWorkspaces.some((item: RecentWorkspaceEntry) => { - return item.path === path && item.workspaceKind.toLowerCase() === 'assistant'; - }); - } - - private isWorkspaceCollapsed(path: string): boolean { return this.collapsedWorkspacePaths.indexOf(path) >= 0; } @@ -748,20 +706,7 @@ export struct RemoteSessionList { } private metadataText(item: RemoteSession): string { - const values: string[] = []; - if (this.showWorkspaceMetadata) { - const workspace = item.workspaceName || item.workspacePath || ''; - if (workspace.length > 0) { - values.push(workspace); - } - } - if (this.showUpdatedMetadata && !Number.isNaN(TimeFormat.timestampMs(item.updatedAt))) { - values.push(TimeFormat.relative(item.updatedAt)); - } - if (this.showStatusMetadata && item.status.length > 0) { - values.push(item.status === 'archived' ? RemoteI18n.t('sidebar.archived') : item.status); - } - return values.join(' · '); + return SessionListProjector.metadataFor(this.view(), item.id); } private actionCapabilities(): SessionActionCapabilities { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets index af5a2504d6..11bdba7192 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets @@ -1,4 +1,5 @@ import { CARD, GREEN, INK, MUTED } from './Theme'; +import { TemplateIcon } from './TemplateIcon'; @ComponentV2 export struct SidebarGlyph { @@ -44,15 +45,11 @@ export struct SidebarGlyph { private Remote() { Stack({ alignContent: Alignment.Center }) { if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { - Image($r('app.media.remote_ref_sidebar_connected')) - .width(35).height(34).objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template).foregroundColor(INK) + TemplateIcon({ src: $r('app.media.remote_ref_sidebar_connected'), iconWidth: 35, iconHeight: 34 }) Text('').width(8).height(8).backgroundColor(GREEN).borderRadius(4) .position({ x: 24, y: 22 }) } else { - Image($r('app.media.remote_logo')) - .width(34).height(34).objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template).foregroundColor(MUTED) + TemplateIcon({ src: $r('app.media.remote_logo'), iconWidth: 34, iconHeight: 34, tint: MUTED }) } } .width(35).height(34) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/TemplateIcon.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/TemplateIcon.ets new file mode 100644 index 0000000000..86d16c223d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/TemplateIcon.ets @@ -0,0 +1,36 @@ +import { INK } from './Theme'; + +// Monochrome bitmap glyph tinted with a semantic theme color. +// +// The reference assets are black-pixel PNGs with an alpha mask, and ArkUI +// cannot tint those the way iOS template images work: `fillColor` only applies +// to SVG sources, and `renderMode(ImageRenderMode.Template)` is a monochrome +// render that keeps the original luminance, so the black pixels stayed black on +// dark surfaces. Painting the tint over the glyph in an offscreen layer and +// clipping it to the alpha mask with `SRC_IN` keeps the color a plain +// `ResourceColor`, so it still resolves per theme. +@ComponentV2 +export struct TemplateIcon { + @Require @Param src: Resource; + @Param iconWidth: number = 24; + @Param iconHeight: number = 24; + @Param tint: ResourceColor = INK; + + build() { + Stack() { + Image(this.src) + .width(this.iconWidth) + .height(this.iconHeight) + .objectFit(ImageFit.Contain) + .draggable(false) + Text('') + .width(this.iconWidth) + .height(this.iconHeight) + .backgroundColor(this.tint) + .blendMode(BlendMode.SRC_IN) + } + .width(this.iconWidth) + .height(this.iconHeight) + .blendMode(BlendMode.SRC_OVER, BlendApplyType.OFFSCREEN) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets index 9af587e2af..8b930354b3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets @@ -2,6 +2,22 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConversationUiQuestionAnswer } from './ConversationUiModels'; import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; +interface QuestionOption { + label: string; + description?: string; +} + +interface QuestionInput { + question: string; + header: string; + options: QuestionOption[]; + multiSelect?: boolean; +} + +interface QuestionPayload { + questions?: QuestionInput[]; +} + @ComponentV2 export struct ToolConfirmationPanel { @Param toolId: string = ''; @@ -116,36 +132,38 @@ export struct ToolConfirmationPanel { @ComponentV2 export struct ToolQuestionAnswerPanel { @Param toolId: string = ''; - @Param prompt: string = ''; + @Param questionsJson: string = ''; + @Param isBusy: boolean = false; @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; - @Local answerText: string = ''; + @Local questions: QuestionInput[] = []; + @Local selectedAnswers: Map = new Map(); + @Local customAnswers: Map = new Map(); + + aboutToAppear(): void { + this.questions = this.parseQuestions(this.questionsJson); + } + + @Monitor('questionsJson') + onQuestionsChanged(): void { + this.questions = this.parseQuestions(this.questionsJson); + this.selectedAnswers = new Map(); + this.customAnswers = new Map(); + } build() { Column({ space: 8 }) { - Text(this.prompt) - .fontSize(12) - .lineHeight(17) - .fontColor(INK) - .width('100%') - TextArea({ placeholder: RemoteI18n.t('chat.answerPlaceholder'), text: this.answerText }) - .height(78) - .fontSize(13) - .backgroundColor(CARD) - .borderRadius(14) - .padding(12) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .enabled(true) - .onChange((value: string) => { this.answerText = value; }) + ForEach(this.questions, (question: QuestionInput, index: number) => { + this.Question(question, index); + }, (question: QuestionInput, index: number) => `${index}:${question.header}:${question.question}`) Row() { Text(RemoteI18n.t('chat.submitAnswer')) - .fontSize(12) - .fontColor(this.canSubmit() ? PRIMARY_ACTION_TEXT : MUTED) + .fontSize(12).fontColor(this.canSubmit() ? PRIMARY_ACTION_TEXT : MUTED) .textAlign(TextAlign.Center) .height(32) .layoutWeight(1) .backgroundColor(this.canSubmit() ? ACCENT : SOFT) + .opacity(this.isBusy ? 0.5 : 1) .borderRadius(16) .onClick(() => this.submit()) } @@ -155,17 +173,137 @@ export struct ToolQuestionAnswerPanel { .padding({ left: 30 }) } + @Builder + private Question(question: QuestionInput, index: number) { + Column({ space: 6 }) { + Text(question.header.length > 0 ? `${question.header} ${question.question}` : question.question) + .fontSize(12).lineHeight(17).fontColor(INK).width('100%') + ForEach(question.options, (option: QuestionOption) => { + this.OptionRow(option, index, question.multiSelect === true) + }, (option: QuestionOption) => option.label) + if (this.isOtherSelected(index)) { + TextArea({ placeholder: RemoteI18n.t('chat.answerPlaceholder'), text: this.customAnswers.get(index) || '' }) + .height(64).fontSize(12).backgroundColor(CARD).borderRadius(10).padding(10) + .border({ width: 1, color: LINE }).defaultFocus(false).enabled(!this.isBusy) + .onChange((value: string) => { + const next = new Map(this.customAnswers); + next.set(index, value); + this.customAnswers = next; + }) + } + } + .width('100%') + } + + // The label is the thing being chosen, so it wraps rather than truncates: an + // option the user cannot read in full is one they cannot pick with any + // confidence. That rules out a fixed row height and a side-by-side + // description, which on a phone leaves the label a sliver of the row. + @Builder + private OptionRow(option: QuestionOption, index: number, multiSelect: boolean) { + Row({ space: 8 }) { + SymbolGlyph(this.isSelected(index, option.label) ? + $r('sys.symbol.checkmark_circle_fill') : $r('sys.symbol.circle')) + .fontSize(18) + .fontColor([this.isSelected(index, option.label) ? INK : MUTED]) + .width(20) + Column({ space: 3 }) { + Text(option.label) + .fontSize(12) + .lineHeight(17) + .fontColor(INK) + .width('100%') + if (option.description && option.description.length > 0) { + Text(option.description) + .fontSize(11) + .lineHeight(15) + .fontColor(MUTED) + .maxLines(3) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .alignItems(VerticalAlign.Top) + .padding({ left: 10, right: 10, top: 9, bottom: 9 }) + .backgroundColor(this.isSelected(index, option.label) ? SOFT : CARD).borderRadius(10) + .onClick(() => this.toggleOption(index, option.label, multiSelect)) + } + private canSubmit(): boolean { - return this.toolId.length > 0 && this.answerText.trim().length > 0; + if (this.isBusy || this.toolId.length === 0 || this.questions.length === 0) { + return false; + } + return this.questions.every((_question: QuestionInput, index: number) => { + const selected = this.selectedAnswers.get(index) || []; + if (selected.length === 0) return false; + return !this.isOtherSelected(index) || (this.customAnswers.get(index) || '').trim().length > 0; + }); + } + + private isSelected(index: number, label: string): boolean { + return (this.selectedAnswers.get(index) || []).indexOf(label) >= 0; + } + + private isOtherSelected(index: number): boolean { + return this.isSelected(index, RemoteI18n.t('common.other')); + } + + private toggleOption(index: number, label: string, multiSelect: boolean): void { + if (this.isBusy) return; + const current = (this.selectedAnswers.get(index) || []).slice(); + if (multiSelect) { + const position = current.indexOf(label); + if (position >= 0) current.splice(position, 1); + else current.push(label); + } else { + current.splice(0, current.length, label); + } + const next = new Map(this.selectedAnswers); + next.set(index, current); + this.selectedAnswers = next; } private submit(): void { if (!this.canSubmit()) { return; } - const answer = this.answerText.trim(); - const answers: ConversationUiQuestionAnswer = { answer, '0': answer }; + const answers: ConversationUiQuestionAnswer = {}; + this.questions.forEach((question: QuestionInput, index: number) => { + const selected = (this.selectedAnswers.get(index) || []).map((value: string) => { + return value === RemoteI18n.t('common.other') ? (this.customAnswers.get(index) || '').trim() : value; + }); + const value: string | string[] = question.multiSelect === true ? selected : selected[0]; + (answers as Record)[`${index}`] = value as Object; + }); this.onAnswerQuestion(this.toolId, answers); - this.answerText = ''; + } + + private parseQuestions(value: string): QuestionInput[] { + try { + const payload = JSON.parse(value) as QuestionPayload; + return (payload.questions || []).map((question: QuestionInput) => this.normalizeQuestion(question)) + .filter((question: QuestionInput) => question.question.length > 0); + } catch (_err) { + return []; + } + } + + private normalizeQuestion(question: QuestionInput): QuestionInput { + const normalized: QuestionInput = { + question: question.question || '', + header: question.header || '', + options: (question.options || []).slice(), + multiSelect: question.multiSelect === true + }; + const otherLabel = RemoteI18n.t('common.other'); + if (!normalized.options.some((option: QuestionOption) => option.label.trim().toLowerCase() === 'other' || + option.label === otherLabel)) { + normalized.options.push({ label: otherLabel, description: '' }); + } + return normalized; } } 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 bb8c2ed7e9..fa8f893aeb 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 @@ -146,11 +146,12 @@ export struct ToolStatusList { if (this.isQuestionTool(tool)) { ToolQuestionAnswerPanel({ toolId: tool.id || '', - prompt: this.questionPrompt(tool), + questionsJson: this.questionInputJson(tool), + isBusy: this.isBusy, onAnswerQuestion: this.onAnswerQuestion }) } - if (this.isRunningTool(tool)) { + if (this.isRunningTool(tool) || this.isQuestionTool(tool)) { Row() { Blank() Text(RemoteI18n.t('chat.cancelTool')) @@ -1015,8 +1016,8 @@ export struct ToolStatusList { private isQuestionTool(tool: ConversationUiToolStatus): boolean { const status = (tool.status || '').toLowerCase(); - const name = tool.name || ''; - return name === 'AskUserQuestion' && + const name = this.normalizedToolName(tool); + return (name === 'askuserquestion' || name === 'ask_user_question') && status !== 'completed' && status !== 'done' && status !== 'failed' && @@ -1039,6 +1040,16 @@ export struct ToolStatusList { return this.toolPreview(preview); } + private questionInputJson(tool: ConversationUiToolStatus): string { + if (tool.tool_input) { + try { + return JSON.stringify(tool.tool_input); + } catch (_err) { + } + } + return this.toolInputPreview(tool); + } + private questionPromptFromJson(preview: string): string { if (preview.trim().length === 0) { return ''; 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 11f1f41e09..0df472ef2b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -1,5 +1,7 @@ import { RemoteI18n } from '../../../i18n/RemoteI18n'; import { RemoteSession } from '../../../model/RemoteModels'; +import { RemoteLogger } from '../../../services/RemoteLogger'; +import { RemoteUiState } from '../../../services/RemoteUiState'; import { RemotePageState } from '../../state/RemotePageState'; import { AppRootPresentationActions, @@ -53,6 +55,14 @@ export struct RemoteSurfaceHost { @Event onRestoreSidebar: () => void = () => {}; @Event onCloseSettings: () => void = () => {}; + // The wide layout keeps the master pane inside a routed destination, so a + // mount here while switching sessions means the navigation stack was torn + // down for a change that is only page state — the sidebar rebuild this used + // to pay for. See AppShellViewModel.replaceRouteWithoutAnimation. + aboutToAppear(): void { + RemoteLogger.info(`remote surface mounted mode=${this.mode}`); + } + build() { if (this.mode === RemoteSurfaceMode.Master) { this.MasterContent(); @@ -89,6 +99,9 @@ export struct RemoteSurfaceHost { showStatusMetadata: this.presentationState.showStatusMetadata, hasMoreSessions: this.remotePageState.hasMoreSessions, isBusy: this.remotePageState.conversation.isBusy || this.remotePageState.isLoadingSessions, + // A pending id means a row was tapped, whether or not the open is slow + // enough to have raised a skeleton. Keying the highlight off the + // loading flag instead used to tie selection to how long the load took. selectedSessionId: this.remotePageState.pendingSessionId.length > 0 ? this.remotePageState.pendingSessionId : (this.showSelectedSession ? this.remotePageState.conversation.activeSession.sessionId : ''), @@ -121,6 +134,17 @@ export struct RemoteSurfaceHost { .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .layoutWeight(1) + if (this.canReconnect()) { + Text(RemoteI18n.t('remote.settings.reconnect')) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(PRIMARY_ACTION_TEXT) + .height(28) + .padding({ left: 12, right: 12 }) + .backgroundColor(PRIMARY_ACTION) + .borderRadius(14) + .onClick(() => this.actions.onRemoteHome.connectWorkspace()) + } } .width('100%') .margin({ top: 16, bottom: 6 }) @@ -129,7 +153,7 @@ export struct RemoteSurfaceHost { @Builder private StatusIndicator() { - if (this.isInitialLoading()) { + if (this.isSyncing()) { LoadingProgress().width(14).height(14).color(MUTED) } else { Stack() { @@ -201,11 +225,22 @@ export struct RemoteSurfaceHost { .fontSize(14).lineHeight(21).fontColor(MUTED).maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }).textAlign(TextAlign.Center) .constraintSize({ maxWidth: 280 }) - Text(RemoteI18n.t('remote.startSession')) - .width(148).height(46).fontSize(15).fontWeight(FontWeight.Medium) - .fontColor(PRIMARY_ACTION_TEXT).backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) - .onClick(() => this.actions.onRemoteHome.createAssistant()) + // Starting a session needs a desktop on the other end, so while the link + // is down the button that is actually useful is the one that brings it + // back up. + if (this.canReconnect()) { + Text(RemoteI18n.t('remote.settings.reconnect')) + .width(148).height(46).fontSize(15).fontWeight(FontWeight.Medium) + .fontColor(PRIMARY_ACTION_TEXT).backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) + .onClick(() => this.actions.onRemoteHome.connectWorkspace()) + } else { + Text(RemoteI18n.t('remote.startSession')) + .width(148).height(46).fontSize(15).fontWeight(FontWeight.Medium) + .fontColor(PRIMARY_ACTION_TEXT).backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) + .onClick(() => this.actions.onRemoteHome.createAssistant()) + } } .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center).padding({ left: 24, right: 24, bottom: 56 }) @@ -304,13 +339,41 @@ export struct RemoteSurfaceHost { this.actions.onRemoteHome.createAssistant(); } + /** + * Whether the surface still owes the user a way back onto a desktop. + * + * The connect button used to live only on the disconnected placeholder, so + * the moment a restored session list took that placeholder's place there was + * no entry point left anywhere on the surface — a list from the cache reads + * as "connected" while nothing is. Offering it from the status row instead + * keeps the two independent: the list says what the phone knows, this says + * whether the desktop is answering. + */ + private canReconnect(): boolean { + return !RemoteUiState.canUseRemote(this.remotePageState.connectionState) && !this.isSyncing(); + } + private canShowSessionList(): boolean { return this.remotePageState.connectionState === 'connected' || this.remotePageState.visibleSessions().length > 0 || this.remotePageState.isLoadingHome || this.remotePageState.isLoadingSessions; } + /** + * Only the first load gets a skeleton. Once there are sessions to show — from + * this connection or from the cache a launch restored — connecting is a + * refresh happening behind a usable list, and replacing that list with + * placeholder rows would be a step backwards. + */ private isInitialLoading(): boolean { + if (this.remotePageState.sessions.length > 0) { + return false; + } + return this.isSyncing(); + } + + /** Work in progress, whether or not there is already a list behind it. */ + private isSyncing(): boolean { return this.remotePageState.isLoadingHome || this.isConnecting(); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConnectOpenIntentPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConnectOpenIntentPolicy.ets new file mode 100644 index 0000000000..70973453de --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConnectOpenIntentPolicy.ets @@ -0,0 +1,45 @@ +import { CONNECT_INTENT_SCAN } from '../state/AppShellState'; + +/** + * Where the connect sheet opens, and what Back means once it is there. + */ +export class ConnectOpenIntentPolicy { + /** + * A signed-in phone normally opens on its account's device list, because that + * is the connection it already has and the one most taps mean. But the entry + * labelled 「扫描二维码连接」 means the camera and nothing else — landing it on a + * device picker reads as the wrong screen rather than as a shortcut, and the + * scanner it promised is then two taps further in. + */ + static initialStep( + openIntent: string, + accountAuthenticated: boolean, + remoteUrl: string, + currentStep: string + ): string { + if (openIntent === CONNECT_INTENT_SCAN) { + return 'scan'; + } + if (accountAuthenticated) { + return 'account'; + } + return remoteUrl.trim().length === 0 ? 'scan' : currentStep; + } + + /** + * Back retraces the way in. A sheet opened straight onto the scanner has no + * step behind it, so it leaves rather than reveals a picker the user never + * passed through. + */ + static backStaysInSheet( + openIntent: string, + currentStep: string, + remoteUrl: string, + showManualPairing: boolean + ): boolean { + return openIntent !== CONNECT_INTENT_SCAN && + currentStep === 'scan' && + remoteUrl.trim().length === 0 && + !showManualPairing; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets index 0ab0183dd2..9ef633a22b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets @@ -41,7 +41,8 @@ export class ConversationSessionFilterPolicy { ConversationSessionFilterPolicy.normalizeWorkspacePath(right); } - private static normalizeWorkspacePath(path: string): string { + /** Public so a caller can bucket by workspace instead of comparing pairwise. */ + static normalizeWorkspacePath(path: string): string { let value = path.trim(); while (value.length > 1 && (value.endsWith('/') || value.endsWith('\\'))) { value = value.slice(0, value.length - 1); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets new file mode 100644 index 0000000000..3085e8af4a --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets @@ -0,0 +1,373 @@ +import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { TimeFormat } from '../../services/TimeFormat'; +import { ConversationSessionFilterPolicy } from './ConversationSessionFilterPolicy'; + +/** Everything the sidebar's grouping depends on. */ +export interface SessionListInputs { + sessions: RemoteSession[]; + query: string; + sortMode: string; + workspaceName: string; + workspacePath: string; + workspaceKind: string; + recentWorkspaces: RecentWorkspaceEntry[]; + workspaceFilter: string; + agentFilter: string; + statusFilter: string; + showWorkspaceMetadata: boolean; + showUpdatedMetadata: boolean; + showStatusMetadata: boolean; +} + +/** + * The session list, already filtered, grouped and labelled. + * + * Every array here is shared, not copied, and must be treated as read-only: + * a cache hands the same instance to every caller until the inputs change. + */ +export interface SessionListProjection { + filtered: RemoteSession[]; + chats: RemoteSession[]; + byTime: RemoteSession[]; + today: RemoteSession[]; + yesterday: RemoteSession[]; + earlier: RemoteSession[]; + projects: RecentWorkspaceEntry[]; + projectSessions: Map; + metadata: Map; + hasActiveFilter: boolean; +} + +const EMPTY_SESSIONS: RemoteSession[] = []; + +/** + * Turns the raw session list into the shape the sidebar renders, in one pass. + * + * The sidebar used to derive this inline, and its builders asked for the + * derived values rather than holding them: `filteredSessions()` alone was + * reached around twenty times per build — once per section header, once per + * project group, three times per row — and re-scanned the whole array every + * time, each scan re-deciding whether a session was an assistant session by + * searching the recent-workspace list again. Traced on device, one selection + * change spent 690ms inside the list's rerender with no child layout beneath + * it: all of it was this arithmetic, run again for inputs that had not moved. + */ +export class SessionListProjector { + static project(inputs: SessionListInputs): SessionListProjection { + const assistantPaths: Set = SessionListProjector.assistantWorkspacePaths(inputs.recentWorkspaces); + const hasActiveFilter = inputs.query.trim().length > 0 || inputs.workspaceFilter.length > 0 || + inputs.agentFilter.length > 0 || inputs.statusFilter.length > 0; + const wantsMetadata = inputs.showWorkspaceMetadata || inputs.showUpdatedMetadata || inputs.showStatusMetadata; + + const filtered: RemoteSession[] = []; + const chats: RemoteSession[] = []; + const projectSessions: Map = new Map(); + const metadata: Map = new Map(); + const nowMs = Date.now(); + + inputs.sessions.forEach((item: RemoteSession) => { + // Decided once and carried through the rest of the pass. It is the + // expensive predicate — it searches the recent-workspace list — and it + // feeds the filter, the chat/project split and the agent grouping alike. + const assistant = SessionListProjector.isAssistantSession(item, inputs, assistantPaths); + const matches = ConversationSessionFilterPolicy.matches( + item, + inputs.query, + inputs.workspacePath, + inputs.workspaceFilter, + inputs.agentFilter, + inputs.statusFilter, + assistant + ); + if (!matches) { + return; + } + filtered.push(item); + if (assistant) { + chats.push(item); + } else { + const key = ConversationSessionFilterPolicy.normalizeWorkspacePath( + item.workspacePath ? item.workspacePath : inputs.workspacePath); + const bucket = projectSessions.get(key); + if (bucket === undefined) { + projectSessions.set(key, [item]); + } else { + bucket.push(item); + } + } + if (wantsMetadata) { + metadata.set(item.id, SessionListProjector.metadataText(item, inputs, nowMs)); + } + }); + + const projects = SessionListProjector.projectEntries(inputs, assistantPaths, hasActiveFilter, projectSessions); + // Sorting by time parses two timestamps per session per comparison, so it + // stays behind the mode that asks for it. + const byTime = inputs.sortMode === 'time' ? SessionListProjector.sortByTime(filtered) : EMPTY_SESSIONS; + + return { + filtered, + chats, + byTime, + today: SessionListProjector.bucket(byTime, 'today', nowMs), + yesterday: SessionListProjector.bucket(byTime, 'yesterday', nowMs), + earlier: SessionListProjector.bucket(byTime, 'earlier', nowMs), + projects, + projectSessions, + metadata, + hasActiveFilter + }; + } + + /** The sessions filed under a workspace, or an empty list when there are none. */ + static sessionsForProject(projection: SessionListProjection, path: string): RemoteSession[] { + const bucket = projection.projectSessions.get(ConversationSessionFilterPolicy.normalizeWorkspacePath(path)); + return bucket === undefined ? EMPTY_SESSIONS : bucket; + } + + static metadataFor(projection: SessionListProjection, sessionId: string): string { + const value = projection.metadata.get(sessionId); + return value === undefined ? '' : value; + } + + static empty(): SessionListProjection { + return { + filtered: EMPTY_SESSIONS, + chats: EMPTY_SESSIONS, + byTime: EMPTY_SESSIONS, + today: EMPTY_SESSIONS, + yesterday: EMPTY_SESSIONS, + earlier: EMPTY_SESSIONS, + projects: [], + projectSessions: new Map(), + metadata: new Map(), + hasActiveFilter: false + }; + } + + private static assistantWorkspacePaths(entries: RecentWorkspaceEntry[]): Set { + const paths: Set = new Set(); + entries.forEach((item: RecentWorkspaceEntry) => { + if (item.workspaceKind.toLowerCase() === 'assistant') { + paths.add(item.path); + } + }); + return paths; + } + + private static isAssistantWorkspace( + path: string, + inputs: SessionListInputs, + assistantPaths: Set + ): boolean { + if (path.length === 0) { + return inputs.workspaceKind.toLowerCase() === 'assistant'; + } + if (path === inputs.workspacePath && inputs.workspaceKind.toLowerCase() === 'assistant') { + return true; + } + return assistantPaths.has(path); + } + + private static isAssistantSession( + item: RemoteSession, + inputs: SessionListInputs, + assistantPaths: Set + ): boolean { + const agentType = (item.agentType ? item.agentType : '').toLowerCase(); + if (agentType === 'claw' || agentType === 'assistant' || agentType === 'chat') { + return true; + } + return SessionListProjector.isAssistantWorkspace( + item.workspacePath ? item.workspacePath : '', inputs, assistantPaths); + } + + private static projectEntries( + inputs: SessionListInputs, + assistantPaths: Set, + hasActiveFilter: boolean, + projectSessions: Map + ): RecentWorkspaceEntry[] { + const entries: RecentWorkspaceEntry[] = []; + const currentIsAssistant = SessionListProjector.isAssistantWorkspace(inputs.workspacePath, inputs, assistantPaths); + if ((inputs.workspacePath.length > 0 || inputs.workspaceName.length > 0) && !currentIsAssistant) { + entries.push({ + path: inputs.workspacePath, + name: inputs.workspaceName, + lastOpened: '', + workspaceKind: 'normal' + }); + } + inputs.recentWorkspaces.forEach((item: RecentWorkspaceEntry) => { + if (item.path.length === 0 || + SessionListProjector.isAssistantWorkspace(item.path, inputs, assistantPaths)) { + return; + } + if (entries.some((entry: RecentWorkspaceEntry) => entry.path === item.path)) { + return; + } + entries.push(item); + }); + if (!hasActiveFilter) { + return entries; + } + // A filter narrow enough to empty a project takes that project's header off + // the list too, so the sidebar does not read as a wall of empty groups. + return entries.filter((item: RecentWorkspaceEntry) => { + const bucket = projectSessions.get(ConversationSessionFilterPolicy.normalizeWorkspacePath(item.path)); + return bucket !== undefined && bucket.length > 0; + }); + } + + private static sortByTime(sessions: RemoteSession[]): RemoteSession[] { + // Timestamps are parsed once each and sorted alongside their session, rather + // than re-parsed inside the comparator for every comparison it makes. + const stamped: TimestampedSession[] = sessions.map((item: RemoteSession): TimestampedSession => { + return { session: item, timestamp: SessionListProjector.sessionTimestamp(item) }; + }); + stamped.sort((left: TimestampedSession, right: TimestampedSession) => right.timestamp - left.timestamp); + return stamped.map((item: TimestampedSession) => item.session); + } + + private static bucket(sessions: RemoteSession[], bucket: string, nowMs: number): RemoteSession[] { + if (sessions.length === 0) { + return EMPTY_SESSIONS; + } + return sessions.filter((item: RemoteSession) => SessionListProjector.timeBucket(item, nowMs) === bucket); + } + + private static timeBucket(item: RemoteSession, nowMs: number): string { + const timestamp = SessionListProjector.sessionTimestamp(item); + if (timestamp <= 0) { + return 'earlier'; + } + const sessionDate = new Date(timestamp); + const now = new Date(nowMs); + if (SessionListProjector.sameCalendarDay(sessionDate, now)) { + return 'today'; + } + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); + return SessionListProjector.sameCalendarDay(sessionDate, yesterday) ? 'yesterday' : 'earlier'; + } + + private static sessionTimestamp(item: RemoteSession): number { + const updated = TimeFormat.timestampMs(item.updatedAt ? item.updatedAt : ''); + if (!Number.isNaN(updated) && updated > 0) { + return updated; + } + const created = TimeFormat.timestampMs(item.createdAt ? item.createdAt : ''); + return !Number.isNaN(created) && created > 0 ? created : 0; + } + + private static sameCalendarDay(left: Date, right: Date): boolean { + return left.getFullYear() === right.getFullYear() && + left.getMonth() === right.getMonth() && + left.getDate() === right.getDate(); + } + + private static metadataText(item: RemoteSession, inputs: SessionListInputs, nowMs: number): string { + const values: string[] = []; + if (inputs.showWorkspaceMetadata) { + const workspace = item.workspaceName ? item.workspaceName : (item.workspacePath ? item.workspacePath : ''); + if (workspace.length > 0) { + values.push(workspace); + } + } + if (inputs.showUpdatedMetadata && !Number.isNaN(TimeFormat.timestampMs(item.updatedAt))) { + values.push(TimeFormat.relative(item.updatedAt, nowMs)); + } + if (inputs.showStatusMetadata && item.status.length > 0) { + values.push(item.status === 'archived' ? RemoteI18n.t('sidebar.archived') : item.status); + } + return values.join(' · '); + } +} + +interface TimestampedSession { + session: RemoteSession; + timestamp: number; +} + +/** + * Holds the last projection and rebuilds it only when its inputs move. + * + * The point of the cache is the selection change: picking a session rebuilds + * the sidebar's whole builder tree, but touches none of the inputs here, so the + * rerender becomes a property diff over lists that were already computed. + * + * The fast path is reference identity on the two arrays. When that misses — + * a caller handing out a freshly filtered array each build would defeat it — + * the contents are compared field by field, which is still one cheap pass + * instead of twenty expensive ones. + */ +export class SessionListProjectionCache { + private key: string = ''; + private sessionsRef: RemoteSession[] = EMPTY_SESSIONS; + private workspacesRef: RecentWorkspaceEntry[] = []; + private sessionFingerprint: string[] = []; + private workspaceFingerprint: string[] = []; + private projection: SessionListProjection = SessionListProjector.empty(); + + get(inputs: SessionListInputs): SessionListProjection { + const key = SessionListProjectionCache.scalarKey(inputs); + if (key === this.key && inputs.sessions === this.sessionsRef && + inputs.recentWorkspaces === this.workspacesRef) { + return this.projection; + } + const sessionFingerprint = SessionListProjectionCache.sessionFingerprint(inputs.sessions); + const workspaceFingerprint = SessionListProjectionCache.workspaceFingerprint(inputs.recentWorkspaces); + if (key === this.key && + SessionListProjectionCache.sameFingerprint(sessionFingerprint, this.sessionFingerprint) && + SessionListProjectionCache.sameFingerprint(workspaceFingerprint, this.workspaceFingerprint)) { + // Same contents behind a new array. Adopt the new references so the next + // call takes the identity fast path. + this.sessionsRef = inputs.sessions; + this.workspacesRef = inputs.recentWorkspaces; + return this.projection; + } + this.key = key; + this.sessionsRef = inputs.sessions; + this.workspacesRef = inputs.recentWorkspaces; + this.sessionFingerprint = sessionFingerprint; + this.workspaceFingerprint = workspaceFingerprint; + this.projection = SessionListProjector.project(inputs); + return this.projection; + } + + private static scalarKey(inputs: SessionListInputs): string { + // Relative timestamps are rendered from the projection, so when they are on + // screen the minute they were rendered in is part of what the projection + // depends on. Off — which is the default — nothing here moves on its own. + const minute = inputs.showUpdatedMetadata ? Math.floor(Date.now() / 60000) : 0; + return `${inputs.query}${inputs.sortMode}${inputs.workspaceName}${inputs.workspacePath}` + + `${inputs.workspaceKind}${inputs.workspaceFilter}${inputs.agentFilter}` + + `${inputs.statusFilter}${inputs.showWorkspaceMetadata ? 1 : 0}` + + `${inputs.showUpdatedMetadata ? 1 : 0}${inputs.showStatusMetadata ? 1 : 0}${minute}`; + } + + private static sessionFingerprint(sessions: RemoteSession[]): string[] { + return sessions.map((item: RemoteSession): string => { + return `${item.id}${item.title}${item.status}${item.agentType}` + + `${item.workspacePath}${item.workspaceName}${item.updatedAt}${item.createdAt}`; + }); + } + + private static workspaceFingerprint(entries: RecentWorkspaceEntry[]): string[] { + return entries.map((item: RecentWorkspaceEntry): string => { + return `${item.path}${item.name}${item.workspaceKind}`; + }); + } + + private static sameFingerprint(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) { + return false; + } + } + return true; + } +} 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 75f39fa259..500169c056 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 @@ -5,6 +5,7 @@ import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteUiState } from '../../services/RemoteUiState'; import { AppRootHostPort } from '../host/AppRootHostAdapter'; +import { CONNECT_INTENT_SCAN } from '../state/AppShellState'; import { AppNavigationBackAction, AppRoute, @@ -23,6 +24,9 @@ export class AppRootRuntime extends AppRootRuntimeComposition { async aboutToAppear(): Promise { this.syncRemotePageSummary(); + await this.remoteChatCache.init(this.host.context()); + await this.remoteSessionListCache.init(this.host.context()); + await this.restoreCachedRemoteSessions(); await this.generalChatBootstrapController.restore(this.host.context()); await this.settingsController.initializeCloudAccount(this.host.context()); await this.settingsController.refreshModelCatalog(); @@ -31,12 +35,16 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } /** - * Only ask for distributed data sync once the phone actually has a desktop - * to relay to. A fresh install has nothing to provision a watch with, and a - * permission prompt at first launch would have no explanation behind it. + * Only ask for distributed data sync once the phone actually has something to + * provision a watch with. A fresh install has nothing, and a permission + * prompt at first launch would have no explanation behind it. + * + * A signed-in account counts on its own, with no desktop in sight: the phone + * mints the watch's credential itself in that case, so waiting for a live + * connection would keep the listener down exactly when it is not needed. */ private async startWatchProvisioning(): Promise { - if (!this.hasRemoteBindingForResume()) { + if (!this.settingsController.hasCloudAccountSession() && !this.hasRemoteBindingForResume()) { return; } await this.watchProvisionController.start(this.host.context()); @@ -111,11 +119,36 @@ export class AppRootRuntime extends AppRootRuntimeComposition { async restoreIdentity(): Promise { if (this.remotePageState.controlTargetType === 'account_device') { + await this.restoreAccountDeviceTarget(false); return; } await this.remoteConnectionController.restore(this.host.context()); } + /** + * Reconnects to the desktop the signed-in account was last driving. + * + * The scanned-room path restores itself from its pairing snapshot, but the + * account path used to do nothing at launch: the phone came up holding a + * session list and a desktop name with no link behind either, and the only + * way back was for the user to walk the device picker again. Announcing + * `reconnecting` up front is what keeps that from reading as connected while + * the device lookup is still in flight. + */ + private async restoreAccountDeviceTarget(navigateHome: boolean): Promise { + const deviceId = this.remotePageState.controlTargetDeviceId.trim(); + if (deviceId.length === 0) { + return; + } + this.remotePageState.setConnectionState(ConnectionState.Reconnecting); + this.remotePageState.setStatusText(RemoteI18n.t('status.restoringConnection')); + await this.settingsController.restoreCloudTarget( + deviceId, + this.remotePageState.controlTargetDeviceName, + navigateHome + ); + } + async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { await this.remoteConnectionController.connect(autoReconnect, accountPassword); await this.settingsController.persistDelegatedAccountSession(); @@ -123,10 +156,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { async reconnect(): Promise { if (this.remotePageState.controlTargetType === 'account_device') { - await this.settingsController.restoreCloudTarget( - this.remotePageState.controlTargetDeviceId, - this.remotePageState.controlTargetDeviceName - ); + await this.restoreAccountDeviceTarget(true); return; } await this.remoteConnectionController.reconnect(); @@ -146,8 +176,20 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } } + /** + * A request that failed against a link the phone still has a binding for is a + * link that dropped, not one that ended, so the heartbeat stays up and its + * next ping is what notices the desktop coming back. Killing it here is what + * used to make a momentary drop permanent: `failed` disables every remote + * action, and nothing was left running to lift it again. + */ failRemoteConnection(err: Object): void { this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(err)); + if (this.hasRemoteBindingForResume()) { + this.remotePageState.setConnectionState(ConnectionState.Reconnecting); + this.remoteActivityViewModel.startHeartbeat(); + return; + } this.remotePageState.setConnectionState(ConnectionState.Failed); this.remoteActivityViewModel.stopHeartbeat(); } @@ -278,10 +320,16 @@ export class AppRootRuntime extends AppRootRuntimeComposition { }, 180); } + /** + * From the settings row that says 「扫描二维码连接」, and so straight to the + * camera: this is the one entry that names what it opens, and it is also the + * only way a signed-in phone can reach the scanner at all — every other path + * stops at the account's device list. + */ openAddConnectionFromSettings(): void { this.appShellState.setSettingsVisible(false); setTimeout(() => { - this.appShellState.setConnectSheetVisible(true); + this.appShellState.setConnectSheetVisible(true, CONNECT_INTENT_SCAN); }, 220); } @@ -289,7 +337,42 @@ export class AppRootRuntime extends AppRootRuntimeComposition { this.remoteWorkspaceSessions = all; const current = this.remotePageState.sessions; const extras = all.filter((item: RemoteSession) => item.workspacePath !== this.remotePageState.workspacePath); - this.remotePageState.setSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); + this.publishRemoteSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); + } + + /** + * The single way a session list reaches the screen, so everything the user + * sees is also what the next launch starts from. + * + * The write is deliberately not awaited: the list is on screen either way, + * and making a repaint wait on disk is the one way this cache could make the + * app feel slower than it did without it. + */ + publishRemoteSessions(sessions: RemoteSession[], hasMore: boolean): void { + this.remotePageState.setSessions(sessions, hasMore); + this.remoteSessionListCache.save(sessions, hasMore); + } + + /** + * Fills Remote Home from disk before anything is connected. + * + * Runs against whichever desktop was used last, because the device to key on + * is not known this early — identity is still being restored, and the + * auto-reconnect that follows targets exactly that desktop. Anything the + * desktop reports replaces this, so a stale entry costs a repaint. + */ + private async restoreCachedRemoteSessions(): Promise { + if (this.remotePageState.sessions.length > 0) { + return; + } + const cached = await this.remoteSessionListCache.restoreLast(); + // Checked again: a connection may have landed while the read was in flight, + // and the desktop's answer outranks the stored one. + if (cached.sessions.length === 0 || this.remotePageState.sessions.length > 0) { + return; + } + RemoteLogger.info(`session list from cache count=${cached.sessions.length}`); + this.remotePageState.setSessions(cached.sessions, cached.hasMore); } mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[] { 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 99989467ed..8272811c5e 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 @@ -30,7 +30,9 @@ import { MobileIdentityStore } from '../../services/MobileIdentityStore'; import { CloudAccountClient, CloudAccountDevice } from '../../services/CloudAccountClient'; import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; +import { RemoteChatCache } from '../../services/RemoteChatCache'; import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { RemoteChatLocalRdbStore } from '../../services/RemoteChatLocalRdbStore'; import { RemoteChatPollingLifecycleController, RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; import { FilePreviewController } from '../viewmodel/FilePreviewController'; @@ -40,6 +42,8 @@ import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteModelController } from '../../services/RemoteModelController'; import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; import { RemoteSessionController } from '../../services/RemoteSessionController'; +import { RemoteSessionListCache } from '../../services/RemoteSessionListCache'; +import { RemoteSessionListRdbStore } from '../../services/RemoteSessionListRdbStore'; import { RemoteSessionManager } from '../../services/RemoteSessionManager'; import { RemoteWorkspaceRepository } from '../../services/RemoteWorkspaceRepository'; import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; @@ -70,8 +74,11 @@ import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; import { WatchProvisionState } from '../state/WatchProvisionState'; -import { WatchProvisionController } from '../../services/WatchProvisionController'; -import { PeerDeviceProvisionOutcome } from '../../services/RelayHttpClient'; +import { + WatchProvisionController, + WatchProvisionOutcome, + WatchProvisionPort +} from '../../services/WatchProvisionController'; import { ConversationViewModel } from '../viewmodel/ConversationViewModel'; import { FilePreviewState } from '../state/FilePreviewState'; import { FilePreviewRequest } from '../../model/FilePreviewTarget'; @@ -117,6 +124,7 @@ export abstract class AppRootRuntimeComposition { abstract openAppSidebar(): void; abstract openRemoteControlSettings(): void; abstract pickImages(): Promise; + abstract publishRemoteSessions(sessions: RemoteSession[], hasMore: boolean): void; abstract reconnect(): Promise; abstract reconnectActiveRemote(): Promise; abstract selectAssistant(path: string): Promise; @@ -151,17 +159,71 @@ export abstract class AppRootRuntimeComposition { readonly remotePageState: RemotePageState = new RemotePageState(); 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 + // better one: it works with the desktop asleep. The room channel stays as + // the fallback for a phone that only ever scanned a QR code and so has no + // account of its own to mint from. + canProvision: (): boolean => this.settingsController.canMintWatchCredential() || + this.canProvisionViaDesktop(), + provision: (deviceId: string, deviceName: string, requestId: string): Promise => + this.provisionWatchDevice(deviceId, deviceName, requestId) + }; readonly watchProvisionController: WatchProvisionController = - new WatchProvisionController(this.watchProvisionState, { - // Provisioning only rides the QR-paired room channel: that is the one - // path where the desktop holds the pairing identity that authorizes it. - canProvision: (): boolean => - (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected && - this.sessionManager.hasRoomChannel(), - provision: (deviceId: string, deviceName: string, requestId: string): Promise => - this.sessionManager.provisionPeerDevice(deviceId, deviceName, requestId), - relayUrl: (): string => this.sessionManager.roomRelayEndpoint() - }); + new WatchProvisionController(this.watchProvisionState, this.watchProvisionPort); + + private canProvisionViaDesktop(): boolean { + return (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected && + this.sessionManager.hasRoomChannel(); + } + + /** + * Mints the watch's credential, preferring this phone's own account. + * + * The relay gates minting on holding a device token, which a password login + * on this phone produces — the desktop was never uniquely entitled, it was + * just the only party the old code asked. Going direct means a watch can be + * onboarded with the desktop closed, and skips a 45-second round trip when + * it is open. + * + * `undefined` back from the account path means "not entitled" (a token + * delegated by a room pairing rather than a login), which is the one case + * worth falling back for. A network failure throws instead, so a blip is not + * reported as a missing desktop. + */ + private async provisionWatchDevice( + deviceId: string, + deviceName: string, + requestId: string + ): Promise { + const minted = await this.settingsController.provisionWatchCredential(deviceId, deviceName, requestId); + if (minted) { + return minted; + } + if (!this.canProvisionViaDesktop()) { + return AppRootRuntimeComposition.provisionUnavailable(); + } + const outcome = await this.sessionManager.provisionPeerDevice(deviceId, deviceName, requestId); + return { + ok: outcome.ok, + // The desktop mints against the relay its room lives on, which is not + // necessarily the one this phone's account is on. + relayUrl: outcome.ok ? this.sessionManager.roomRelayEndpoint() : '', + token: outcome.token, + userId: outcome.userId, + masterKeyBase64: outcome.masterKeyBase64, + deviceId: outcome.deviceId, + failure: outcome.failure, + desktopReported: outcome.desktopReported + }; + } + + private static provisionUnavailable(): WatchProvisionOutcome { + return { + ok: false, relayUrl: '', token: '', userId: '', masterKeyBase64: '', deviceId: '', + failure: '', desktopReported: false + }; + } readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); readonly generalChatController: GeneralChatController = GeneralChatController.createDefault(this.generalChatConfigStore); @@ -180,6 +242,25 @@ export abstract class AppRootRuntimeComposition { (): string => this.conversationController.visibleGeneralChatDraftId() ); readonly chatTimelineStore: ConversationViewModel = new ConversationViewModel(); + // Scoped to the desktop currently being controlled: session ids are issued by + // the desktop, so two of them on one account can name different conversations + // with the same id. + readonly remoteChatCache: RemoteChatCache = + new RemoteChatCache( + new RemoteChatLocalRdbStore(), + (): string => this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId + ); + readonly remoteSessionListCache: RemoteSessionListCache = + new RemoteSessionListCache( + new RemoteSessionListRdbStore(), + (): string => this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId + ); + // Both caches hold what one account saw on one desktop, so they are dropped + // together whenever that binding ends. + readonly clearCachedRemoteData: () => Promise = async (): Promise => { + await this.remoteChatCache.clear(); + await this.remoteSessionListCache.clear(); + }; readonly generalChatCommandController: GeneralChatCommandController = new GeneralChatCommandController( this.generalChatController, @@ -234,8 +315,11 @@ export abstract class AppRootRuntimeComposition { ); readonly voiceInputService: VoiceInputService = new VoiceInputService(); readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = - new RemoteActivityLifecycleController(() => { - this.remoteActivityViewModel.checkConnectionHealth(); + new RemoteActivityLifecycleController((): Promise => { + // Returned, not fired and forgotten: the heartbeat skips its next tick + // while this one is still out, and it can only do that if it is handed + // something to wait on. + return this.remoteActivityViewModel.checkConnectionHealth(); }); readonly remoteActivityViewModel: RemoteActivityViewModel = new RemoteActivityViewModel( @@ -244,6 +328,7 @@ export abstract class AppRootRuntimeComposition { this.remoteResumeGate, { isConnected: (): boolean => (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected, + isReconnecting: (): boolean => (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Reconnecting, isBusy: (): boolean => this.remotePageState.isBusy, hasRemoteBinding: (): boolean => this.hasRemoteBindingForResume(), isRemoteChat: (): boolean => this.appShellViewModel.isRoute(AppRoute.RemoteChat), @@ -251,7 +336,6 @@ export abstract class AppRootRuntimeComposition { onConnectionState: (state: string): void => this.remotePageState.setConnectionState(state as ConnectionState), onStatus: (status: string): void => this.remotePageState.setStatusText(status), onConnectionError: async (err: Object): Promise => this.settingsController.handleRemoteConnectionError(err), - onStopHeartbeat: (): void => this.remoteActivityViewModel.stopHeartbeat(), onStartPolling: (): void => this.conversationController.startRemotePolling(), onStopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), onPoll: async (): Promise => { @@ -328,7 +412,7 @@ export abstract class AppRootRuntimeComposition { const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession) => { return item.workspacePath !== this.remotePageState.workspacePath; }); - this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); + this.publishRemoteSessions(this.mergeSessions(sessions, extras), hasMore); }, onActiveSession: (session: SessionSummary) => { this.conversationController.applyRemoteActiveSession(session); @@ -371,6 +455,9 @@ export abstract class AppRootRuntimeComposition { onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { this.conversationController.updateKnownMessageCount(pollVersion, knownMessageCount); }, + onTimelineReady: () => { + this.remotePageState.setConversationLoading(false); + }, onSendSucceeded: (turnId: string, pendingActiveId: string) => { if (turnId.length > 0) { this.chatTimelineStore.setLocalActiveTurn(turnId); @@ -404,13 +491,17 @@ export abstract class AppRootRuntimeComposition { onStatusText: (statusText: string) => { this.remotePageState.setStatusText(statusText); }, + onToast: (message: string) => { + this.conversationController.showHomeToast(message); + }, onBusy: (isBusy: boolean) => { this.remotePageState.setBusy(isBusy); }, onPollRequested: () => { - this.remoteChatPollingLifecycleController.pollNow(); + this.remoteChatPollingLifecycleController.nudge(); } - } + }, + this.remoteChatCache ); readonly remoteFileDownloadController: RemoteFileDownloadController = new RemoteFileDownloadController( @@ -452,7 +543,7 @@ export abstract class AppRootRuntimeComposition { this.remotePageState.setBusy(isBusy); }, () => { - this.remoteChatPollingLifecycleController.pollNow(); + this.remoteChatPollingLifecycleController.nudge(); } ); readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = @@ -511,9 +602,6 @@ export abstract class AppRootRuntimeComposition { onResetTimeline: (sessionId: string): void => this.conversationController.resetRemoteTimeline(sessionId), onClearRemoteFiles: (): void => this.remoteFileDownloadController.clear(), onKnownStateReset: (): void => this.conversationController.resetKnownRemoteState(), - onLoadModelCatalog: async (sessionId: string): Promise => { - await this.conversationController.loadRemoteModelCatalog(sessionId); - }, onLoadActiveMessages: async (): Promise => { await this.conversationController.loadRemoteMessages(); }, @@ -565,6 +653,7 @@ export abstract class AppRootRuntimeComposition { async (): Promise => { await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); }, + this.clearCachedRemoteData, (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), (): void => this.appShellState.setConnectSheetVisible(false), (): void => this.appShellState.setConnectSheetVisible(true) @@ -594,6 +683,7 @@ export abstract class AppRootRuntimeComposition { startHeartbeat: (): void => this.remoteActivityViewModel.startHeartbeat(), resetTimeline: (): void => this.conversationController.resetRemoteTimeline(''), resetKnownRemoteState: (): void => this.conversationController.resetKnownRemoteState(), + clearCachedRemoteData: this.clearCachedRemoteData, closeSettings: (): void => this.appShellState.setSettingsVisible(false), closeConnectSheet: (): void => this.appShellState.setConnectSheetVisible(false), navigateRemoteHome: (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), @@ -612,6 +702,7 @@ export abstract class AppRootRuntimeComposition { { timeline: this.chatTimelineStore, chat: this.remoteChatCommandController, + chatCache: this.remoteChatCache, polling: this.remoteChatPollingLifecycleController, models: this.remoteModelController, files: this.remoteFileDownloadController, @@ -773,7 +864,6 @@ export abstract class AppRootRuntimeComposition { openAccount: (): void => { this.appShellState.openSettings('account'); }, cloudLogin: (relayUrl: string, username: string, password: string): Promise => this.settingsController.loginCloudAccount(relayUrl, username, password), - cloudSync: (): Promise => this.settingsController.syncCloudAccount(), cloudLogout: (): Promise => this.settingsController.logoutCloudAccount(), cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), getPermissionMode: (): Promise => this.settingsController.getRemotePermissionMode(), @@ -815,4 +905,3 @@ export abstract class AppRootRuntimeComposition { } - diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets index e34e22c5d1..6301298b05 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets @@ -1,5 +1,10 @@ import { AppRoute } from '../navigation/AppRouteContract'; +/** The connect sheet decides for itself where to open. */ +export const CONNECT_INTENT_AUTO: string = 'auto'; +/** The user tapped something that promised a camera, so open the camera. */ +export const CONNECT_INTENT_SCAN: string = 'scan'; + @ObservedV2 export class AppShellState { /** @@ -12,6 +17,13 @@ export class AppShellState { @Trace showSettings: boolean = false; @Trace settingsMode: string = 'general'; @Trace showConnectSheet: boolean = false; + /** + * Why the connect sheet was opened, which is not something the sheet can work + * out for itself: a signed-in phone has an account device list to offer and a + * camera to offer, and only the entry point knows which one the user just + * asked for by name. + */ + @Trace connectSheetIntent: string = CONNECT_INTENT_AUTO; /** * Mirror of the resolved master-detail layout mode. Only the presentation * layer measures the viewport, so runtime logic that must branch on compact @@ -55,7 +67,15 @@ export class AppShellState { this.setSettingsVisible(false); } - setConnectSheetVisible(visible: boolean): void { + /** + * The intent is set on every open rather than cleared on close, because the + * sheet can also be dismissed by a drag that never reaches this class — and a + * default that only holds while nobody swipes is not a default. + */ + setConnectSheetVisible(visible: boolean, intent: string = CONNECT_INTENT_AUTO): void { + if (visible) { + this.connectSheetIntent = intent; + } this.showConnectSheet = visible; } 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 a555f25aa0..fc137e964d 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 @@ -71,6 +71,9 @@ export class ConversationViewState { state.workspaceBranch = remote.workspaceBranch; state.connectionState = remote.connectionState; state.isLoadingConversation = remote.isLoadingConversation; + if (state.isLoadingConversation) { + state.activeSession = ConversationViewState.emptySession(); + } state.composerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; state.showSuggestionsWhenEmpty = false; state.downloadingFilePath = remote.downloadingFilePath; 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 3284ff69fb..e72fe68688 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 @@ -53,6 +53,11 @@ export class RemotePageState { @Trace pendingSessionId: string = ''; @Trace isConversationDismissed: boolean = false; @Trace sessionErrorText: string = ''; + // Deliberately untraced: the memo behind visibleSessions(), not state anything + // renders. Tracing it would make reading the list a write to the list. + private visibleSessionsCache: RemoteSession[] = []; + private visibleSessionsSource: RemoteSession[] | undefined = undefined; + private visibleSessionsQuery: string = ' '; get activeSession(): SessionSummary { return this.conversation.activeSession; } get sessions(): RemoteSession[] { return this.conversation.sessions; } get persistedMessages(): ChatMessage[] { return this.conversation.persistedMessages; } @@ -310,14 +315,30 @@ export class RemotePageState { this.closePickers(); } + /** + * The sessions the sidebar may show, as a stable array. + * + * This is read straight into a `@Param`, so returning a fresh array each call + * marked the session list dirty on every rebuild of its parent — including + * the rebuild that a selection change causes, which is the one case where + * nothing about the list has moved. Handing back the same instance until the + * source or the query changes lets the list keep what it already computed. + */ visibleSessions(): RemoteSession[] { + const source = this.conversation.sessions; const query = this.sessionQuery.trim().toLowerCase(); - return this.conversation.sessions.filter((item: RemoteSession) => { + if (source === this.visibleSessionsSource && query === this.visibleSessionsQuery) { + return this.visibleSessionsCache; + } + this.visibleSessionsSource = source; + this.visibleSessionsQuery = query; + this.visibleSessionsCache = source.filter((item: RemoteSession) => { if (item.id.length === 0 || item.status === 'archived') { return false; } return query.length === 0 || item.title.toLowerCase().indexOf(query) >= 0; }); + return this.visibleSessionsCache; } hasRunningActiveTurn(): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets index 2b723f14d5..889c48082d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets @@ -48,8 +48,20 @@ export class AppShellViewModel { this.syncActiveRoute(); } + /** + * Makes [route] the whole stack, or does nothing if it already is. + * + * The guard deliberately ignores [sessionId]. A route names a screen, not the + * conversation on it — which session is open is read from page state, and no + * component ever reads the path param back. Treating a session switch as a + * navigation meant clearing the stack and pushing a fresh `NavDestination`, + * and on the wide layout the master pane lives inside that destination: every + * tap on a row tore down and rebuilt the whole sidebar. Measured on tablet, + * that rebuild was ~2.1s of the ~3.5s the main thread spent blocked before + * the transcript appeared. + */ replaceRouteWithoutAnimation(route: AppRoute, sessionId: string = ''): void { - if (this.currentRoute() === route && sessionId.length === 0) { + if (this.currentRoute() === route) { return; } this.navigationStack.clear(false); 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 b43aa96612..74b5ef25b6 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 @@ -8,7 +8,7 @@ import { } from '../../model/RemoteModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; -import { ChatTimelineState } from '../../services/ChatTimelineStore'; +import { ChatTimelineState, ChatTimelineStore } from '../../services/ChatTimelineStore'; import { ClipboardService } from '../../services/ClipboardService'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { ImagePickerService } from '../../services/ImagePickerService'; @@ -19,6 +19,7 @@ import { GeneralChatServiceState, GeneralChatServiceStatus } from '../../services/general-chat/GeneralChatServiceState'; +import { RemoteChatCache } from '../../services/RemoteChatCache'; import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; import { RemoteChatPollingCursor, @@ -62,6 +63,7 @@ export interface RemoteConversationHooks { export interface RemoteConversationDependencies { readonly timeline: ConversationViewModel; readonly chat: RemoteChatCommandController; + readonly chatCache: RemoteChatCache; readonly polling: RemoteChatPollingLifecycleController; readonly models: RemoteModelController; readonly files: RemoteFileDownloadController; @@ -92,6 +94,7 @@ export class ConversationController { private knownModelCatalogVersion: number = 0; private knownRemoteMessageCount: number = 0; private isSyncingAfterTurn: boolean = false; + private isRebuildingRemoteTranscript: boolean = false; private remoteCreateWorkspaceLoadVersion: number = 0; constructor( @@ -217,15 +220,6 @@ export class ConversationController { ); } - async loadRemoteModelCatalog(sessionId: string): Promise { - const runtime = this.requireRemoteRuntime(); - await runtime.models.loadCatalog( - sessionId, - runtime.connection.ensureAvailable(), - runtime.hooks.isConversationContext - ); - } - async selectRemoteModel(modelId: string): Promise { const runtime = this.requireRemoteRuntime(); await runtime.models.selectModel( @@ -260,12 +254,19 @@ export class ConversationController { !runtime.connection.ensureAvailable()) { return; } + // Sending into a turn that is still running is allowed, but the desktop + // queues it rather than interrupting, so say so once — otherwise the + // message just sits in the transcript with nothing appearing to happen. + const queuedBehindRunningTurn = this.hasRunningRemoteTurn(); this.remote.clearComposer(); const localMessage = RemoteUiState.localUserMessage(text, images); runtime.timeline.appendOptimisticMessage(localMessage); const pendingActiveId = runtime.timeline.setPendingActiveTurn(localMessage.id); this.syncRemoteTimeline(); - RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId}`); + RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId} behindRunningTurn=${queuedBehindRunningTurn ? '1' : '0'}`); + if (queuedBehindRunningTurn) { + this.showHomeToast(RemoteI18n.t('chat.queuedAfterRunningTurn')); + } this.startRemotePolling(); runtime.polling.nudge(); const imageContexts: RemoteImageContext[] = images.length > 0 ? @@ -404,8 +405,15 @@ export class ConversationController { if (!runtime.hooks.isConversationContext(snapshot.sessionId)) { return; } + if (snapshot.historyRewritten) { + this.rebuildRemoteTranscript(snapshot.sessionId); + return; + } runtime.timeline.applySnapshot(snapshot); this.syncRemoteTimeline(); + if (snapshot.newMessages.length > 0) { + this.cacheRemoteTranscript(snapshot.sessionId); + } this.knownPollVersionValue = snapshot.cursor.pollVersion; this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; @@ -428,17 +436,51 @@ export class ConversationController { } } + /** + * Writes the transcript now on screen back to disk. + * + * Fire-and-forget on purpose: the snapshot is already rendered, and a cache + * that cannot be written only costs the next open a fetch it used to pay for + * anyway. + */ + private cacheRemoteTranscript(sessionId: string): void { + const runtime = this.requireRemoteRuntime(); + const state: ChatTimelineState = runtime.timeline.snapshotState(); + runtime.chatCache.sync(sessionId, state.persistedMessages); + } + + /** + * Refetches a transcript the desktop no longer agrees with. + * + * Tails are handed out by index, so once the desktop reports fewer messages + * than this session had counted there is no offset left that means the same + * thing on both ends. Everything stored for the session goes, including the + * poll cursor, which `reloadMessages` resets by way of `onMessageCountKnown`. + */ + private rebuildRemoteTranscript(sessionId: string): void { + const runtime = this.requireRemoteRuntime(); + if (this.isRebuildingRemoteTranscript) { + return; + } + this.isRebuildingRemoteTranscript = true; + RemoteLogger.info(`remote transcript rewritten upstream session=${this.shortSessionId(sessionId)}`); + runtime.chatCache.forget(sessionId) + .then((): Promise => runtime.chat.reloadMessages(sessionId, runtime.hooks.isConversationContext)) + .then((): void => { + this.isRebuildingRemoteTranscript = false; + }) + .catch((): void => { + this.isRebuildingRemoteTranscript = false; + }); + } + hasRunningRemoteTurn(): boolean { return this.remote.activeTurnMessage.id.length > 0 && (this.remote.activeTurnMessage.status || '').toLowerCase() === 'active'; } remoteActiveTurnId(): string { - const active = this.remote.activeTurnMessage; - if (active.turnId && active.turnId.length > 0) { - return active.turnId; - } - return active.id.indexOf('active-') === 0 ? active.id.slice('active-'.length) : ''; + return ChatTimelineStore.cancelableTurnId(this.remote.activeTurnMessage); } projectedRemoteTimelineItems(): ChatTimelineItem[] { @@ -999,7 +1041,14 @@ export class ConversationController { } this.isSyncingAfterTurn = true; try { - await this.loadRemoteMessages(); + // Deliberately not the cached path: this exists to pick up whatever the + // desktop settled on after the turn finished, which is exactly what the + // cache does not know yet. + const runtime = this.requireRemoteRuntime(); + await runtime.chat.reloadMessages( + this.remote.activeSession.sessionId || '', + runtime.hooks.isConversationContext + ); } finally { this.isSyncingAfterTurn = false; } @@ -1014,6 +1063,12 @@ export class ConversationController { const runtime = this.requireRemoteRuntime(); runtime.filePreview.close(); this.remote.setConversationDismissed(false); + if (runtime.appShell.isRoute(AppRoute.RemoteChat)) { + // Already on the screen this session belongs on. Swapping the path entry + // would rebuild the destination — and with it the sidebar — for a change + // that only page state describes. See replaceRouteWithoutAnimation. + return; + } if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { runtime.appShell.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); return; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets index 8228df45dd..632e3d4681 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets @@ -4,9 +4,11 @@ import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; +import { RemoteLogger } from '../../services/RemoteLogger'; export interface RemoteActivityViewModelHooks { readonly isConnected: () => boolean; + readonly isReconnecting: () => boolean; readonly isBusy: () => boolean; readonly hasRemoteBinding: () => boolean; readonly isRemoteChat: () => boolean; @@ -14,7 +16,6 @@ export interface RemoteActivityViewModelHooks { readonly onConnectionState: (state: string) => void; readonly onStatus: (status: string) => void; readonly onConnectionError: (err: Object) => Promise; - readonly onStopHeartbeat: () => void; readonly onStartPolling: () => void; readonly onStopPolling: () => void; readonly onPoll: () => Promise; @@ -28,6 +29,7 @@ export class RemoteActivityViewModel { private readonly connection: RemoteConnectionCoordinator; private readonly gate: AsyncLifecycleGate; private readonly hooks: RemoteActivityViewModelHooks; + private recovering: boolean = false; constructor( activity: RemoteActivityLifecycleController, @@ -49,21 +51,52 @@ export class RemoteActivityViewModel { this.activity.stopHeartbeat(); } + /** + * A health check while the link is up, a recovery probe once it is not. + * + * A failed ping used to park the connection in `failed` and stop the + * heartbeat with it. Nothing survived that: the poller was stopped too, and + * `failed` disables every remote action, so a desktop that blinked out for a + * moment left the phone dead until the user backgrounded and refocused the + * app. The heartbeat is the only thing still ticking at that point, which + * makes it the only thing that can notice the desktop coming back — so it + * keeps ticking, and the first ping that answers puts the session back on the + * wire. Ticks are serialised by the heartbeat itself, and a ping is cheap + * enough to repeat every interval for as long as the drop lasts. + */ async checkConnectionHealth(): Promise { - if (!this.hooks.isConnected() || this.hooks.isBusy()) { + if (this.hooks.isBusy() || this.recovering || !this.hooks.hasRemoteBinding()) { + return; + } + const wasConnected = this.hooks.isConnected(); + if (!wasConnected && !this.hooks.isReconnecting()) { return; } try { await this.connection.ping(); + if (!wasConnected) { + await this.restoreAfterHealthyPing(); + } } catch (err) { if (await this.hooks.onConnectionError(err)) { return; } - const message = ConnectionErrorPolicy.errorText(err); - this.hooks.onConnectionState('failed'); - this.hooks.onStatus(message); - this.hooks.onStopPolling(); - this.hooks.onStopHeartbeat(); + this.hooks.onStatus(ConnectionErrorPolicy.errorText(err)); + if (wasConnected) { + RemoteLogger.info('heartbeat ping failed, waiting for the link to come back'); + this.hooks.onStopPolling(); + this.hooks.onConnectionState('reconnecting'); + } + } + } + + private async restoreAfterHealthyPing(): Promise { + RemoteLogger.info('heartbeat ping answered, link restored'); + this.hooks.onConnectionState('connected'); + this.hooks.onStatus(RemoteI18n.t('connection.connected')); + if (this.hooks.isRemoteChat() && this.hooks.activeSession().sessionId.length > 0) { + this.hooks.onStartPolling(); + await this.hooks.onPoll(); } } @@ -103,9 +136,18 @@ export class RemoteActivityViewModel { return; } this.hooks.onStatus(ConnectionErrorPolicy.errorText(err)); - this.hooks.onStopHeartbeat(); this.hooks.onConnectionState('reconnecting'); - await this.reconnect(token); + // The heartbeat keeps running through this. If the reconnect below fails + // there is nothing else left to try again, and its next tick is the retry + // — it just has to stay out of the way while this one is in flight. + this.recovering = true; + try { + await this.reconnect(token); + } catch (reconnectErr) { + this.hooks.onStatus(ConnectionErrorPolicy.errorText(reconnectErr)); + } finally { + this.recovering = false; + } } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets index 40e66955c8..d1e4ebd7c8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets @@ -46,6 +46,7 @@ export class RemoteConnectionController { private readonly stopHeartbeat: () => void; private readonly stopPolling: () => void; private readonly loadRecentWorkspaces: () => Promise; + private readonly clearCachedRemoteData: () => Promise; private readonly replaceRoute: (route: AppRoute) => void; private readonly closeConnectSheet: () => void; private readonly openConnectSheet: () => void; @@ -70,6 +71,7 @@ export class RemoteConnectionController { stopHeartbeat: () => void, stopPolling: () => void, loadRecentWorkspaces: () => Promise, + clearCachedRemoteData: () => Promise, replaceRoute: (route: AppRoute) => void, closeConnectSheet: () => void, openConnectSheet: () => void @@ -89,6 +91,7 @@ export class RemoteConnectionController { this.stopHeartbeat = stopHeartbeat; this.stopPolling = stopPolling; this.loadRecentWorkspaces = loadRecentWorkspaces; + this.clearCachedRemoteData = clearCachedRemoteData; this.replaceRoute = replaceRoute; this.closeConnectSheet = closeConnectSheet; this.openConnectSheet = openConnectSheet; @@ -167,7 +170,10 @@ export class RemoteConnectionController { this.pageState.setStatusText(RemoteI18n.t('connection.connected')); this.closeConnectSheet(); this.startHeartbeat(); - await this.loadRecentWorkspaces(); + // Left running rather than awaited: this scan costs one round trip per + // recent workspace, and everything it finds is an addition to a session + // list that is already on screen. Its own failures are logged inside. + void this.loadRecentWorkspaces(); this.pageState.setLoadingHome(false); RemoteLogger.info('connect success'); } catch (err) { @@ -238,6 +244,10 @@ export class RemoteConnectionController { this.pageState.setAccountPairing(false, ''); this.pageState.setRemoteUrlInputVisible(false); await this.identity.clearPairingInput(); + // Unpairing is the user saying they are done with that desktop. Leaving + // its sessions on disk would put them back on Remote Home at the next + // launch, with nothing behind them to open. + await this.clearCachedRemoteData(); } else { this.pageState.setRemoteUrlInputVisible(this.pageState.remoteUrl.trim().length > 0); } 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 42ab00f630..947ee6a13a 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 @@ -4,6 +4,9 @@ import { RemoteChatCommandController } from '../../services/RemoteChatCommandCon import { RemoteModelController } from '../../services/RemoteModelController'; import { RemoteSessionController } from '../../services/RemoteSessionController'; import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { DeferredLoadingGate } from '../../services/DeferredLoadingGate'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { MainThreadStallProbe } from '../../services/MainThreadStallProbe'; import { RemotePageState } from '../state/RemotePageState'; export interface RemoteSessionViewModelHooks { @@ -18,7 +21,6 @@ export interface RemoteSessionViewModelHooks { readonly onResetTimeline: (sessionId: string) => void; readonly onClearRemoteFiles: () => void; readonly onKnownStateReset: () => void; - readonly onLoadModelCatalog: (sessionId: string) => Promise; readonly onLoadActiveMessages: () => Promise; readonly onRefreshSessions: () => Promise; readonly onSelectWorkspace: (path: string) => Promise; @@ -32,6 +34,14 @@ export class RemoteSessionViewModel { private readonly models: RemoteModelController; private readonly files: RemoteFileDownloadController; private readonly hooks: RemoteSessionViewModelHooks; + // Long enough that a cached transcript never flashes a skeleton on its way in, + // short enough that a transcript coming over the relay still says so. + private readonly openLoadingGate: DeferredLoadingGate = + new DeferredLoadingGate(140, (visible: boolean): void => this.pageState.setConversationLoading(visible)); + // How many opens this view model has started and not yet settled. Opening a + // session raises the busy flag, so without this the flag an open sets would + // read, to the next tap, as a run in progress. + private openInFlight: number = 0; constructor( pageState: RemotePageState, @@ -49,6 +59,13 @@ export class RemoteSessionViewModel { this.hooks = hooks; } + private static shortId(value: string): string { + if (value.length <= 10) { + return value; + } + return `${value.slice(0, 6)}...${value.slice(-4)}`; + } + async refreshSessions(): Promise { await this.sessions.refresh( this.pageState.sessionQuery, @@ -81,7 +98,29 @@ export class RemoteSessionViewModel { onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat, modelId: string = '' ): Promise { - await this.sessions.create( + if (this.hooks.isBusy() || !this.hooks.remoteAvailable()) { + return; + } + const previousSession: SessionSummary = this.copySession(this.pageState.activeSession); + this.beginSessionCreation(onRouteChat); + try { + const created = await this.performCreateSession(agentType, instruction, onRouteChat, modelId); + if (!created) { + this.restoreAfterFailedCreation(previousSession, onRouteChat); + } + } finally { + this.pageState.setConversationLoading(false); + this.pageState.setPendingSessionId(''); + } + } + + private async performCreateSession( + agentType: string, + instruction: string, + onRouteChat: (sessionId: string) => void, + modelId: string + ): Promise { + return await this.sessions.create( agentType, this.hooks.isBusy(), this.hooks.remoteAvailable(), @@ -91,10 +130,12 @@ export class RemoteSessionViewModel { this.hooks.onKnownStateReset(); this.hooks.onResetTimeline(session.sessionId); onRouteChat(session.sessionId); - await this.hooks.onLoadModelCatalog(session.sessionId); - await this.refreshSessions(); + // 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. await this.hooks.onLoadActiveMessages(); this.hooks.onStartPolling(); + await this.refreshSessions(); }, instruction, modelId @@ -109,14 +150,69 @@ export class RemoteSessionViewModel { onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat, modelId: string = '' ): Promise { - if (path.length > 0 && path !== currentPath) { - await this.hooks.onSelectWorkspace(path); - await this.createSession(agentType, instruction, onRouteChat, modelId); + if (this.hooks.isBusy() || !this.hooks.remoteAvailable()) { return; } - if (path.length === 0 || path === currentPath) { - await this.createSession(agentType, instruction, onRouteChat, modelId); + const previousSession: SessionSummary = this.copySession(this.pageState.activeSession); + const previousWorkspacePath = currentPath; + this.beginSessionCreation(onRouteChat); + let created: SessionSummary | undefined; + try { + if (path.length > 0 && path !== currentPath) { + await this.hooks.onSelectWorkspace(path); + // Workspace selection clears page discovery state. Creation still owns + // the detail pane until the create command resolves. + this.pageState.setConversationLoading(true); + } + if (path.length === 0 || path === currentPath) { + created = await this.performCreateSession(agentType, instruction, onRouteChat, modelId); + } else if (this.pageState.workspacePath === path) { + created = await this.performCreateSession(agentType, instruction, onRouteChat, modelId); + } + if (!created) { + if (this.pageState.workspacePath === previousWorkspacePath) { + this.restoreAfterFailedCreation(previousSession, onRouteChat); + } else { + this.pageState.clearActiveSession(); + this.hooks.onResetTimeline(''); + this.hooks.onKnownStateReset(); + this.hooks.onRouteHome(); + } + } + } finally { + this.pageState.setConversationLoading(false); + this.pageState.setPendingSessionId(''); + } + } + + private beginSessionCreation(onRouteChat: (sessionId: string) => void): void { + this.hooks.onStopPolling(); + this.pageState.setPendingSessionId(''); + this.pageState.setConversationLoading(true); + onRouteChat(''); + } + + private restoreAfterFailedCreation( + previousSession: SessionSummary, + onRouteChat: (sessionId: string) => void + ): void { + if (previousSession.sessionId.length === 0) { + this.hooks.onRouteHome(); + return; } + this.pageState.setActiveSession(previousSession); + onRouteChat(previousSession.sessionId); + this.hooks.onStartPolling(); + } + + private copySession(session: SessionSummary): SessionSummary { + return { + sessionId: session.sessionId, + title: session.title, + workspacePath: session.workspacePath, + agentType: session.agentType, + initialTurnId: session.initialTurnId + }; } async openSession( @@ -124,14 +220,36 @@ export class RemoteSessionViewModel { currentWorkspacePath: string, onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat ): Promise { - const isBusy = this.hooks.isBusy(); + // A tap that arrives while another open is still in flight is a correction, + // not a mistake: the user has picked a different session and expects that + // one. It used to be swallowed, because `RemoteSessionController.open` + // raises the busy flag for the whole open and this guard read that flag as + // a run in progress — so for the second or so an uncached open takes, every + // row in the list was dead and nothing on screen said why. Opens supersede + // each other instead; only a run this view model did not start blocks. + const isBusy = this.hooks.isBusy() && this.openInFlight === 0; const remoteAvailable = this.hooks.remoteAvailable(); if (isBusy || item.id.length === 0 || !remoteAvailable) { + // Dropped taps are invisible from the outside — the row simply does + // nothing — so say which guard swallowed it. + RemoteLogger.info(`session open ignored session=${RemoteSessionViewModel.shortId(item.id)} busy=${isBusy ? '1' : '0'} available=${remoteAvailable ? '1' : '0'}`); return; } + // Bracketing the open: every stage between this line and the transcript + // reaching the screen logs, so the gap that the user feels can be pinned on + // one of them rather than inferred from the two ends. + RemoteLogger.info(`session open tap session=${RemoteSessionViewModel.shortId(item.id)}`); + MainThreadStallProbe.watch('session-open', 6000); this.pageState.setPendingSessionId(item.id); - this.pageState.setConversationLoading(true); + // No skeleton yet. The tapped row highlights from the pending id alone, and + // everything the detail pane needs to show the new session empty — title, + // workspace, a cleared timeline — is set synchronously below. If the + // transcript is cached it lands before the gate opens and the pane goes + // straight from one conversation to the next. + const loadingToken = this.openLoadingGate.arm(); onRouteChat(item.id); + RemoteLogger.info(`session open routed session=${RemoteSessionViewModel.shortId(item.id)}`); + this.openInFlight += 1; try { await this.sessions.open( item, @@ -145,7 +263,16 @@ export class RemoteSessionViewModel { this.pageState.setHasMoreMessages(false); this.files.clear(); this.pageState.clearComposer(); - await this.hooks.onLoadModelCatalog(item.id); + // Transcript first: it is the thing the user opened the session to + // see, and a cached one needs no round trip at all. + // + // Nothing fetches the model catalog here. The reset above puts the + // known catalog version back to zero, so the first poll asks for the + // catalog as part of the same request and the desktop sends it. A + // separate `get_model_catalog` would only queue ahead of the + // transcript — the desktop answers device RPCs one at a time, and + // measured against this device it pushed the transcript from ~2.4s + // out to ~5.5s. await this.hooks.onLoadActiveMessages(); if (this.pageState.activeSession.sessionId === session.sessionId) { this.hooks.onStartPolling(); @@ -153,8 +280,14 @@ export class RemoteSessionViewModel { } ); } finally { - this.pageState.setConversationLoading(false); - this.pageState.setPendingSessionId(''); + this.openInFlight -= 1; + this.openLoadingGate.release(loadingToken); + // Only the tap that still owns the gate owns the highlight: a second tap + // during the first open has already moved both. + if (loadingToken === this.openLoadingGate.currentToken()) { + this.pageState.setPendingSessionId(''); + } + RemoteLogger.info(`session open settled session=${RemoteSessionViewModel.shortId(item.id)}`); } } @@ -168,6 +301,7 @@ export class RemoteSessionViewModel { this.pageState.sessionFilter, async (): Promise => { this.hooks.onStopPolling(); + await this.chat.forgetMessages(item.id); this.hooks.onResetTimeline(''); this.pageState.setHasMoreMessages(false); this.pageState.clearComposer(); @@ -199,10 +333,6 @@ export class RemoteSessionViewModel { ); } - async loadModelCatalog(sessionId: string, isChatRoute: (sessionId: string) => boolean): Promise { - await this.models.loadCatalog(sessionId, this.hooks.remoteAvailable(), isChatRoute); - } - async selectModel(modelId: string): Promise { await this.models.selectModel( modelId, 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 02e90e9f0b..6f04721a46 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 @@ -1,6 +1,6 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice, CloudAccountRequestError, CloudAccountSession, CloudAccountClient } from '../../services/CloudAccountClient'; -import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; +import { CloudAccountSessionStore, PersistedCloudAccountSession } from '../../services/CloudAccountSessionStore'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { Encoding } from '../../services/Encoding'; import { @@ -14,6 +14,7 @@ import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/Genera import { GeneralChatServiceStatus } from '../../services/general-chat/GeneralChatServiceState'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { WatchProvisionOutcome } from '../../services/WatchProvisionController'; import { RemotePermissionMode } from '../../model/RemoteModels'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; @@ -33,6 +34,8 @@ export interface CloudAccountSettingsHooks { readonly startHeartbeat: () => void; readonly resetTimeline: () => void; readonly resetKnownRemoteState: () => void; + /** Cached transcripts and session lists belong to the account that was signed in. */ + readonly clearCachedRemoteData: () => Promise; readonly closeSettings: () => void; readonly closeConnectSheet: () => void; readonly navigateRemoteHome: () => void; @@ -157,6 +160,63 @@ export class SettingsController { return this.cloudSession !== undefined; } + /** + * Whether this phone might be able to add a device to the account itself. + * + * Deliberately "might": the session here is either a real password login or + * one delegated by a room pairing, and only the first carries a token the + * relay will mint from. The two are indistinguishable locally, so the answer + * is optimistic and `provisionWatchCredential` reports the refusal. + */ + canMintWatchCredential(): boolean { + return this.cloudSession !== undefined && this.cloudRelayUrl.length > 0; + } + + /** + * Mints a watch's own account credential straight from this phone. + * + * Returns `undefined` when this phone is not entitled to mint — no session, + * or a delegated token the relay refuses — which is the caller's cue to fall + * back to asking the paired desktop. Any other failure throws, because a + * network blip is not the same answer as "you may not". + * + * The account master key never leaves for the relay: it is read from the + * session this phone already holds and handed back for sealing to the + * watch's ephemeral key. + */ + async provisionWatchCredential( + deviceId: string, + deviceName: string, + requestId: string + ): Promise { + const session = this.cloudSession; + if (!session || this.cloudRelayUrl.length === 0) { + return undefined; + } + const cloud = this.requireCloud(); + try { + const provisioned = await cloud.client.provisionDevice( + this.cloudRelayUrl, session, deviceId, deviceName, requestId); + RemoteLogger.info(`watch credential minted from the phone account device=${provisioned.deviceId}`); + return { + ok: true, + relayUrl: this.cloudRelayUrl, + token: provisioned.token, + userId: provisioned.userId, + masterKeyBase64: Encoding.bytesToBase64(session.masterKey), + deviceId: provisioned.deviceId, + failure: '', + desktopReported: false + }; + } catch (err) { + if (err instanceof CloudAccountRequestError && (err.statusCode === 401 || err.statusCode === 403)) { + RemoteLogger.info('phone account may not mint a device credential; deferring to the desktop'); + return undefined; + } + throw err instanceof Error ? err : new Error('Watch credential provisioning failed.'); + } + } + async persistDelegatedAccountSession(): Promise { if (this.cloudSession) { return; @@ -192,27 +252,6 @@ export class SettingsController { return session.userId; } - async syncCloudAccount(): Promise { - const cloud = this.requireCloud(); - const session = this.cloudSession; - if (!session || this.cloudRelayUrl.length === 0) { - throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); - } - let bundles: Object[]; - try { - bundles = await cloud.client.fetchSessions(this.cloudRelayUrl, session, 0); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - throw new Error(RemoteI18n.t('remote.settings.accountExpired')); - } - throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed')); - } - await this.loadGeneralChatAccountModels(session, this.cloudRelayUrl); - RemoteLogger.info(`cloud account backup sync completed count=${bundles.length}`); - return String(bundles.length); - } - applyCloudAccountSession(session: CloudAccountSession, relayUrl: string, username: string): void { const remoteState = this.requireCloud().remoteState; this.cloudSession = session; @@ -232,6 +271,7 @@ export class SettingsController { this.store.replaceAccountModels([]); await this.refreshModelCatalog(); await cloud.sessionStore.clear(); + await cloud.hooks.clearCachedRemoteData(); cloud.remoteState.setAccountUserId(''); cloud.remoteState.setAccountUsername(''); cloud.remoteState.clearControlTarget(); @@ -245,7 +285,7 @@ export class SettingsController { return []; } try { - return await cloud.client.listDevices(this.cloudRelayUrl, session); + return await cloud.client.listDevices(this.cloudRelayUrl, session, cloud.hooks.deviceId()); } catch (err) { if (err instanceof CloudAccountRequestError && err.statusCode === 401) { await this.expireCloudAccountSession(); @@ -275,7 +315,19 @@ export class SettingsController { return cloud.sessionManager.setPermissionMode(mode); } - async restoreCloudTarget(targetDeviceId: string, targetDeviceName: string): Promise { + /** + * Puts the account back on the desktop it was last driving. + * + * `navigateHome` is off for the cold-start restore: the launch route is the + * user's business, and reconnecting in the background should not move them + * off the screen the app opened on. A manual reconnect is already on the + * Remote surface, so there it lands where the user expects. + */ + async restoreCloudTarget( + targetDeviceId: string, + targetDeviceName: string, + navigateHome: boolean = true + ): Promise { const targetId = targetDeviceId.trim(); if (targetId.length === 0) { return; @@ -297,9 +349,27 @@ export class SettingsController { deviceName: target.deviceName || targetDeviceName || target.deviceId, online: target.online, lastSeenAt: target.lastSeenAt - }); + }, navigateHome); } catch (err) { RemoteLogger.warn(`cloud target restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); + if (!this.cloudSession) { + // The account itself went away — an expired token clears it — so there + // is no desktop left to aim at, and the surfaces already say as much. + return; + } + // A failed connect attempt drops the target so a half-open link cannot be + // mistaken for a live one, but a restore that failed is exactly the case + // where the target still matters: without it the reconnect button has + // nothing to retry and falls through to the paired-desktop path. + remoteState.setControlTarget('account_device', targetId, targetDeviceName.trim() || targetId); + // The caller announces `reconnecting` before this runs, and every surface + // hides its reconnect entry point while a connect is in flight. Failing + // silently would leave that announcement standing forever, with no way + // left to retry it. Only report when nothing downstream already did. + if (remoteState.connectionState !== 'failed') { + remoteState.setConnectionState('failed'); + remoteState.setStatusText(ConnectionErrorPolicy.errorText(err)); + } } } @@ -370,7 +440,9 @@ export class SettingsController { targetDeviceName: device.deviceName }); cloud.hooks.startHeartbeat(); - await cloud.hooks.loadRecentWorkspaces(); + // Same reasoning as the paired-desktop path in RemoteConnectionController: + // the cross-workspace scan runs behind a list that is already usable. + void cloud.hooks.loadRecentWorkspaces(); } catch (err) { if (err instanceof CloudAccountRequestError && err.statusCode === 401) { await this.expireCloudAccountSession(); @@ -438,6 +510,7 @@ export class SettingsController { masterKey: Encoding.base64ToBytes(persisted.masterKey) }; this.applyCloudAccountSession(session, persisted.relayUrl, persisted.username || session.userId); + this.restorePersistedControlTarget(persisted); await this.loadGeneralChatAccountModels(session, persisted.relayUrl); } catch (err) { RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); @@ -445,6 +518,25 @@ export class SettingsController { } } + /** + * Names the desktop this account was last driving, without connecting to it. + * + * The account path has no pairing snapshot on disk the way the scanned-room + * path does — the target rides along in the encrypted account record — so + * without this a cold start could not even say which desktop it used to be + * on, and the restore that follows would have nothing to aim at. + */ + private restorePersistedControlTarget(persisted: PersistedCloudAccountSession): void { + const targetId = (persisted.targetDeviceId || '').trim(); + if (targetId.length === 0) { + return; + } + const targetName = (persisted.targetDeviceName || '').trim() || targetId; + const remoteState = this.requireCloud().remoteState; + remoteState.setControlTarget('account_device', targetId, targetName); + remoteState.setDesktopIdentity(targetName, targetId); + } + private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { const cloud = this.requireCloud(); this.store.replaceAccountModels([]); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets index 36a5ab165d..eb533f8968 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets @@ -1,3 +1,18 @@ +// Each offered action has a live and a dimmed form, and both are worth keeping +// apart from Idle. SendBlocked means the user has written something the link +// cannot carry yet — drawing that as idle would tell them their draft does not +// exist. VoiceBlocked keeps the microphone on screen through a busy moment, so +// the control does not swap glyphs mid-turn and then swap back. Idle is the one +// state with nothing to offer: no dictation on this surface, and no draft. +export enum ComposerPrimaryAction { + Stop = 'stop', + Send = 'send', + SendBlocked = 'send_blocked', + Voice = 'voice', + VoiceBlocked = 'voice_blocked', + Idle = 'idle' +} + export class ChatComposerPolicy { static canSend( text: string, @@ -15,4 +30,56 @@ export class ChatComposerPolicy { static canUseVoice(text: string, attachmentCount: number, isBusy: boolean): boolean { return text.trim().length === 0 && attachmentCount === 0 && !isBusy; } + + // Which of the things the one round button on the right is offering. The + // composer has a single primary slot rather than a row of buttons, so "which + // action" is a decision, not a layout detail — and it is the same decision on + // both clients. Dictation and a running turn used to arrive here folded into + // one flag, but they sit on opposite sides of the draft: dictation outranks + // it because the draft is still being spoken, whereas a running turn only + // outranks it where the draft has nowhere to go — on a surface that can hand + // the message over mid-run, keeping the button on Stop traps what was typed. + static primaryAction( + text: string, + attachmentCount: number, + isBusy: boolean, + isVoiceListening: boolean, + isTurnRunning: boolean, + supportsMidRunSend: boolean, + requiresRemoteConnection: boolean, + connectionState: string, + showVoiceInput: boolean + ): ComposerPrimaryAction { + if (isVoiceListening) { + return ComposerPrimaryAction.Stop; + } + if (isTurnRunning && !supportsMidRunSend) { + return ComposerPrimaryAction.Stop; + } + if (text.trim().length > 0 || attachmentCount > 0) { + const sendable = ChatComposerPolicy.canSend( + text, attachmentCount, isBusy, requiresRemoteConnection, connectionState); + return sendable ? ComposerPrimaryAction.Send : ComposerPrimaryAction.SendBlocked; + } + if (isTurnRunning) { + return ComposerPrimaryAction.Stop; + } + if (!showVoiceInput) { + return ComposerPrimaryAction.Idle; + } + const usable = ChatComposerPolicy.canUseVoice(text, attachmentCount, isBusy); + return usable ? ComposerPrimaryAction.Voice : ComposerPrimaryAction.VoiceBlocked; + } + + // Whether the bar is in its tall form, where the field gets its own row and + // the side controls move to a second one. A newline counts even without + // focus: the collapsed field is one line tall, and text the user cannot see + // is text they cannot check before sending. + static isExpanded( + text: string, + inputFocused: boolean, + modelSelectorOpen: boolean + ): boolean { + return inputFocused || modelSelectorOpen || text.indexOf('\n') >= 0; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets index 84837768fa..ee974c5e55 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets @@ -22,6 +22,10 @@ export interface ChatSessionSnapshot { activeTurn?: ChatMessage; modelCatalog?: RemoteModelCatalog; shouldSyncAfterTurnEnded: boolean; + // The desktop now holds fewer messages than this session had counted, so the + // transcript it was handing out tails of is no longer the one on screen. + // Nothing below can resume from an offset into a list that shrank. + historyRewritten: boolean; } export interface ChatSessionControllerCallbacks { @@ -182,6 +186,8 @@ export class ChatSessionController { const hasAssistantMessage = incomingMessages.some((message: ChatMessage) => { return message.role === 'assistant'; }); + const historyRewritten = result.totalMessageCount > 0 && + result.totalMessageCount < this.cursor.knownMessageCount; if (result.changed) { this.cursor = { @@ -215,7 +221,8 @@ export class ChatSessionController { result.changed || activeTurnChanged, result, incomingMessages, - turnEndedNow || (isSettlingEndedTurn && !isRunningNow) + turnEndedNow || (isSettlingEndedTurn && !isRunningNow), + historyRewritten ); } @@ -232,7 +239,8 @@ export class ChatSessionController { changed: boolean, result: PollSessionResult, newMessages: ChatMessage[], - shouldSyncAfterTurnEnded: boolean + shouldSyncAfterTurnEnded: boolean, + historyRewritten: boolean ): void { this.callbacks.onSnapshot({ sessionId: this.sessionId, @@ -247,7 +255,8 @@ export class ChatSessionController { newMessages, activeTurn: this.activeTurn, modelCatalog: result.modelCatalog, - shouldSyncAfterTurnEnded + shouldSyncAfterTurnEnded, + historyRewritten }); } 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 05a894ef71..ef2cdca7bf 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 @@ -21,14 +21,28 @@ export class ChatTimelineRevisionTracker { private signature: string = ''; private revision: number = 0; + /** + * Identity of a single rendered item: its own id, plus everything about it + * the bubble draws. + * + * Doubles as the timeline's `ForEach` key, which is the point of it being + * per-item. Keying on the revision instead made every bubble in the + * transcript a new node whenever any one of them changed — during a run that + * is a full rebuild per poll tick, to show one message growing. + */ + static itemSignature(item: ChatTimelineItem): string { + const message = item.message; + return `${item.type}:${item.id}:${item.isStreaming}:${item.isFinalizing}:` + + `${message ? message.status : ''}:${message ? message.renderVersion || 0 : 0}:` + + `${message ? ChatTimelineRevisionTracker.textSignature(message.text) : ''}:` + + `${message && message.thinking ? ChatTimelineRevisionTracker.textSignature(message.thinking) : ''}:` + + `${message && message.items ? message.items.length : 0}:${message && message.tools ? message.tools.length : 0}`; + } + update(items: ChatTimelineItem[]): number { - const nextSignature = items.map((item: ChatTimelineItem) => { - const message = item.message; - return `${item.type}:${item.id}:${item.isStreaming}:${item.isFinalizing}:` + - `${message ? message.status : ''}:${message ? message.renderVersion || 0 : 0}:` + - `${message ? this.textSignature(message.text) : ''}:${message && message.thinking ? this.textSignature(message.thinking) : ''}:` + - `${message && message.items ? message.items.length : 0}:${message && message.tools ? message.tools.length : 0}`; - }).join('|'); + const nextSignature = items + .map((item: ChatTimelineItem) => ChatTimelineRevisionTracker.itemSignature(item)) + .join('|'); if (nextSignature !== this.signature) { this.signature = nextSignature; this.revision += 1; @@ -36,7 +50,7 @@ export class ChatTimelineRevisionTracker { return this.revision; } - private textSignature(value: string): string { + private static textSignature(value: string): string { let hash = 0; for (let index = 0; index < value.length; index++) { hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0; @@ -52,15 +66,23 @@ export class ChatTimelineRevisionTracker { } export class ChatTimelineProjector { + /** + * `activeTurnAnchorId` is the id of the optimistic message the running turn + * is answering. Anything sent after that message is queued behind the turn, + * so it belongs below the reply rather than above it — appending the turn + * last would push a mid-run message up under the previous one instead. + */ static project( messages: ChatMessage[], pendingMessages: ChatMessage[], activeTurn: ChatMessage, - hasMoreMessages: boolean + hasMoreMessages: boolean, + activeTurnAnchorId: string = '' ): ChatTimelineItem[] { const timelineMessages = ChatTimelineProjector.realMessages(messages); 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 = { id: `message-${message.id}`, @@ -73,7 +95,14 @@ export class ChatTimelineProjector { return item; }); - pendingItems.forEach((message: ChatMessage) => { + // The message that started the turn has usually been persisted by the time + // the reply streams, so no anchor is left to sit behind: whatever is still + // optimistic was sent later and is waiting its turn. + if (shouldRenderActiveTurn && anchorIndex < 0) { + items.push(ChatTimelineProjector.activeTurnItem(activeTurn)); + } + + pendingItems.forEach((message: ChatMessage, index: number) => { const item: ChatTimelineItem = { id: `pending-${message.id}`, type: 'optimistic_user_message', @@ -83,21 +112,11 @@ export class ChatTimelineProjector { showRetryAction: false }; items.push(item); + if (shouldRenderActiveTurn && index === anchorIndex) { + items.push(ChatTimelineProjector.activeTurnItem(activeTurn)); + } }); - if (shouldRenderActiveTurn) { - const turnKey = ChatTimelineProjector.activeTurnKey(activeTurn); - const item: ChatTimelineItem = { - id: `active-${turnKey}-${ChatTimelineProjector.activeTurnVersionKey(activeTurn)}`, - type: 'assistant_live_turn', - message: activeTurn, - isStreaming: (activeTurn.status || '').toLowerCase() === 'active', - isFinalizing: (activeTurn.status || '').toLowerCase() === 'completed', - showRetryAction: false - }; - items.push(item); - } - if (items.length === 0 && !hasMoreMessages) { const item: ChatTimelineItem = { id: 'empty-state', @@ -113,6 +132,31 @@ export class ChatTimelineProjector { return items; } + private static anchorIndex(pendingItems: ChatMessage[], activeTurnAnchorId: string): number { + if (activeTurnAnchorId.length === 0) { + return -1; + } + for (let index = 0; index < pendingItems.length; index++) { + if (pendingItems[index].id === activeTurnAnchorId) { + return index; + } + } + return -1; + } + + private static activeTurnItem(activeTurn: ChatMessage): ChatTimelineItem { + const turnKey = ChatTimelineProjector.activeTurnKey(activeTurn); + const item: ChatTimelineItem = { + id: `active-${turnKey}-${ChatTimelineProjector.activeTurnVersionKey(activeTurn)}`, + type: 'assistant_live_turn', + message: activeTurn, + isStreaming: (activeTurn.status || '').toLowerCase() === 'active', + isFinalizing: (activeTurn.status || '').toLowerCase() === 'completed', + showRetryAction: false + }; + return item; + } + private static markLatestFailedMessageRetryable(items: ChatTimelineItem[]): void { for (let index = items.length - 1; index >= 0; index--) { const message = items[index].message; 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 691a619a49..ef90f6c172 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 @@ -10,6 +10,11 @@ import { ChatTimelineItem, ChatTimelineProjector } from './ChatTimelineProjector import { RemoteUiState } from './RemoteUiState'; import { ConversationEvent } from './ConversationEvent'; +// Marks the placeholder turn shown between sending a message and hearing back +// which turn the desktop opened for it. Everything after the prefix is a local +// message id, so it must never be mistaken for a server-issued turn id. +export const PENDING_ACTIVE_TURN_ID_PREFIX: string = 'active-pending-'; + export type ChatSyncPhase = | 'idle' | 'loading' @@ -32,9 +37,15 @@ export interface ChatTimelineState { export class ChatTimelineStore { private state: ChatTimelineState = ChatTimelineStore.emptyState(''); + // The optimistic message the active turn is replying to. Kept out of + // `ChatTimelineState` because nothing but the projection needs it: it exists + // so a message sent mid-run renders below the running reply instead of + // jumping up beside the message that started it. + private activeTurnAnchor: string = ''; reset(sessionId: string = ''): void { this.state = ChatTimelineStore.emptyState(sessionId); + this.activeTurnAnchor = ''; } snapshot(): ChatTimelineState { @@ -206,6 +217,11 @@ export class ChatTimelineStore { !ChatTimelineStore.isLocalPendingActiveTurn(this.state.activeTurn)) { return; } + // Promoting the placeholder keeps the message it was opened for; a turn + // that appears without one belongs to the newest thing the user sent. + if (!this.state.activeTurn || !ChatTimelineStore.isLocalPendingActiveTurn(this.state.activeTurn)) { + this.activeTurnAnchor = this.lastOptimisticMessageId(); + } const activeTurn: ChatMessage = { id: activeId, turnId: normalizedTurnId, @@ -231,12 +247,13 @@ export class ChatTimelineStore { if (normalizedLocalId.length === 0) { return ''; } - const activeId = `active-pending-${normalizedLocalId}`; + const activeId = `${PENDING_ACTIVE_TURN_ID_PREFIX}${normalizedLocalId}`; if (this.state.activeTurn && this.state.activeTurn.id.length > 0 && this.state.activeTurn.id !== activeId && !ChatTimelineStore.isLocalPendingActiveTurn(this.state.activeTurn)) { return ''; } + this.activeTurnAnchor = normalizedLocalId; const activeTurn: ChatMessage = { id: activeId, role: 'assistant', @@ -262,6 +279,7 @@ export class ChatTimelineStore { !ChatTimelineStore.isLocalPendingActiveTurn(this.state.activeTurn)) { return; } + this.activeTurnAnchor = ''; this.state = { sessionId: this.state.sessionId, persistedMessages: this.state.persistedMessages, @@ -291,6 +309,7 @@ export class ChatTimelineStore { } clearActiveTurn(): void { + this.activeTurnAnchor = ''; this.state = { sessionId: this.state.sessionId, persistedMessages: this.state.persistedMessages, @@ -398,10 +417,21 @@ export class ChatTimelineStore { this.state.persistedMessages, this.state.optimisticMessages, this.activeTurnOrEmpty(), - hasMoreMessages + hasMoreMessages, + this.activeTurnAnchor ); } + /** The optimistic message the running turn answers, or '' once it persisted. */ + activeTurnAnchorId(): string { + return this.activeTurnAnchor; + } + + private lastOptimisticMessageId(): string { + const messages = this.state.optimisticMessages; + return messages.length > 0 ? messages[messages.length - 1].id : ''; + } + private acceptsSession(sessionId: string): boolean { return sessionId.length === 0 || this.state.sessionId.length === 0 || this.state.sessionId === sessionId; } @@ -494,8 +524,23 @@ export class ChatTimelineStore { return undefined; } + // The turn id to ask the desktop to cancel, or '' for "whatever is running + // now". A placeholder turn carries a local message id rather than a turn id, + // and offering that as one gets the cancel rejected as stale while the task + // keeps going — so the placeholder window has to fall through to no id. + static cancelableTurnId(activeTurn: ChatMessage): string { + if (activeTurn.turnId && activeTurn.turnId.length > 0) { + return activeTurn.turnId; + } + if (activeTurn.id.indexOf(PENDING_ACTIVE_TURN_ID_PREFIX) === 0) { + return ''; + } + return activeTurn.id.indexOf('active-') === 0 ? activeTurn.id.slice('active-'.length) : ''; + } + private static isLocalPendingActiveTurn(activeTurn: ChatMessage): boolean { - return activeTurn.id.indexOf('active-pending-') === 0 && (!activeTurn.turnId || activeTurn.turnId.length === 0); + return activeTurn.id.indexOf(PENDING_ACTIVE_TURN_ID_PREFIX) === 0 && + (!activeTurn.turnId || activeTurn.turnId.length === 0); } private static emptyState(sessionId: string): ChatTimelineState { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets index 1ae3c6919b..c00e33d725 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets @@ -3,6 +3,7 @@ import { CloudAccountCrypto, CloudAccountKdfParams } from './CloudAccountCrypto' import { Encoding } from './Encoding'; import { HarmonyRemoteCryptoCipher } from './RemoteCrypto'; import { RemoteLogger } from './RemoteLogger'; +import { RemoteI18n } from '../i18n/RemoteI18n'; import { CommandStatusResponse, EncryptedPayload, RemoteCommand } from '../model/RemoteModels'; interface AccountChallenge { @@ -18,7 +19,13 @@ interface AccountAuthResponse { } interface LoginChallengeRequest { username: string; } -interface LoginRequest { username: string; password_hash: string; device_id: string; device_name: string; } +interface LoginRequest { + username: string; + password_hash: string; + device_id: string; + device_name: string; + device_kind: string; +} interface RelayErrorResponse { error?: string; } export class CloudAccountRequestError extends Error { @@ -40,46 +47,37 @@ export interface CloudAccountSession { export interface CloudAccountDevice { deviceId: string; deviceName: string; + deviceKind?: string; online: boolean; lastSeenAt?: number; } -interface CloudAccountDeviceWire { - device_id: string; - device_name: string; - online: boolean; - last_seen_at?: number; +/** A device credential minted for another device, never for this one. */ +export interface CloudProvisionedDevice { + token: string; + userId: string; + deviceId: string; } -export interface CloudSessionBundle { - sessionId: string; - metadata: Object; - turns: Object[]; - sourceDeviceId?: string; - sourceDeviceName?: string; - version: number; +interface ProvisionDeviceRequest { + device_id: string; + device_name: string; + device_kind: string; + request_id: string; } -interface SyncSessionEntry { - session_id: string; - encrypted_data: string; - nonce: string; - version: number; +interface ProvisionDeviceWire { + token: string; + user_id: string; + device_id: string; } -interface SyncSessionList { sessions: SyncSessionEntry[]; } -interface SessionBundleWire { - session_id: string; - metadata: Object; - turns: Object[]; - source_device_id?: string; - source_device_name?: string; -} -interface SyncSessionUpload { - session_id: string; - encrypted_data: string; - nonce: string; - version: number; +interface CloudAccountDeviceWire { + device_id: string; + device_name: string; + device_kind?: string; + online: boolean; + last_seen_at?: number; } interface SyncSettingsEntry { @@ -96,6 +94,45 @@ export interface CloudSettingsBlob { /** Default BitFun cloud relay used by the desktop account flow. */ export const DEFAULT_CLOUD_RELAY_URL: string = 'https://remote.openbitfun.com/relay'; +/** Device kinds the relay accepts; mirrors `relay-service/src/db.rs::DEVICE_KINDS`. */ +const DEVICE_KIND_DESKTOP: string = 'desktop'; +const DEVICE_KIND_MOBILE: string = 'mobile'; +const DEVICE_KIND_WATCH: string = 'watch'; + +/** + * Device names our own non-desktop builds register under, lowercased. + * + * Only labels we hardcode ourselves belong here: the Android client reports + * `Build.MODEL` (`android/.../DeviceInstall.kt:27`), an arbitrary string that + * guessing at would hide desktops as readily as phones. + * + * Kept in step with `shared/core-transport/.../CloudAccountClient.kt`, which + * carries the same list for the Android and iOS clients. + */ +const KNOWN_NON_DESKTOP_DEVICE_NAMES: string[] = ['harmonyos phone', 'harmonyos watch']; + +/** + * Whether a relay device row is a desktop, and so controllable from a phone. + * + * A row that reports its kind is taken at its word. A row without one predates + * the relay learning about kinds, and is judged by two weaker signals: this + * phone's own row is never a desktop, and neither is one carrying a name our + * own builds register under. Anything else stays visible — hiding a real + * desktop would strand the user, while a stale phone row disappears the next + * time that phone logs in against a relay that stores kinds. + */ +function isDesktopDeviceRow(device: CloudAccountDeviceWire, selfDeviceId: string): boolean { + const kind = (device.device_kind || '').trim(); + if (kind.length > 0) { + return kind === DEVICE_KIND_DESKTOP; + } + if (selfDeviceId.length > 0 && device.device_id === selfDeviceId) { + return false; + } + const name = (device.device_name || '').trim().toLowerCase(); + return KNOWN_NON_DESKTOP_DEVICE_NAMES.indexOf(name) < 0; +} + /** Client for the current desktop relay account protocol. */ export class CloudAccountClient { private readonly cipher: HarmonyRemoteCryptoCipher = new HarmonyRemoteCryptoCipher(); @@ -124,30 +161,53 @@ export class CloudAccountClient { username: normalizedUser, password_hash: Encoding.bytesToBase64(passwordHash), device_id: deviceId, - device_name: 'HarmonyOS Phone' + device_name: 'HarmonyOS Phone', + device_kind: DEVICE_KIND_MOBILE }; const auth = await this.post(normalizedRelayUrl, '/api/auth/login', loginRequest); RemoteLogger.info(`cloud login authenticated elapsed_ms=${Date.now() - startedAt}`); return { token: auth.token, userId: auth.user_id, masterKey }; } - async fetchSessions(relayUrl: string, session: CloudAccountSession, since: number = 0): Promise { - const suffix = `/api/sync/sessions?since=${Math.max(0, since)}`; - const payload = await this.request(relayUrl, suffix, 'GET', undefined, session.token); - const bundles: CloudSessionBundle[] = []; - for (const entry of payload.sessions || []) { - const plain = await this.decryptSyncPayload(session.masterKey, entry.encrypted_data, entry.nonce); - const bundle = JSON.parse(plain) as SessionBundleWire; - bundles.push({ - sessionId: bundle.session_id || entry.session_id, - metadata: bundle.metadata, - turns: bundle.turns, - sourceDeviceId: bundle.source_device_id, - sourceDeviceName: bundle.source_device_name, - version: entry.version - }); + /** + * Adds another device to this account and returns its own credential. + * + * The relay gates `/api/auth/provision-device` on nothing but + * `AuthToken::is_device_token()` (`relay-service/src/routes/auth.rs`), so a + * phone that signed in with the account password is as entitled to mint here + * as the desktop is — the desktop was never special, it just happened to be + * the one holding a session. A token delegated by a room pairing is + * `delegated_control` rather than `device` and is refused with 403; that is + * the caller's cue to fall back to asking the desktop. + * + * The account master key is deliberately not part of this exchange. The relay + * never sees it, and the caller already holds its own copy — a watch gets it + * sealed to its ephemeral key, not from here. + */ + async provisionDevice( + relayUrl: string, + session: CloudAccountSession, + deviceId: string, + deviceName: string, + requestId: string + ): Promise { + const body: ProvisionDeviceRequest = { + device_id: deviceId, + device_name: deviceName, + device_kind: DEVICE_KIND_WATCH, + request_id: requestId + }; + const wire = await this.request( + relayUrl, '/api/auth/provision-device', 'POST', body, session.token); + const token = (wire.token || '').trim(); + const userId = (wire.user_id || '').trim(); + const provisionedDeviceId = (wire.device_id || '').trim(); + if (token.length === 0 || userId !== session.userId || provisionedDeviceId !== deviceId) { + // A credential naming a different account or device would sign the watch + // in as somebody else. Refuse rather than pass it on. + throw new Error('Relay returned a mismatched provisioned device identity.'); } - return bundles; + return { token, userId, deviceId: provisionedDeviceId }; } async fetchSettings(relayUrl: string, session: CloudAccountSession): Promise { @@ -169,11 +229,25 @@ export class CloudAccountClient { }; } - async listDevices(relayUrl: string, session: CloudAccountSession): Promise { + /** + * The account's controllable devices — desktops only. + * + * A relay that stores device kinds already filters this list; the client + * repeats the judgement so a phone stops listing itself and its peers before + * that relay is deployed. [selfDeviceId] is this install's own device id. + */ + async listDevices( + relayUrl: string, + session: CloudAccountSession, + selfDeviceId: string = '' + ): Promise { const devices = await this.request(relayUrl, '/api/devices', 'GET', undefined, session.token); - return devices.map((device: CloudAccountDeviceWire): CloudAccountDevice => ({ + const desktops = devices.filter((device: CloudAccountDeviceWire): boolean => + isDesktopDeviceRow(device, selfDeviceId)); + return desktops.map((device: CloudAccountDeviceWire): CloudAccountDevice => ({ deviceId: device.device_id, deviceName: device.device_name || device.device_id, + deviceKind: device.device_kind, online: device.online, lastSeenAt: device.last_seen_at })); @@ -199,6 +273,7 @@ export class CloudAccountClient { encrypted_data: Encoding.bytesToBase64(encrypted), nonce: Encoding.bytesToBase64(nonce) }; + RemoteLogger.info(`device rpc cmd=${command.cmd || 'unknown'} bytes=${body.encrypted_data.length}`); const response = await this.request( relayUrl, `/api/devices/${encodeURIComponent(target)}/rpc`, @@ -219,33 +294,6 @@ export class CloudAccountClient { return parsed; } - async deleteSession(relayUrl: string, session: CloudAccountSession, sessionId: string): Promise { - await this.request(relayUrl, `/api/sync/sessions/${encodeURIComponent(sessionId)}`, 'DELETE', undefined, session.token); - } - - async uploadSession(relayUrl: string, session: CloudAccountSession, bundle: CloudSessionBundle): Promise { - const nonce = Encoding.randomBytes(12); - const wire: SessionBundleWire = { - session_id: bundle.sessionId, - metadata: bundle.metadata, - turns: bundle.turns, - source_device_id: bundle.sourceDeviceId, - source_device_name: bundle.sourceDeviceName - }; - const encrypted = await this.cipher.encrypt( - Encoding.utf8ToBytes(JSON.stringify(wire)), session.masterKey, nonce - ); - const version = Date.now(); - const body: SyncSessionUpload = { - session_id: bundle.sessionId, - encrypted_data: Encoding.bytesToBase64(encrypted), - nonce: Encoding.bytesToBase64(nonce), - version - }; - await this.request(relayUrl, '/api/sync/sessions', 'POST', body, session.token); - return version; - } - private async decryptSyncPayload(masterKey: Uint8Array, data: string, nonceText: string): Promise { const plain = await this.cipher.decrypt(Encoding.base64ToBytes(data), masterKey, Encoding.base64ToBytes(nonceText)); return Encoding.bytesToUtf8(plain); @@ -286,6 +334,10 @@ export class CloudAccountClient { ): Promise { const request = http.createHttp(); const base = relayUrl.replace(/\/$/, ''); + // Timed so the watch has something to be slow relative to. Phone and watch + // reach the same relay and the same desktop, so a gap between them is the + // client's own cost and a shared floor is the round trip's. + const startedAt = Date.now(); try { const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; if (token.length > 0) headers.Authorization = `Bearer ${token}`; @@ -296,11 +348,26 @@ export class CloudAccountClient { connectTimeout: 15000, readTimeout: readTimeoutMs }; - if (body !== undefined) requestOptions.extraData = JSON.stringify(body); + let bodyBytes = 0; + if (body !== undefined) { + const encoded = JSON.stringify(body); + bodyBytes = encoded.length; + requestOptions.extraData = encoded; + } const response = await request.request(`${base}${path}`, requestOptions); const text = typeof response.result === 'string' ? response.result : JSON.stringify(response.result); + // `tls` is cumulative from request start: near zero means the connection + // was reused, and anything large is a handshake this call paid for alone. + const timing = response.performanceTiming; + RemoteLogger.info(`relay ${method} ${path} status=${response.responseCode} elapsed_ms=${Date.now() - startedAt}` + + ` dns=${Math.round(timing.dnsTiming)} tcp=${Math.round(timing.tcpTiming)}` + + ` tls=${Math.round(timing.tlsTiming)} ttfb=${Math.round(timing.firstReceiveTiming)}`); if (response.responseCode < 200 || response.responseCode >= 300) { - let message = `Relay login failed (HTTP ${response.responseCode}).`; + // The size belongs in the log: a body the relay refuses for being too + // big is otherwise indistinguishable from one it refuses for auth. + RemoteLogger.error(`relay request failed ${method} ${path} status=${response.responseCode} bytes=${bodyBytes}`); + let message = response.responseCode === 413 ? RemoteI18n.t('errors.payloadTooLarge') : + `Relay request failed (HTTP ${response.responseCode}).`; try { const error = JSON.parse(text) as RelayErrorResponse; if (error.error) message = error.error; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/DeferredLoadingGate.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DeferredLoadingGate.ets new file mode 100644 index 0000000000..0b758bb1ab --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DeferredLoadingGate.ets @@ -0,0 +1,69 @@ +/** + * Holds a loading state back until waiting for it is something the user can + * actually perceive. + * + * Opening a session that is already cached needs one short read off disk, and + * announcing that with a full-pane skeleton costs more than it explains: the + * transcript is replaced by a placeholder and then by itself again, which reads + * as a stall rather than as speed. Below the grace period nothing is shown at + * all; past it the read is slow enough — a fetch over the relay, say — that + * silence would be the worse answer. + * + * Each `arm` supersedes the one before it, so a second tap while the first open + * is still settling cannot have its skeleton dismissed by the first one + * finishing. The token is what enforces that. + */ +export class DeferredLoadingGate { + private readonly delayMs: number; + private readonly onVisible: (visible: boolean) => void; + private timerId: number = 0; + private token: number = 0; + + constructor(delayMs: number, onVisible: (visible: boolean) => void) { + this.delayMs = delayMs; + this.onVisible = onVisible; + } + + /** Starts a wait. The returned token is what `release` has to be given. */ + arm(): number { + this.cancelTimer(); + this.token += 1; + const armed = this.token; + this.timerId = setTimeout(() => { + this.timerId = 0; + if (armed !== this.token) { + return; + } + this.onVisible(true); + }, this.delayMs); + return armed; + } + + /** The token of the most recent `arm`, for callers keeping state beside it. */ + currentToken(): number { + return this.token; + } + + /** Ends the wait started by `token`, if it is still the current one. */ + release(token: number): void { + if (token !== this.token) { + return; + } + this.cancelTimer(); + this.onVisible(false); + } + + /** Ends whatever wait is open, for teardown. */ + reset(): void { + this.cancelTimer(); + this.token += 1; + this.onVisible(false); + } + + private cancelTimer(): void { + if (this.timerId !== 0) { + clearTimeout(this.timerId); + this.timerId = 0; + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/Encoding.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/Encoding.ets index 610a847bf4..c4cfc3c96a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/Encoding.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/Encoding.ets @@ -2,6 +2,17 @@ import buffer from '@ohos.buffer'; import { cryptoFramework } from '@kit.CryptoArchitectureKit'; import util from '@ohos.util'; +/** + * Byte/text conversions on the path every relay RPC takes. + * + * These run inline on whichever thread issues the call, which for the device + * RPC is the UI thread, and they run over the whole request and the whole + * response — a transcript-sized `poll_session` reply is hundreds of kilobytes. + * So the cost per byte is what decides whether the frame that should be + * painting a skeleton gets to run at all. Everything here has to stay on a + * native bulk primitive; anything that touches bytes one at a time from ArkTS + * shows up directly as a blocked main thread. + */ export class Encoding { static utf8ToBytes(text: string): Uint8Array { const value = buffer.from(text, 'utf-8'); @@ -48,12 +59,23 @@ export class Encoding { return JSON.parse(text) as T; } + /** + * Detaches a `Buffer` into a plain `Uint8Array` in one native copy. + * + * This used to read `value[index]` in a loop. On a `Buffer` each of those is + * a proxied property access rather than a typed-array load, so a + * transcript-sized response — hundreds of kilobytes, hundreds of thousands of + * reads — spent about a second of the UI thread here, inside the window where + * the conversation pane was meant to be painting its skeleton. + * + * `value.buffer` is already scoped to this Buffer's own bytes: measured on + * device, a 32-byte Buffer sitting at pool offset 856 reports a 32-byte + * ArrayBuffer, so the offset must not be applied to it. `util.Base64Helper` + * and `util.TextEncoder` would skip the Buffer entirely, but both return + * empty on this runtime. + */ private static copyBuffer(value: buffer.Buffer): Uint8Array { - const copy = new Uint8Array(value.length); - for (let index = 0; index < value.length; index++) { - copy[index] = value[index]; - } - return copy; + return new Uint8Array(value.buffer); } private static copyRandomData(value: Uint8Array | ArrayBuffer, expectedSize: number): Uint8Array { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ImagePickerService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ImagePickerService.ets index 524ab49dd9..397aaa6c42 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ImagePickerService.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ImagePickerService.ets @@ -3,6 +3,7 @@ import image from '@ohos.multimedia.image'; import photoAccessHelper from '@ohos.file.photoAccessHelper'; import util from '@ohos.util'; import { RemoteI18n } from '../i18n/RemoteI18n'; +import { RemoteLogger } from './RemoteLogger'; import { RemoteImageContext, SelectedImageAttachment } from '../model/RemoteModels'; interface ImageMetadata { @@ -24,8 +25,15 @@ interface ImageSize { export class ImagePickerService { private readonly maxImageBytes: number = 4 * 1024 * 1024; + /** + * What an image is allowed to weigh on the wire. A picture travels as base64 + * inside an encrypted, base64-wrapped command, so it reaches the relay at + * roughly 1.8× its byte size — a budget in the hundreds of KB is what keeps a + * phone photo inside a request the relay will accept. + */ + private readonly transportImageBudgetBytes: number = 800 * 1024; private readonly maxImageDimension: number = 1600; - private readonly compressionQualities: number[] = [82, 72, 62]; + private readonly compressionQualities: number[] = [82, 72, 62, 50]; async pickImages(maxCount: number, existingCount: number): Promise { const remaining = Math.max(0, maxCount - existingCount); @@ -74,6 +82,7 @@ export class ImagePickerService { const base64 = new util.Base64Helper().encodeToStringSync(encoded.bytes); const name = this.imageName(uri, index); + RemoteLogger.info(`image picked original=${stat.size} encoded=${encoded.bytes.length} compressed=${encoded.compressed ? '1' : '0'} mime=${encoded.mimeType}`); return { id: `harmony-image-${Date.now()}-${index}`, uri, @@ -87,7 +96,7 @@ export class ImagePickerService { } private async encodeImage(uri: string, originalSize: number): Promise { - if (originalSize <= this.maxImageBytes) { + if (originalSize <= this.transportImageBudgetBytes) { return this.readOriginalImage(uri, originalSize); } @@ -140,7 +149,7 @@ export class ImagePickerService { }); const bytes = new Uint8Array(packed); bestBytes = bytes; - if (bytes.length <= this.maxImageBytes) { + if (bytes.length <= this.transportImageBudgetBytes) { return { bytes, mimeType: 'image/jpeg', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MainThreadStallProbe.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MainThreadStallProbe.ets new file mode 100644 index 0000000000..c0c5a6f66f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MainThreadStallProbe.ets @@ -0,0 +1,58 @@ +import { RemoteLogger } from './RemoteLogger'; + +const TICK_MS = 250; +const REPORT_THRESHOLD_MS = 400; + +/** + * Tells a blocked main thread apart from a deferred frame. + * + * A fixed-interval timer whose own lateness is the measurement: the tick is + * trivial, so anything it arrives late by was spent by somebody else holding + * the JS thread. Without this, a log line that lands 1.6s after the state + * write behind it reads the same either way — the thread was busy, or ArkUI + * simply had not painted yet — and those two want opposite fixes. + * + * Armed for a window rather than left running, so nothing pays 4Hz for a + * measurement that only matters around an event someone asked about. + */ +export class MainThreadStallProbe { + private static timerId: number = 0; + private static deadline: number = 0; + private static expectedAt: number = 0; + private static reason: string = ''; + + /** Watches for `windowMs`, restarting the window if one is already open. */ + static watch(reason: string, windowMs: number): void { + MainThreadStallProbe.reason = reason; + MainThreadStallProbe.deadline = Date.now() + windowMs; + if (MainThreadStallProbe.timerId !== 0) { + return; + } + MainThreadStallProbe.arm(); + } + + static stop(): void { + if (MainThreadStallProbe.timerId !== 0) { + clearTimeout(MainThreadStallProbe.timerId); + MainThreadStallProbe.timerId = 0; + } + MainThreadStallProbe.deadline = 0; + } + + private static arm(): void { + MainThreadStallProbe.expectedAt = Date.now() + TICK_MS; + MainThreadStallProbe.timerId = setTimeout(() => { + MainThreadStallProbe.timerId = 0; + const now = Date.now(); + const late = now - MainThreadStallProbe.expectedAt; + if (late >= REPORT_THRESHOLD_MS) { + RemoteLogger.info(`main thread stall reason=${MainThreadStallProbe.reason} blocked_ms=${late}`); + } + if (now >= MainThreadStallProbe.deadline) { + MainThreadStallProbe.deadline = 0; + return; + } + MainThreadStallProbe.arm(); + }, TICK_MS); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteActivityLifecycleController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteActivityLifecycleController.ets index 9a3382f119..d26fb88e5e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteActivityLifecycleController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteActivityLifecycleController.ets @@ -4,7 +4,7 @@ export class RemoteActivityLifecycleController { private readonly heartbeatController: RemoteHeartbeatController; constructor( - onHeartbeatTick: () => void, + onHeartbeatTick: () => Promise, heartbeatIntervalMs: number = 15000, heartbeatScheduler?: RemoteHeartbeatScheduler ) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCache.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCache.ets new file mode 100644 index 0000000000..dac8913ddc --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCache.ets @@ -0,0 +1,276 @@ +import { ChatMessage } from '../model/RemoteModels'; +import { RemoteLogger } from './RemoteLogger'; + +/** + * One session's cached transcript, as it was last written. + * + * `lastMessageId` is what makes an append safe: the desktop hands out message + * tails by index (`poll_session` skips `known_msg_count` entries), so a cached + * prefix that no longer matches the desktop's would silently splice two + * different transcripts together. + */ +export interface RemoteChatCacheSlice { + messages: ChatMessage[]; + lastMessageId: string; +} + +/** Storage behind [RemoteChatCache]; see `RemoteChatLocalRdbStore`. */ +export interface RemoteChatCacheStore { + init(context: Context): Promise; + loadSlice(deviceKey: string, sessionId: string): Promise; + appendMessages( + deviceKey: string, + sessionId: string, + startSeq: number, + messages: ChatMessage[], + lastMessageId: string + ): Promise; + replaceMessages( + deviceKey: string, + sessionId: string, + messages: ChatMessage[], + lastMessageId: string + ): Promise; + deleteSession(deviceKey: string, sessionId: string): Promise; + pruneSessions(deviceKey: string, keepMostRecent: number): Promise; + clearAll(): Promise; +} + +/** + * How many sessions per desktop keep a cached transcript. + * + * The list a phone actually revisits is short, and every session past that is + * disk spent on a transcript the user will re-fetch anyway. Past the budget the + * least recently opened transcript goes first, and opening counts as reading — + * so a session the user keeps coming back to outlives any number of one-off + * ones opened around it. + */ +const CACHED_SESSIONS_PER_DEVICE: number = 20; + +/** + * How many transcripts stay in memory in front of the disk ones. + * + * Three covers what the phone actually alternates between — the session being + * worked in, the one before it, and whatever was glanced at in between — and + * costs nothing on disk. Past that the read off disk is already fast enough + * that holding more would be spending memory to save nothing. + */ +const RESIDENT_SESSIONS: number = 3; + +/** + * On-disk transcripts, so reopening a session renders before the network answers. + * + * Everything here is reconstructible from the desktop, which is what lets every + * method swallow its own failures: a cache that cannot be read or written makes + * the app as slow as it was before the cache existed, and nothing worse. The + * one thing it must never do is serve a transcript that is missing messages out + * of the middle, so any doubt about a stored prefix is resolved by reporting a + * miss. + * + * Entries are scoped per desktop: `session_id` is issued by the desktop that + * owns the session, so two desktops on one account can hand out the same id for + * different conversations. + */ +export class RemoteChatCache { + private readonly store: RemoteChatCacheStore; + private readonly deviceKeyProvider: () => string; + private ready: boolean = false; + // Write cursor for the session on screen, so a poll tick costs one append + // rather than a read followed by an append. + private trackedScope: string = ''; + private trackedCount: number = 0; + private trackedLastMessageId: string = ''; + // Transcripts held in memory, most recently used last. + // + // Only ever populated from a disk read that succeeded or a disk write that + // succeeded, so a resident copy can never claim more messages than the store + // actually holds — which is what lets a resident hit set the append cursor + // instead of forcing the next sync to rewrite the session. + private readonly resident: Map = new Map(); + private residentOrder: string[] = []; + + constructor(store: RemoteChatCacheStore, deviceKeyProvider: () => string) { + this.store = store; + this.deviceKeyProvider = deviceKeyProvider; + } + + async init(context: Context): Promise { + try { + await this.store.init(context); + this.ready = true; + } catch (err) { + RemoteLogger.error(`remote chat cache unavailable: ${RemoteChatCache.reason(err)}`); + } + } + + /** The cached transcript for [sessionId], or an empty list on any miss. */ + async load(sessionId: string): Promise { + const scope = this.scope(sessionId); + if (scope.length === 0) { + return []; + } + const resident = this.resident.get(scope); + if (resident !== undefined) { + this.touchResident(scope); + this.track(scope, resident.messages.length, resident.lastMessageId); + RemoteLogger.info(`remote chat cache resident hit count=${resident.messages.length}`); + return resident.messages.slice(); + } + try { + const slice = await this.store.loadSlice(this.deviceKeyProvider(), sessionId); + this.track(scope, slice.messages.length, slice.lastMessageId); + if (slice.messages.length > 0) { + this.remember(scope, slice.messages, slice.lastMessageId); + } + return slice.messages; + } catch (err) { + this.forgetCursor(); + this.evictResident(scope); + RemoteLogger.error(`remote chat cache read failed: ${RemoteChatCache.reason(err)}`); + return []; + } + } + + /** + * Brings the stored transcript in line with what the timeline now holds. + * + * Appends when the stored rows are still a prefix of [persistedMessages] and + * rewrites the session otherwise — a poll tick adds a handful of messages to + * a transcript that can hold hundreds, and rewriting all of them every few + * hundred milliseconds is the one way this cache could cost more than it saves. + */ + async sync(sessionId: string, persistedMessages: ChatMessage[]): Promise { + const scope = this.scope(sessionId); + if (scope.length === 0 || persistedMessages.length === 0) { + return; + } + const lastMessageId = persistedMessages[persistedMessages.length - 1].id; + try { + if (this.canAppendTo(scope, persistedMessages)) { + if (persistedMessages.length === this.trackedCount) { + return; + } + await this.store.appendMessages( + this.deviceKeyProvider(), + sessionId, + this.trackedCount, + persistedMessages.slice(this.trackedCount), + lastMessageId + ); + } else { + await this.store.replaceMessages( + this.deviceKeyProvider(), + sessionId, + persistedMessages, + lastMessageId + ); + // Only a rewrite can bring a session into the cache that was not + // already there, so this is the one branch eviction has anything to do: + // a poll tick appending to a stored session leaves the count where it + // was, and paying for a prune on every tick would be pure overhead. + await this.store.pruneSessions(this.deviceKeyProvider(), CACHED_SESSIONS_PER_DEVICE); + } + this.track(scope, persistedMessages.length, lastMessageId); + this.remember(scope, persistedMessages, lastMessageId); + } catch (err) { + // Dropping the cursor turns the next sync into a rewrite, which is the + // only safe assumption once a write outcome is unknown. The resident copy + // goes with it: what it holds was true of a store this write may have + // moved out from under it. + this.forgetCursor(); + this.evictResident(scope); + RemoteLogger.error(`remote chat cache write failed: ${RemoteChatCache.reason(err)}`); + } + } + + /** Drops one session, for a delete or a transcript the desktop rewrote. */ + async forget(sessionId: string): Promise { + const scope = this.scope(sessionId); + if (scope.length === 0) { + return; + } + if (scope === this.trackedScope) { + this.forgetCursor(); + } + this.evictResident(scope); + try { + await this.store.deleteSession(this.deviceKeyProvider(), sessionId); + } catch (err) { + RemoteLogger.error(`remote chat cache evict failed: ${RemoteChatCache.reason(err)}`); + } + } + + /** + * Drops every cached transcript, for signing out. + * + * Transcripts belong to the account that was signed in; leaving them on disk + * for the next account to read is the one failure mode worth being loud about. + */ + async clear(): Promise { + this.forgetCursor(); + this.resident.clear(); + this.residentOrder = []; + if (!this.ready) { + return; + } + try { + await this.store.clearAll(); + } catch (err) { + RemoteLogger.error(`remote chat cache clear failed: ${RemoteChatCache.reason(err)}`); + } + } + + private canAppendTo(scope: string, persistedMessages: ChatMessage[]): boolean { + return this.trackedScope === scope && + this.trackedCount > 0 && + this.trackedCount <= persistedMessages.length && + persistedMessages[this.trackedCount - 1].id === this.trackedLastMessageId; + } + + private track(scope: string, count: number, lastMessageId: string): void { + this.trackedScope = scope; + this.trackedCount = count; + this.trackedLastMessageId = lastMessageId; + } + + private forgetCursor(): void { + this.trackedScope = ''; + this.trackedCount = 0; + this.trackedLastMessageId = ''; + } + + /** Takes a copy: the caller's array outlives this call and the timeline edits it. */ + private remember(scope: string, messages: ChatMessage[], lastMessageId: string): void { + this.resident.set(scope, { messages: messages.slice(), lastMessageId }); + this.touchResident(scope); + while (this.residentOrder.length > RESIDENT_SESSIONS) { + const oldest = this.residentOrder.shift(); + if (oldest !== undefined) { + this.resident.delete(oldest); + } + } + } + + private touchResident(scope: string): void { + this.residentOrder = this.residentOrder.filter((entry: string): boolean => entry !== scope); + this.residentOrder.push(scope); + } + + private evictResident(scope: string): void { + this.resident.delete(scope); + this.residentOrder = this.residentOrder.filter((entry: string): boolean => entry !== scope); + } + + /** Cache key for a session, or '' when there is nothing safe to key on. */ + private scope(sessionId: string): string { + const deviceKey = this.deviceKeyProvider().trim(); + if (!this.ready || deviceKey.length === 0 || sessionId.length === 0) { + return ''; + } + return `${deviceKey}::${sessionId}`; + } + + private static reason(err: Object): string { + return err instanceof Error ? err.message : `${err}`; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets index 4791ede63a..304422b1be 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets @@ -7,6 +7,8 @@ import { } from '../model/RemoteModels'; import { RemoteI18n } from '../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; +import { RemoteChatCache } from './RemoteChatCache'; +import { RemoteLogger } from './RemoteLogger'; export interface RemoteChatCommandClient { getSessionMessages(sessionId: string): Promise; @@ -24,6 +26,10 @@ export interface RemoteChatCommandClient { export interface RemoteChatCommandCallbacks { onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => void; onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => void; + // Raised once the transcript is on screen, which for a cached session is + // before any request has been sent. Without it the loading skeleton would + // outlive the messages it is standing in for. + onTimelineReady: () => void; onSendSucceeded: (turnId: string, pendingActiveId: string) => void; onSendFailed: ( rawText: string, @@ -34,6 +40,9 @@ export interface RemoteChatCommandCallbacks { onActiveSession: (session: SessionSummary) => void; onSessionTitleChanged: (sessionId: string, title: string) => void; onStatusText: (statusText: string) => void; + // The status line is only rendered while the link is down, so anything the + // user asked for and did not get has to be said out loud instead. + onToast: (message: string) => void; onBusy: (isBusy: boolean) => void; onPollRequested: () => void; } @@ -41,34 +50,140 @@ export interface RemoteChatCommandCallbacks { export class RemoteChatCommandController { private readonly client: RemoteChatCommandClient; private readonly callbacks: RemoteChatCommandCallbacks; + private readonly cache: RemoteChatCache; - constructor(client: RemoteChatCommandClient, callbacks: RemoteChatCommandCallbacks) { + constructor( + client: RemoteChatCommandClient, + callbacks: RemoteChatCommandCallbacks, + cache: RemoteChatCache + ) { this.client = client; this.callbacks = callbacks; + this.cache = cache; } + private static shortId(value: string): string { + if (value.length <= 10) return value; + return `${value.slice(0, 6)}...${value.slice(-4)}`; + } + + /** + * Puts a session's transcript on screen, from disk when there is one. + * + * A cache hit sends nothing at all: the poller that starts right after this + * asks for `known_msg_count` messages onwards, so whatever arrived while the + * session was closed comes back as an ordinary tail rather than as another + * copy of the whole transcript. + */ async loadMessages( sessionId: string, canApply: (sessionId: string) => boolean + ): Promise { + if (sessionId.length === 0) { + return; + } + const cached = await this.cache.load(sessionId); + if (cached.length > 0) { + if (!canApply(sessionId)) { + return; + } + RemoteLogger.info(`messages from cache session=${RemoteChatCommandController.shortId(sessionId)} count=${cached.length}`); + this.applyMessages(cached); + this.callbacks.onStatusText(RemoteI18n.t('status.messagesRestored')); + return; + } + await this.reloadMessages(sessionId, canApply); + } + + /** + * Fetches the whole transcript and makes it the cached one. + * + * Also the way out of a desktop-side rewrite: the tail protocol counts from + * the start of the transcript, so once the desktop reports fewer messages + * than the phone holds there is no offset left to resume from. + */ + async reloadMessages( + sessionId: string, + canApply: (sessionId: string) => boolean ): Promise { if (sessionId.length === 0) { return; } try { - this.callbacks.onStatusText(RemoteI18n.t('status.loadMessages')); + // Guarded like every other write below. A tap that lands while a disk read + // is still settling gets here for a session that is already off screen, + // and "正在加载消息" against someone else's transcript reads as a stall the + // visible session is not having. + if (canApply(sessionId)) { + this.callbacks.onStatusText(RemoteI18n.t('status.loadMessages')); + } const result = await this.client.getSessionMessages(sessionId); + if (canApply(sessionId)) { + this.applyMessages(result.messages); + this.callbacks.onStatusText(RemoteI18n.t('status.messagesSynced')); + } + // Stored whether or not that session is still the one on screen: the + // transcript was fetched by session id, so it is as valid for a session + // the user just switched away from as for one they stayed on. Discarding + // it would leave a session the user demonstrably opened uncached, and a + // fetch slow enough to be switched away from is exactly the one worth not + // paying for twice. + // + // Writing another session's transcript moves the cache's append cursor + // off the visible one, which costs that session a single full rewrite on + // its next sync before appends resume. + await this.cache.sync(sessionId, result.messages); + } catch (err) { + // A failure belongs to the session it was fetched for. Unguarded, a slow + // fetch the user switched away from would land its error text and its + // empty timeline on whatever session they switched to — the failure of + // the session they left, reported as the state of the one they are on. if (!canApply(sessionId)) { + RemoteLogger.info( + `stale message load dropped session=${RemoteChatCommandController.shortId(sessionId)}`); return; } - this.callbacks.onMessagesLoaded(result.messages, false); - this.callbacks.onMessageCountKnown(0, result.messages.length); - this.callbacks.onStatusText(RemoteI18n.t('status.messagesSynced')); - } catch (err) { this.callbacks.onStatusText(ConnectionErrorPolicy.errorText(err)); this.callbacks.onMessageCountKnown(0, 0); + this.callbacks.onTimelineReady(); } } + /** + * A poll version of 0 is deliberate on both paths: the desktop resets its + * tracker when it restarts, so a version carried over from a previous + * connection would match by accident and answer "nothing changed". + * + * The count handed on is the message count, which for a restored transcript + * can sit below what the desktop counts, because the timeline drops synthetic + * system entries. Erring low is the safe direction: the next poll returns a + * few messages already on screen, they merge by id, and the count corrects + * itself from the desktop's own total. + */ + private applyMessages(messages: ChatMessage[]): void { + this.callbacks.onMessagesLoaded(messages, false); + this.callbacks.onMessageCountKnown(0, messages.length); + this.callbacks.onTimelineReady(); + // The last thing that happens before ArkUI owns the delay: everything after + // this line is build, measure and paint. + RemoteLogger.info(`messages applied to state count=${messages.length}`); + } + + /** Drops a session's cached transcript once it no longer exists upstream. */ + async forgetMessages(sessionId: string): Promise { + await this.cache.forget(sessionId); + } + + /** + * Reaches back for history the timeline is missing. + * + * No desktop reachable over the relay reports one today: `get_session_messages` + * ignores its `limit`/`before_message_id` and answers with the whole + * transcript and `has_more: false`, so `hasMoreMessages` never turns true and + * this stays a fetch nothing currently asks for. It is kept whole, and kept + * writing to the cache like every other full fetch, so that the day the + * desktop does page its history this path is not the odd one out. + */ async loadOlderMessages( sessionId: string, currentPollVersion: number, @@ -85,6 +200,7 @@ export class RemoteChatCommandController { this.callbacks.onMessagesLoaded(result.messages, false); this.callbacks.onMessageCountKnown(currentPollVersion, result.messages.length); this.callbacks.onStatusText(RemoteI18n.t('status.messagesSynced')); + await this.cache.sync(sessionId, result.messages); } catch (err) { this.callbacks.onStatusText(ConnectionErrorPolicy.errorText(err)); } finally { @@ -129,20 +245,29 @@ export class RemoteChatCommandController { activeTurnId: string, remoteAvailable: boolean ): Promise { + RemoteLogger.info(`stop task requested session=${RemoteChatCommandController.shortId(sessionId)} turn=${RemoteChatCommandController.shortId(activeTurnId)} active=${activeTurnMessageId.length > 0 ? '1' : '0'}`); if (sessionId.length === 0 || !remoteAvailable) { return; } if (activeTurnMessageId.length === 0) { - this.callbacks.onStatusText(RemoteI18n.t('status.noRunningTask')); + const nothingRunning = RemoteI18n.t('status.noRunningTask'); + this.callbacks.onStatusText(nothingRunning); + this.callbacks.onToast(nothingRunning); return; } try { this.callbacks.onStatusText(RemoteI18n.t('status.stoppingTask')); await this.client.cancelTask(sessionId, activeTurnId); + RemoteLogger.info(`stop task accepted session=${RemoteChatCommandController.shortId(sessionId)} turn=${RemoteChatCommandController.shortId(activeTurnId)}`); this.callbacks.onStatusText(RemoteI18n.t('status.stopRequested')); this.callbacks.onPollRequested(); } catch (err) { - this.callbacks.onStatusText(ConnectionErrorPolicy.errorText(err)); + // A rejected cancel leaves the task running, so failing quietly here is + // indistinguishable from the stop having worked. + const errorText = ConnectionErrorPolicy.errorText(err); + RemoteLogger.info(`stop task failed session=${RemoteChatCommandController.shortId(sessionId)} turn=${RemoteChatCommandController.shortId(activeTurnId)}`); + this.callbacks.onStatusText(errorText); + this.callbacks.onToast(errorText); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets new file mode 100644 index 0000000000..dca1cbd250 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets @@ -0,0 +1,243 @@ +import { relationalStore } from '@kit.ArkData'; +import { ChatMessage } from '../model/RemoteModels'; +import { Encoding } from './Encoding'; +import { RemoteChatCacheSlice, RemoteChatCacheStore } from './RemoteChatCache'; + +const STORE_CONFIG: relationalStore.StoreConfig = { + name: 'bitfun_remote_chat.db', + // A step above the S1 the general-chat store uses: these transcripts carry + // the user's source, file paths and terminal output from a machine they own. + securityLevel: relationalStore.SecurityLevel.S2 +}; + +/** + * Bumped whenever a stored row stops meaning what it used to. + * + * A cache is reconstructible by definition, so a mismatch drops the tables and + * refills them from the desktop instead of paying for a migration. + */ +const SCHEMA_VERSION: number = 1; +const SCHEMA_VERSION_KEY: string = 'schema_version'; + +const CREATE_META_SQL = + 'CREATE TABLE IF NOT EXISTS remote_chat_meta (' + + 'key TEXT PRIMARY KEY, value TEXT NOT NULL)'; +const CREATE_SESSIONS_SQL = + 'CREATE TABLE IF NOT EXISTS remote_chat_sessions (' + + 'device_key TEXT NOT NULL, session_id TEXT NOT NULL, message_count INTEGER NOT NULL, ' + + 'last_message_id TEXT NOT NULL, updated_at INTEGER NOT NULL, ' + + 'PRIMARY KEY (device_key, session_id))'; +// `seq` is the message's index in the desktop's own list, not an arbitrary sort +// key: `poll_session` returns the tail after `known_msg_count` entries, so the +// two have to count the same things in the same order. +const CREATE_MESSAGES_SQL = + 'CREATE TABLE IF NOT EXISTS remote_chat_messages (' + + 'device_key TEXT NOT NULL, session_id TEXT NOT NULL, seq INTEGER NOT NULL, payload TEXT NOT NULL, ' + + 'PRIMARY KEY (device_key, session_id, seq))'; + +/** Remote transcripts on disk, keyed by the desktop that owns the session. */ +export class RemoteChatLocalRdbStore implements RemoteChatCacheStore { + private store?: relationalStore.RdbStore; + + async init(context: Context): Promise { + const store = await relationalStore.getRdbStore(context, STORE_CONFIG); + await store.executeSql(CREATE_META_SQL); + if (await RemoteChatLocalRdbStore.storedSchemaVersion(store) !== SCHEMA_VERSION) { + await store.executeSql('DROP TABLE IF EXISTS remote_chat_messages'); + await store.executeSql('DROP TABLE IF EXISTS remote_chat_sessions'); + await store.executeSql( + 'INSERT OR REPLACE INTO remote_chat_meta (key, value) VALUES (?, ?)', + [SCHEMA_VERSION_KEY, `${SCHEMA_VERSION}`] + ); + } + await store.executeSql(CREATE_SESSIONS_SQL); + await store.executeSql(CREATE_MESSAGES_SQL); + this.store = store; + } + + async loadSlice(deviceKey: string, sessionId: string): Promise { + const store = this.requireStore(); + let expectedCount = 0; + let lastMessageId = ''; + const header = await store.querySql( + 'SELECT message_count, last_message_id FROM remote_chat_sessions ' + + 'WHERE device_key = ? AND session_id = ?', + [deviceKey, sessionId] + ); + try { + if (header.goToNextRow()) { + expectedCount = header.getLong(header.getColumnIndex('message_count')); + lastMessageId = header.getString(header.getColumnIndex('last_message_id')); + } + } finally { + header.close(); + } + if (expectedCount <= 0) { + return { messages: [], lastMessageId: '' }; + } + + const messages: ChatMessage[] = []; + const rows = await store.querySql( + 'SELECT payload FROM remote_chat_messages ' + + 'WHERE device_key = ? AND session_id = ? AND seq < ? ORDER BY seq ASC', + [deviceKey, sessionId, expectedCount] + ); + try { + const payloadColumn = rows.getColumnIndex('payload'); + while (rows.goToNextRow()) { + messages.push(Encoding.parseJsonObject(rows.getString(payloadColumn))); + } + } finally { + rows.close(); + } + // Fewer rows than the header claims means a write was interrupted partway. + // Reporting a miss costs one fetch; reporting the rows would show the user + // a transcript with messages missing out of the middle. + if (messages.length !== expectedCount) { + return { messages: [], lastMessageId: '' }; + } + await this.touchSession(deviceKey, sessionId); + return { messages, lastMessageId }; + } + + async appendMessages( + deviceKey: string, + sessionId: string, + startSeq: number, + messages: ChatMessage[], + lastMessageId: string + ): Promise { + if (messages.length === 0) { + return; + } + const store = this.requireStore(); + // Clearing the range first makes a retried append idempotent, which is what + // lets the write run without a transaction around it. + await store.executeSql( + 'DELETE FROM remote_chat_messages WHERE device_key = ? AND session_id = ? AND seq >= ?', + [deviceKey, sessionId, startSeq] + ); + await store.batchInsert( + 'remote_chat_messages', + RemoteChatLocalRdbStore.rows(deviceKey, sessionId, startSeq, messages) + ); + await this.writeHeader(deviceKey, sessionId, startSeq + messages.length, lastMessageId); + } + + async replaceMessages( + deviceKey: string, + sessionId: string, + messages: ChatMessage[], + lastMessageId: string + ): Promise { + const store = this.requireStore(); + await store.executeSql( + 'DELETE FROM remote_chat_messages WHERE device_key = ? AND session_id = ?', + [deviceKey, sessionId] + ); + if (messages.length > 0) { + await store.batchInsert( + 'remote_chat_messages', + RemoteChatLocalRdbStore.rows(deviceKey, sessionId, 0, messages) + ); + } + await this.writeHeader(deviceKey, sessionId, messages.length, lastMessageId); + } + + async deleteSession(deviceKey: string, sessionId: string): Promise { + const store = this.requireStore(); + await store.executeSql( + 'DELETE FROM remote_chat_messages WHERE device_key = ? AND session_id = ?', + [deviceKey, sessionId] + ); + await store.executeSql( + 'DELETE FROM remote_chat_sessions WHERE device_key = ? AND session_id = ?', + [deviceKey, sessionId] + ); + } + + async pruneSessions(deviceKey: string, keepMostRecent: number): Promise { + const store = this.requireStore(); + const stale = + 'SELECT session_id FROM remote_chat_sessions WHERE device_key = ? ' + + 'ORDER BY updated_at DESC LIMIT -1 OFFSET ?'; + await store.executeSql( + `DELETE FROM remote_chat_messages WHERE device_key = ? AND session_id IN (${stale})`, + [deviceKey, deviceKey, keepMostRecent] + ); + await store.executeSql( + `DELETE FROM remote_chat_sessions WHERE device_key = ? AND session_id IN (${stale})`, + [deviceKey, deviceKey, keepMostRecent] + ); + } + + async clearAll(): Promise { + const store = this.requireStore(); + await store.executeSql('DELETE FROM remote_chat_messages'); + await store.executeSql('DELETE FROM remote_chat_sessions'); + } + + /** Keeps [pruneSessions] evicting the sessions the user stopped opening. */ + private async touchSession(deviceKey: string, sessionId: string): Promise { + await this.requireStore().executeSql( + 'UPDATE remote_chat_sessions SET updated_at = ? WHERE device_key = ? AND session_id = ?', + [Date.now(), deviceKey, sessionId] + ); + } + + private async writeHeader( + deviceKey: string, + sessionId: string, + messageCount: number, + lastMessageId: string + ): Promise { + const values: relationalStore.ValuesBucket = { + device_key: deviceKey, + session_id: sessionId, + message_count: messageCount, + last_message_id: lastMessageId, + updated_at: Date.now() + }; + await this.requireStore().insert( + 'remote_chat_sessions', + values, + relationalStore.ConflictResolution.ON_CONFLICT_REPLACE + ); + } + + private static rows( + deviceKey: string, + sessionId: string, + startSeq: number, + messages: ChatMessage[] + ): relationalStore.ValuesBucket[] { + return messages.map((message: ChatMessage, index: number): relationalStore.ValuesBucket => ({ + device_key: deviceKey, + session_id: sessionId, + seq: startSeq + index, + payload: JSON.stringify(message) + })); + } + + private static async storedSchemaVersion(store: relationalStore.RdbStore): Promise { + const result = await store.querySql( + 'SELECT value FROM remote_chat_meta WHERE key = ?', + [SCHEMA_VERSION_KEY] + ); + try { + if (!result.goToNextRow()) { + return 0; + } + return Number.parseInt(result.getString(result.getColumnIndex('value')), 10) || 0; + } finally { + result.close(); + } + } + + private requireStore(): relationalStore.RdbStore { + if (!this.store) { + throw new Error('Remote chat local store is not initialized.'); + } + return this.store; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteHeartbeatController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteHeartbeatController.ets index e71d644cea..4c306c83af 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteHeartbeatController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteHeartbeatController.ets @@ -14,13 +14,14 @@ class SystemRemoteHeartbeatScheduler implements RemoteHeartbeatScheduler { } export class RemoteHeartbeatController { - private readonly onTick: () => void; + private readonly onTick: () => Promise; private readonly intervalMs: number; private readonly scheduler: RemoteHeartbeatScheduler; private timerId: number = 0; + private inFlight: boolean = false; constructor( - onTick: () => void, + onTick: () => Promise, intervalMs: number = 15000, scheduler: RemoteHeartbeatScheduler = new SystemRemoteHeartbeatScheduler() ) { @@ -32,10 +33,30 @@ export class RemoteHeartbeatController { start(): void { this.stop(); this.timerId = this.scheduler.setInterval(() => { - this.onTick(); + void this.runTick(); }, this.intervalMs); } + /** + * A tick that arrives while the previous one is still out is dropped, not + * queued. The interval counts wall-clock time, but a heartbeat can outlive + * many intervals: a desktop that has stopped answering holds every request + * until the relay's own timeout, two minutes later. Starting one more each + * interval turns a single unanswered request into a pile of them aimed at the + * desktop that is already not answering. + */ + private async runTick(): Promise { + if (this.inFlight) { + return; + } + this.inFlight = true; + try { + await this.onTick(); + } finally { + this.inFlight = false; + } + } + stop(): void { if (this.timerId !== 0) { this.scheduler.clearInterval(this.timerId); 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 8bbcd94eff..79d01a5ac0 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 @@ -6,6 +6,7 @@ import { } from '../model/RemoteModels'; import { RemoteI18n } from '../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; +import { RemoteLogger } from './RemoteLogger'; import { RemoteUiState } from './RemoteUiState'; export interface RemoteSessionClient { @@ -34,6 +35,7 @@ export class RemoteSessionController { private sessions: RemoteSession[] = []; private hasMore: boolean = false; private offset: number = 0; + private openGeneration: number = 0; constructor( client: RemoteSessionClient, @@ -45,6 +47,13 @@ export class RemoteSessionController { this.callbacks = callbacks; } + private static shortId(value: string): string { + if (value.length <= 10) { + return value; + } + return `${value.slice(0, 6)}...${value.slice(-4)}`; + } + setSessions(sessions: RemoteSession[], hasMore: boolean): void { this.sessions = sessions.slice(); this.hasMore = hasMore; @@ -132,9 +141,9 @@ export class RemoteSessionController { onCreated: (session: SessionSummary) => Promise, instruction: string = '', modelId: string = '' - ): Promise { + ): Promise { if (isBusy || !remoteAvailable) { - return; + return undefined; } try { this.callbacks.onBusy(true); @@ -148,8 +157,10 @@ export class RemoteSessionController { this.callbacks.onActiveSession(session); this.callbacks.onStatusText(RemoteI18n.t('status.sessionCreated')); await onCreated(session); + return session; } catch (err) { this.callbacks.onStatusText(ConnectionErrorPolicy.errorText(err)); + return undefined; } finally { this.callbacks.onBusy(false); } @@ -163,8 +174,15 @@ export class RemoteSessionController { onOpened: (session: SessionSummary) => Promise ): Promise { if (isBusy || item.id.length === 0 || !remoteAvailable) { + RemoteLogger.info(`open session dropped session=${RemoteSessionController.shortId(item.id)} busy=${isBusy ? '1' : '0'} available=${remoteAvailable ? '1' : '0'}`); return; } + // Opens supersede each other, so the one that finishes first is not + // necessarily the one still on screen. Lowering the busy flag from that one + // would tell the composer the session it is attached to had finished + // loading while the newer open was still fetching. + this.openGeneration += 1; + const generation = this.openGeneration; try { this.callbacks.onBusy(true); this.callbacks.onStatusText(RemoteI18n.t('status.loadMessages')); @@ -177,7 +195,9 @@ export class RemoteSessionController { this.callbacks.onActiveSession(session); await onOpened(session); } finally { - this.callbacks.onBusy(false); + if (generation === this.openGeneration) { + this.callbacks.onBusy(false); + } } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListCache.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListCache.ets new file mode 100644 index 0000000000..662d9f75a3 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListCache.ets @@ -0,0 +1,169 @@ +import { RemoteSession } from '../model/RemoteModels'; +import { RemoteLogger } from './RemoteLogger'; + +/** A device's session list as it was last shown. */ +export interface RemoteSessionListSlice { + sessions: RemoteSession[]; + hasMore: boolean; +} + +/** Storage behind [RemoteSessionListCache]; see `RemoteSessionListRdbStore`. */ +export interface RemoteSessionListStore { + init(context: Context): Promise; + loadList(deviceKey: string): Promise; + /** The list for whichever device was written last, for a cold start. */ + loadLastList(): Promise; + saveList(deviceKey: string, sessions: RemoteSession[], hasMore: boolean): Promise; + clearAll(): Promise; +} + +/** + * How many sessions per desktop are worth keeping. + * + * The list exists to fill the first screen while the connection settles, and + * the desktop replaces it wholesale a moment later, so storing past what the + * user can scroll to in that moment buys nothing. + */ +const CACHED_SESSIONS_PER_DEVICE: number = 60; + +/** + * The session list on disk, so Remote Home has something to show before the + * desktop answers. + * + * Same bargain as `RemoteChatCache`: every failure is swallowed, because a + * cache that cannot be read leaves the app exactly as slow as it was without + * one. What is served here is a snapshot that may already be stale — titles + * change, sessions get deleted — which is safe only because opening a session + * goes through the desktop anyway and the live list overwrites this one as soon + * as the connection is up. + */ +export class RemoteSessionListCache { + private readonly store: RemoteSessionListStore; + private readonly deviceKeyProvider: () => string; + private ready: boolean = false; + // Signature of the last list written, so the repeated `onSessions` callbacks + // behind one refresh cost one write rather than one write each. + private writtenScope: string = ''; + private writtenSignature: string = ''; + + constructor(store: RemoteSessionListStore, deviceKeyProvider: () => string) { + this.store = store; + this.deviceKeyProvider = deviceKeyProvider; + } + + async init(context: Context): Promise { + try { + await this.store.init(context); + this.ready = true; + } catch (err) { + RemoteLogger.error(`remote session list cache unavailable: ${RemoteSessionListCache.reason(err)}`); + } + } + + /** + * The list for the device the phone last talked to. + * + * Used at startup, where the device to key on is not known yet: identity is + * still being restored and the auto-reconnect that follows targets exactly + * the device this list came from. + */ + async restoreLast(): Promise { + if (!this.ready) { + return RemoteSessionListCache.empty(); + } + try { + return await this.store.loadLastList(); + } catch (err) { + RemoteLogger.error(`remote session list cache read failed: ${RemoteSessionListCache.reason(err)}`); + return RemoteSessionListCache.empty(); + } + } + + /** The list stored for the device currently being controlled. */ + async load(): Promise { + const deviceKey = this.scope(); + if (deviceKey.length === 0) { + return RemoteSessionListCache.empty(); + } + try { + return await this.store.loadList(deviceKey); + } catch (err) { + RemoteLogger.error(`remote session list cache read failed: ${RemoteSessionListCache.reason(err)}`); + return RemoteSessionListCache.empty(); + } + } + + /** + * Stores the list now on screen. + * + * An empty list is never written: disconnecting clears the list through the + * same path a real refresh takes, and a user who steps away from a desktop + * should still find their sessions there on the next launch. + */ + async save(sessions: RemoteSession[], hasMore: boolean): Promise { + const deviceKey = this.scope(); + if (deviceKey.length === 0 || sessions.length === 0) { + return; + } + const kept = sessions.slice(0, CACHED_SESSIONS_PER_DEVICE); + const keptHasMore = hasMore || kept.length < sessions.length; + const signature = RemoteSessionListCache.signature(kept, keptHasMore); + if (this.writtenScope === deviceKey && this.writtenSignature === signature) { + return; + } + try { + await this.store.saveList(deviceKey, kept, keptHasMore); + this.writtenScope = deviceKey; + this.writtenSignature = signature; + } catch (err) { + this.forgetSignature(); + RemoteLogger.error(`remote session list cache write failed: ${RemoteSessionListCache.reason(err)}`); + } + } + + /** Drops every stored list, for signing out. */ + async clear(): Promise { + this.forgetSignature(); + if (!this.ready) { + return; + } + try { + await this.store.clearAll(); + } catch (err) { + RemoteLogger.error(`remote session list cache clear failed: ${RemoteSessionListCache.reason(err)}`); + } + } + + private forgetSignature(): void { + this.writtenScope = ''; + this.writtenSignature = ''; + } + + /** The device to key on, or '' when there is nothing safe to key on. */ + private scope(): string { + const deviceKey = this.deviceKeyProvider().trim(); + if (!this.ready || deviceKey.length === 0) { + return ''; + } + return deviceKey; + } + + /** + * Everything the list renders from, so a stored list is rewritten when a + * title changes but not when the same list is published twice. + */ + private static signature(sessions: RemoteSession[], hasMore: boolean): string { + const parts = sessions.map((item: RemoteSession): string => { + return `${item.id}${item.title}${item.status}${item.updatedAt}${item.messageCount}`; + }); + return `${hasMore ? '1' : '0'}${parts.join('')}`; + } + + private static empty(): RemoteSessionListSlice { + return { sessions: [], hasMore: false }; + } + + private static reason(err: Object): string { + return err instanceof Error ? err.message : `${err}`; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListRdbStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListRdbStore.ets new file mode 100644 index 0000000000..0a7a302861 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListRdbStore.ets @@ -0,0 +1,126 @@ +import { relationalStore } from '@kit.ArkData'; +import { RemoteSession } from '../model/RemoteModels'; +import { Encoding } from './Encoding'; +import { RemoteSessionListSlice, RemoteSessionListStore } from './RemoteSessionListCache'; + +const STORE_CONFIG: relationalStore.StoreConfig = { + name: 'bitfun_remote_sessions.db', + // Session titles are as revealing as the transcripts themselves, so this + // matches the level the transcript store uses. + securityLevel: relationalStore.SecurityLevel.S2 +}; + +/** Bumped whenever a stored row stops meaning what it used to; see the chat store. */ +const SCHEMA_VERSION: number = 1; +const SCHEMA_VERSION_KEY: string = 'schema_version'; +const LAST_DEVICE_KEY: string = 'last_device_key'; + +const CREATE_META_SQL = + 'CREATE TABLE IF NOT EXISTS remote_session_meta (' + + 'key TEXT PRIMARY KEY, value TEXT NOT NULL)'; +// One row per device: the list is small, is always replaced as a whole, and is +// only ever read in full, so there is nothing for a per-session row to buy. +const CREATE_LIST_SQL = + 'CREATE TABLE IF NOT EXISTS remote_session_list (' + + 'device_key TEXT PRIMARY KEY, payload TEXT NOT NULL, has_more INTEGER NOT NULL, ' + + 'updated_at INTEGER NOT NULL)'; + +/** Session lists on disk, keyed by the desktop that owns them. */ +export class RemoteSessionListRdbStore implements RemoteSessionListStore { + private store?: relationalStore.RdbStore; + + async init(context: Context): Promise { + const store = await relationalStore.getRdbStore(context, STORE_CONFIG); + await store.executeSql(CREATE_META_SQL); + if (await RemoteSessionListRdbStore.storedValue(store, SCHEMA_VERSION_KEY) !== `${SCHEMA_VERSION}`) { + await store.executeSql('DROP TABLE IF EXISTS remote_session_list'); + await store.executeSql( + 'DELETE FROM remote_session_meta WHERE key = ?', + [LAST_DEVICE_KEY] + ); + await store.executeSql( + 'INSERT OR REPLACE INTO remote_session_meta (key, value) VALUES (?, ?)', + [SCHEMA_VERSION_KEY, `${SCHEMA_VERSION}`] + ); + } + await store.executeSql(CREATE_LIST_SQL); + this.store = store; + } + + async loadList(deviceKey: string): Promise { + if (deviceKey.length === 0) { + return RemoteSessionListRdbStore.empty(); + } + const rows = await this.requireStore().querySql( + 'SELECT payload, has_more FROM remote_session_list WHERE device_key = ?', + [deviceKey] + ); + try { + if (!rows.goToNextRow()) { + return RemoteSessionListRdbStore.empty(); + } + return { + sessions: Encoding.parseJsonObject(rows.getString(rows.getColumnIndex('payload'))), + hasMore: rows.getLong(rows.getColumnIndex('has_more')) === 1 + }; + } finally { + rows.close(); + } + } + + async loadLastList(): Promise { + const deviceKey = await RemoteSessionListRdbStore.storedValue(this.requireStore(), LAST_DEVICE_KEY); + return await this.loadList(deviceKey); + } + + async saveList(deviceKey: string, sessions: RemoteSession[], hasMore: boolean): Promise { + if (deviceKey.length === 0) { + return; + } + const store = this.requireStore(); + const values: relationalStore.ValuesBucket = { + device_key: deviceKey, + payload: JSON.stringify(sessions), + has_more: hasMore ? 1 : 0, + updated_at: Date.now() + }; + await store.insert('remote_session_list', values, relationalStore.ConflictResolution.ON_CONFLICT_REPLACE); + // Written after the list, so a pointer never names a row that is not there. + await store.executeSql( + 'INSERT OR REPLACE INTO remote_session_meta (key, value) VALUES (?, ?)', + [LAST_DEVICE_KEY, deviceKey] + ); + } + + async clearAll(): Promise { + const store = this.requireStore(); + await store.executeSql('DELETE FROM remote_session_list'); + await store.executeSql('DELETE FROM remote_session_meta WHERE key = ?', [LAST_DEVICE_KEY]); + } + + private static async storedValue(store: relationalStore.RdbStore, key: string): Promise { + const rows = await store.querySql( + 'SELECT value FROM remote_session_meta WHERE key = ?', + [key] + ); + try { + if (!rows.goToNextRow()) { + return ''; + } + return rows.getString(rows.getColumnIndex('value')); + } finally { + rows.close(); + } + } + + private static empty(): RemoteSessionListSlice { + return { sessions: [], hasMore: false }; + } + + private requireStore(): relationalStore.RdbStore { + if (!this.store) { + throw new Error('Remote session list local store is not initialized.'); + } + return this.store; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets index 0112cf3101..de4ddd166e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteToolActionController.ets @@ -1,6 +1,7 @@ import { RemoteQuestionAnswerPayload } from '../model/RemoteModels'; import { RemoteI18n } from '../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; +import { RemoteLogger } from './RemoteLogger'; export interface RemoteToolActionClient { confirmTool(toolId: string, updatedInput?: Object): Promise; @@ -59,6 +60,7 @@ export class RemoteToolActionController { } async cancel(toolId: string, sessionId: string, remoteAvailable: boolean): Promise { + RemoteLogger.info(`tool cancel requested tool=${RemoteToolActionController.shortId(toolId)} session=${RemoteToolActionController.shortId(sessionId)}`); await this.runToolAction( toolId, sessionId, @@ -77,6 +79,7 @@ export class RemoteToolActionController { remoteAvailable: boolean, answers: RemoteQuestionAnswerPayload ): Promise { + RemoteLogger.info(`question answer requested tool=${RemoteToolActionController.shortId(toolId)} session=${RemoteToolActionController.shortId(sessionId)} fields=${Object.keys(answers).length}`); await this.runToolAction( toolId, sessionId, @@ -104,6 +107,7 @@ export class RemoteToolActionController { this.onBusy(true); this.onStatusText(pendingText); await action(); + RemoteLogger.info(`tool action accepted tool=${RemoteToolActionController.shortId(toolId)}`); this.onStatusText(successText); this.onPoll(); } catch (err) { @@ -112,4 +116,9 @@ export class RemoteToolActionController { this.onBusy(false); } } + + private static shortId(value: string): string { + if (value.length <= 10) return value; + return `${value.slice(0, 6)}...${value.slice(-4)}`; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets index b0cf3d2c5c..f6244b3c4c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets @@ -2,6 +2,18 @@ import { AssistantEntry, RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } fr import { RemoteWorkspaceDataSource } from './RemoteWorkspaceRepository'; import { RemoteLogger } from './RemoteLogger'; +/** + * How many workspaces are listed at once during cross-workspace discovery. + * + * Each listing is one round trip to the desktop over the relay, and a phone + * that has been used for a while has twenty-odd recent workspaces: done one + * after another that is five seconds of the session list rearranging itself + * behind the user. Kept small because these run alongside the polling that + * drives an open conversation, and a burst wide enough to crowd it out would + * trade a slow list for a slow chat. + */ +const CONCURRENT_WORKSPACE_LISTINGS: number = 4; + /** Coordinates workspace/assistant selection and cross-workspace session discovery. */ export class RemoteWorkspaceCoordinator { private readonly repository: RemoteWorkspaceDataSource; @@ -26,20 +38,39 @@ export class RemoteWorkspaceCoordinator { return await this.repository.setAssistant(path); } + /** + * Every session the given workspaces know about, first occurrence winning. + * + * Listings run a batch at a time rather than all at once or one at a time, + * and results are merged in the order the paths were given, so widening the + * batch changes how long this takes and nothing about what it returns. + */ async sessionsForWorkspaces(paths: string[]): Promise { const all: RemoteSession[] = []; - for (const path of paths) { - try { - const sessions = await this.repository.listSessionsForWorkspace(path, 50); + for (let start = 0; start < paths.length; start += CONCURRENT_WORKSPACE_LISTINGS) { + const batch: string[] = paths.slice(start, start + CONCURRENT_WORKSPACE_LISTINGS); + const listings: RemoteSession[][] = await Promise.all( + batch.map((path: string): Promise => this.sessionsForWorkspace(path)) + ); + listings.forEach((sessions: RemoteSession[]) => { sessions.forEach((item: RemoteSession) => { if (!all.some((existing: RemoteSession) => existing.id === item.id)) { all.push(item); } }); - } catch (err) { - RemoteLogger.warn(`load workspace sessions failed path=${path}: ${String(err)}`); - } + }); } return all; } + + /** One workspace's sessions, or none when that workspace cannot be read. */ + private async sessionsForWorkspace(path: string): Promise { + try { + return await this.repository.listSessionsForWorkspace(path, 50); + } catch (err) { + // One unreadable workspace is not a reason to lose the other twenty. + RemoteLogger.warn(`load workspace sessions failed path=${path}: ${String(err)}`); + return []; + } + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchHandoffStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchHandoffStore.ets index 1595d747ef..4018f84beb 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchHandoffStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchHandoffStore.ets @@ -1,12 +1,20 @@ import distributedKVStore from '@ohos.data.distributedKVStore'; import { common } from '@kit.AbilityKit'; +import { distributedDeviceManager } from '@kit.DistributedServiceKit'; import { RemoteLogger } from './RemoteLogger'; import { WATCH_PROVISION_REQUEST_KEY, WATCH_PROVISION_RESPONSE_KEY } from './WatchProvisionProtocol'; -const STORE_ID: string = 'bitfun_harmony_handoff'; +/** + * Versioned because a store's `securityLevel` is fixed at creation: it can only + * ever be raised, and for a store that syncs across devices it cannot be changed + * at all. `bitfun_harmony_handoff` was created S2 on both devices and had to be + * abandoned rather than relabelled — see the S1 note on `securityLevel` below. + * Must stay identical to the watch's `DistributedHandoffStore`. + */ +const STORE_ID: string = 'bitfun_harmony_handoff_s1'; export type WatchProvisionRequestHandler = (payload: string) => void; @@ -16,7 +24,13 @@ export type WatchProvisionRequestHandler = (payload: string) => void; * `distributedKVStore` isolates stores by bundle name *and* store id and only * replicates between installs of the same app on the same account, which is * why the phone carries the watch's bundle name (`com.bitfun.app`) rather than - * a store id of its own. The store, the id and the option set below all have + * a store id of its own. + * + * The isolation key is really bundle name + **AppID**, and AppID is derived from + * the signing certificate — so this project and the watch's must also be built + * with the same signing material. Two DevEco-generated debug identities under + * one bundle name produce two stores that never see each other, with no error on + * either side. The store, the id and the option set below all have * to match the watch's `DistributedHandoffStore` exactly or the two apps end * up with private stores that never see each other's writes. */ @@ -25,6 +39,8 @@ export class WatchHandoffStore { private kvManager?: distributedKVStore.KVManager; private kvStore?: distributedKVStore.SingleKVStore; private listener?: (change: distributedKVStore.ChangeNotification) => void; + private syncListener?: (results: Array<[string, number]>) => void; + private deviceManager?: distributedDeviceManager.DeviceManager; constructor(context: common.UIAbilityContext) { this.context = context; @@ -37,13 +53,18 @@ export class WatchHandoffStore { entries.forEach((entry: distributedKVStore.Entry) => { if (entry.key === WATCH_PROVISION_REQUEST_KEY && entry.value.type === distributedKVStore.ValueType.STRING) { + RemoteLogger.info('watch provision request arrived over KV'); handler(String(entry.value.value)); } }); }; store.on('dataChange', distributedKVStore.SubscribeType.SUBSCRIBE_TYPE_ALL, this.listener); // A watch that asked while the phone app was closed gets picked up here; - // the request's own age decides whether it is still worth acting on. + // the request's own age decides whether it is still worth acting on. The + // pull first: the watch pushes on write, but a write that happened while + // this app was not running was pushed at a replica that was not listening, + // and only an explicit pull goes back for it. + this.sync(store, distributedKVStore.SyncMode.PULL_ONLY); try { const value = await store.get(WATCH_PROVISION_REQUEST_KEY); if (typeof value === 'string' && value.trim().length > 0) { @@ -57,17 +78,78 @@ export class WatchHandoffStore { async writeResponse(payload: string): Promise { const store = await this.getStore(); await store.put(WATCH_PROVISION_RESPONSE_KEY, payload); + // The watch is waiting on a foreground screen that blanks after nine + // seconds; `autoSync`'s own schedule is far too slack to catch that window. + this.sync(store, distributedKVStore.SyncMode.PUSH_ONLY); + } + + /** + * `autoSync` is a hint, not a guarantee — it batches on the platform's + * schedule, which on this pair can be never. Both halves of the handoff push + * their own writes explicitly, so a missing answer means a missing answer + * rather than a sync that was still pending. + */ + private sync(store: distributedKVStore.SingleKVStore, mode: distributedKVStore.SyncMode): void { + const peers = this.peerNetworkIds(); + if (peers.length === 0) { + RemoteLogger.warn('watch handoff: no trusted device online to sync with'); + return; + } + try { + store.sync(peers, mode, 0); + RemoteLogger.info(`watch handoff sync requested mode=${mode} peers=${peers.length}`); + } catch (err) { + RemoteLogger.warn(`watch handoff sync failed: ${WatchHandoffStore.errorText(err)}`); + } + } + + /** + * Network ids of the trusted devices currently online. `sync` addresses + * devices by network id, which is per-session, so this is resolved per call + * rather than cached. + */ + private peerNetworkIds(): string[] { + try { + if (!this.deviceManager) { + this.deviceManager = distributedDeviceManager.createDeviceManager( + this.context.abilityInfo.bundleName); + } + const ids: string[] = []; + this.deviceManager.getAvailableDeviceListSync() + .forEach((device: distributedDeviceManager.DeviceBasicInfo) => { + const networkId = device.networkId; + if (networkId && networkId.length > 0) { + ids.push(networkId); + } + }); + return ids; + } catch (err) { + RemoteLogger.warn(`watch handoff device list failed: ${WatchHandoffStore.errorText(err)}`); + return []; + } } stop(): void { - if (this.kvStore && this.listener) { + if (this.kvStore) { try { - this.kvStore.off('dataChange', this.listener); + if (this.listener) { + this.kvStore.off('dataChange', this.listener); + } + // By reference, never bare: an argument-less `off` drops every ArkTS + // instance's listener on this store, not just this one's. + if (this.syncListener) { + this.kvStore.off('syncComplete', this.syncListener); + } } catch (err) { RemoteLogger.warn(`watch handoff unsubscribe failed: ${WatchHandoffStore.errorText(err)}`); } } this.listener = undefined; + this.syncListener = undefined; + if (this.deviceManager) { + distributedDeviceManager.releaseDeviceManager(this.deviceManager); + this.deviceManager = undefined; + } } private async getStore(): Promise { @@ -86,10 +168,30 @@ export class WatchHandoffStore { backup: false, autoSync: true, kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION, - securityLevel: distributedKVStore.SecurityLevel.S2 + // S1, and it has to be: a store only replicates to a device whose security + // level is at least its data label, and a watch is an SL1 device — SL1 + // accepts S1 and nothing else. At S2 the sync was refused by policy before + // it was ever attempted, which is indistinguishable from a dead link: no + // `dataChange` here, and an empty RecentError on both devices. + // + // S1 is honest for what actually rides this store rather than a shortcut + // around the rule. The master key is never here in the clear — the request + // carries the watch's ephemeral public key, and the response carries a + // blob sealed to it that only the watch can open. If plaintext credentials + // needed to cross, the answer would be a second store, not a lower label. + securityLevel: distributedKVStore.SecurityLevel.S1 }; this.kvStore = await this.kvManager.getKVStore(STORE_ID, options); await this.kvStore.enableSync(true); + // The only place a refused or failed replication reports itself. Without it + // a sync the platform dropped is indistinguishable from a watch that never + // asked. + this.syncListener = (results: Array<[string, number]>): void => { + results.forEach((result: [string, number]) => { + RemoteLogger.info(`watch handoff sync complete device=${result[0].substring(0, 8)} code=${result[1]}`); + }); + }; + this.kvStore.on('syncComplete', this.syncListener); return this.kvStore; } 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 1f4ee28a89..4a15a8f04e 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 @@ -1,6 +1,5 @@ import { abilityAccessCtrl, common, Context, Permissions } from '@kit.AbilityKit'; import { RemoteI18n } from '../i18n/RemoteI18n'; -import { PeerDeviceProvisionOutcome } from './RelayHttpClient'; import { RemoteLogger } from './RemoteLogger'; import { WatchHandoffStore } from './WatchHandoffStore'; import { WatchProvisionCrypto } from './WatchProvisionCrypto'; @@ -13,12 +12,36 @@ import { WatchProvisionState } from '../pages/state/WatchProvisionState'; const DATASYNC_PERMISSION: Permissions = 'ohos.permission.DISTRIBUTED_DATASYNC'; +/** + * A minting attempt's result, whoever performed it. + * + * `relayUrl` travels with the credential rather than being asked for + * separately, because the two are only valid together: a token minted by this + * phone's own account authenticates against the account's relay, one minted by + * a paired desktop against the room's. Reporting them from two places is how + * they would come to disagree. + */ +export interface WatchProvisionOutcome { + ok: boolean; + relayUrl: string; + token: string; + userId: string; + masterKeyBase64: string; + deviceId: string; + failure: string; + /** True when a desktop answered and refused, so `failure` is worth showing. */ + desktopReported: boolean; +} + /** What the controller needs from the remote stack, kept narrow for testing. */ export interface WatchProvisionPort { - /** True when a QR-paired desktop room is live; provisioning needs one. */ - canProvision(): boolean; - provision(deviceId: string, deviceName: string, requestId: string): Promise; - relayUrl(): string; + /** True when some path to a credential exists — account or paired desktop. */ + readonly canProvision: () => boolean; + readonly provision: ( + deviceId: string, + deviceName: string, + requestId: string + ) => Promise; } /** @@ -112,13 +135,21 @@ export class WatchProvisionController { if (this.pending && this.pending.requestId === request.requestId) { return; } - if (this.inFlight || this.pending) { + if (this.inFlight || (this.pending && this.pending.deviceId !== request.deviceId)) { // One at a time: a second watch waiting behind a silent card would look // identical to a phone that never heard it. this.answered.add(request.requestId); void this.writeError(request.requestId, RemoteI18n.t('watchProvision.busy')); return; } + if (this.pending) { + // The same watch asking again: it gave up on the last attempt and started + // over, and the card still up here is for a request nobody is waiting on. + // Replace it — telling a watch it is busy with itself is the one answer + // that cannot be true. + RemoteLogger.info('watch provisioning request replaced by a newer one from the same watch'); + this.answered.add(this.pending.requestId); + } this.pending = request; this.state.ask(request.deviceName, request.deviceId); } @@ -167,7 +198,7 @@ export class WatchProvisionController { await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.noDesktop')); return; } - let outcome: PeerDeviceProvisionOutcome; + let outcome: WatchProvisionOutcome; try { outcome = await this.port.provision(request.deviceId, request.deviceName, request.requestId); } catch (err) { @@ -185,8 +216,15 @@ export class WatchProvisionController { return; } + if (outcome.relayUrl.trim().length === 0) { + // A credential with no relay to present it at is unusable, and the watch + // would only discover that after signing in. Stop here instead. + RemoteLogger.error('watch provisioning produced a credential without a relay url'); + await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.handoffFailed')); + return; + } const credential: WatchProvisionCredential = { - relay_url: this.port.relayUrl(), + relay_url: outcome.relayUrl, token: outcome.token, user_id: outcome.userId, master_key: outcome.masterKeyBase64, 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 4161ebe9fd..1f982f5755 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets @@ -93,6 +93,19 @@ export default function architectureUnitTest() { expect(shell.navigationStack.getAllPathName().length).assertEqual(1); }); + // Switching sessions is a state change, not a navigation. Treating the + // session id as part of the destination made every row tap clear the stack + // and push a fresh one, and on the wide layout the master pane lives inside + // that destination — so the whole sidebar was rebuilt on each tap. + it('keeps a session switch from rebuilding the chat destination', 0, () => { + const shell = new AppShellViewModel(); + shell.pushRoute(AppRoute.RemoteHome); + shell.pushRoute(AppRoute.RemoteChat, 'session-a'); + shell.replaceRouteWithoutAnimation(AppRoute.RemoteChat, 'session-b'); + expect(shell.currentRoute()).assertEqual(AppRoute.RemoteChat); + expect(shell.navigationStack.getAllPathName().length).assertEqual(2); + }); + it('keeps wide layout geometry pure and deterministic', 0, () => { expect(WideLayoutGeometry.detailOffset(false, 24, 8)).assertEqual(24); expect(WideLayoutGeometry.detailOffset(true, 24, 8)).assertEqual(8); 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 57de0bb8df..b941f9c53c 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -313,6 +313,61 @@ export default function conversationStateUnitTest() { expect(state.activeTurn ? state.activeTurn.id : '').assertEqual(''); }); + it('never offers a placeholder id as the turn to cancel', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-stop'); + store.appendOptimisticMessage(chatMessage('local-1', 'user', 'Run it')); + const pendingActiveId = store.setPendingActiveTurn('local-1'); + const pendingTurn = store.snapshot().activeTurn; + + // The placeholder is what the user sees while the desktop has not yet + // reported a turn — asking to cancel `pending-local-1` would be refused + // as stale, so this window must resolve to "cancel whatever is running". + expect(pendingActiveId).assertEqual('active-pending-local-1'); + expect(ChatTimelineStore.cancelableTurnId(pendingTurn!)).assertEqual(''); + + store.setLocalActiveTurn('turn-9'); + expect(ChatTimelineStore.cancelableTurnId(store.snapshot().activeTurn!)).assertEqual('turn-9'); + }); + + it('keeps a message sent mid-run below the reply it is waiting on', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-queue'); + // The turn the desktop is already running: its own user message came back + // persisted, which is what leaves nothing for the turn to sit behind. + store.mergePersistedMessages([chatMessage('user-1', 'user', 'First')]); + store.setLocalActiveTurn('turn-1'); + store.appendOptimisticMessage(chatMessage('local-2', 'user', 'Second')); + + const items = store.project(false); + expect(items.length).assertEqual(3); + expect(items[0].type).assertEqual('user_message'); + expect(items[1].type).assertEqual('assistant_live_turn'); + expect(items[2].type).assertEqual('optimistic_user_message'); + expect(items[2].message ? items[2].message!.id : '').assertEqual('local-2'); + }); + + it('still draws the first reply under the message that started it', 0, () => { + const store = new ChatTimelineStore(); + store.reset('session-first'); + store.appendOptimisticMessage(chatMessage('local-1', 'user', 'First')); + store.setPendingActiveTurn('local-1'); + + // Nothing has been persisted yet, so without the anchor the placeholder + // would render above the message the user just sent. + expect(store.activeTurnAnchorId()).assertEqual('local-1'); + const pending = store.project(false); + expect(pending[0].type).assertEqual('optimistic_user_message'); + expect(pending[1].type).assertEqual('assistant_live_turn'); + + // Promoting the placeholder to the real turn must not lose the anchor. + store.setLocalActiveTurn('turn-1'); + expect(store.activeTurnAnchorId()).assertEqual('local-1'); + const promoted = store.project(false); + expect(promoted[0].type).assertEqual('optimistic_user_message'); + expect(promoted[1].type).assertEqual('assistant_live_turn'); + }); + it('projects empty state through the store', 0, () => { const store = new ChatTimelineStore(); store.reset('session-empty'); @@ -1031,7 +1086,8 @@ export default function conversationStateUnitTest() { const remoteProjection = ConversationViewState.project(AppRoute.RemoteChat, remote, general, ''); expect(remoteProjection.surface).assertEqual(ChatSurface.Remote); expect(remoteProjection.chatInput).assertEqual('remote draft'); - expect(remoteProjection.activeSession.sessionId).assertEqual('remote-session'); + expect(remoteProjection.activeSession.sessionId).assertEqual(''); + expect(remote.activeSession.sessionId).assertEqual('remote-session'); expect(remoteProjection.isLoadingConversation).assertTrue(); const generalProjection = ConversationViewState.project(AppRoute.ChatHome, remote, general, 'Configure model'); @@ -1060,6 +1116,39 @@ export default function conversationStateUnitTest() { expect(sessions[1].id).assertEqual('one-session'); expect(sessions[2].id).assertEqual('two-session'); }); + + // Listings run several at a time, so the result can no longer be read off + // the order they happened to finish in. + it('keeps results in path order across concurrency batches', 0, async () => { + const source = new FakeRemoteWorkspaceDataSource(); + const paths: string[] = []; + for (let index = 0; index < 9; index++) { + const path = `/workspace-${index}`; + paths.push(path); + source.sessionsByPath.set(path, [remoteSession(`session-${index}`, `Session ${index}`)]); + } + const coordinator = new RemoteWorkspaceCoordinator(source); + + const sessions = await coordinator.sessionsForWorkspaces(paths); + expect(sessions.length).assertEqual(9); + expect(sessions[0].id).assertEqual('session-0'); + expect(sessions[4].id).assertEqual('session-4'); + expect(sessions[8].id).assertEqual('session-8'); + expect(source.listedPaths.length).assertEqual(9); + }); + + it('keeps the other workspaces when one of them cannot be listed', 0, async () => { + const source = new FakeRemoteWorkspaceDataSource(); + source.sessionsByPath.set('/one', [remoteSession('one-session', 'One')]); + source.sessionsByPath.set('/three', [remoteSession('three-session', 'Three')]); + source.failingPaths = ['/two']; + const coordinator = new RemoteWorkspaceCoordinator(source); + + const sessions = await coordinator.sessionsForWorkspaces(['/one', '/two', '/three']); + expect(sessions.length).assertEqual(2); + expect(sessions[0].id).assertEqual('one-session'); + expect(sessions[1].id).assertEqual('three-session'); + }); }); describe('ProductSurfaceIsolation', () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets index 97a364d5b6..4aece46c0c 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets @@ -222,7 +222,7 @@ export default function lifecycleUnitTest() { it('owns heartbeat timer restart, stop, and tick dispatch', 0, () => { const scheduler = new FakeRemoteHeartbeatScheduler(); let tickCount = 0; - const controller = new RemoteHeartbeatController(() => { + const controller = new RemoteHeartbeatController(async () => { tickCount += 1; }, 15000, scheduler); @@ -244,13 +244,39 @@ export default function lifecycleUnitTest() { expect(scheduler.clearedIds.length).assertEqual(2); expect(scheduler.clearedIds[1]).assertEqual(2); }); + + it('drops ticks that arrive while the previous one is unanswered', 0, async () => { + const scheduler = new FakeRemoteHeartbeatScheduler(); + let started = 0; + let answer: () => void = () => {}; + const controller = new RemoteHeartbeatController((): Promise => { + started += 1; + return new Promise((resolve: () => void) => { + answer = resolve; + }); + }, 15000, scheduler); + + controller.start(); + scheduler.tick(1); + scheduler.tick(1); + scheduler.tick(1); + // Three intervals have elapsed against a desktop that answered none of + // them, and exactly one request is out. + expect(started).assertEqual(1); + + answer(); + await Promise.resolve(); + await Promise.resolve(); + scheduler.tick(1); + expect(started).assertEqual(2); + }); }); describe('RemoteActivityLifecycleController', () => { it('owns remote heartbeat lifecycle while preserving tick dispatch', 0, () => { const scheduler = new FakeRemoteHeartbeatScheduler(); let tickCount = 0; - const controller = new RemoteActivityLifecycleController(() => { + const controller = new RemoteActivityLifecycleController(async () => { tickCount += 1; }, 15000, scheduler); diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets index 68a3341a7f..92e4aac379 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets @@ -52,7 +52,17 @@ import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory' import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; +import { + RemoteChatCache, + RemoteChatCacheSlice, + RemoteChatCacheStore +} from '../main/ets/services/RemoteChatCache'; import { RemoteChatCommandClient, RemoteChatCommandController } from '../main/ets/services/RemoteChatCommandController'; +import { + RemoteSessionListCache, + RemoteSessionListSlice, + RemoteSessionListStore +} from '../main/ets/services/RemoteSessionListCache'; import { RemoteChatPollingLifecycleController } from '../main/ets/services/RemoteChatPollingLifecycleController'; import { RemoteFileDownloadClient, @@ -151,6 +161,10 @@ export interface GeneralChatRecordedRequest { export class FakeRemoteWorkspaceDataSource implements RemoteWorkspaceDataSource { recent: RecentWorkspaceEntry[] = []; sessionsByPath: Map = new Map(); + /** Paths whose listing rejects, for the workspace a desktop cannot read. */ + failingPaths: string[] = []; + /** Every path listed, in the order the listings were started. */ + listedPaths: string[] = []; async listRecentWorkspaces(): Promise { return this.recent; @@ -175,6 +189,10 @@ export class FakeRemoteWorkspaceDataSource implements RemoteWorkspaceDataSource } async listSessionsForWorkspace(path: string, _limit: number): Promise { + this.listedPaths.push(path); + if (this.failingPaths.includes(path)) { + throw new Error(`workspace unavailable: ${path}`); + } return this.sessionsByPath.get(path) || []; } @@ -918,6 +936,186 @@ export class RemoteChatMessagesProjectionRecord { knownMessageCount: number = -1; } +/** Stands in for the RDB-backed transcript cache, keyed the same way it is. */ +/** + * An in-memory stand-in for `RemoteChatLocalRdbStore`. + * + * Recency is modelled the same way the real store models it — reads touch a + * session just as writes do — so a test can assert which transcript eviction + * actually kept rather than only that a prune was asked for. + */ +export class FakeRemoteChatCacheStore implements RemoteChatCacheStore { + readonly slices: Map = new Map(); + appendCount: number = 0; + replaceCount: number = 0; + pruneRequests: number[] = []; + clearCount: number = 0; + shouldFail: boolean = false; + /** Cache keys, least recently touched first. */ + private recency: string[] = []; + + async init(_context: Context): Promise { + this.failIfNeeded(); + } + + async loadSlice(deviceKey: string, sessionId: string): Promise { + this.failIfNeeded(); + const key = FakeRemoteChatCacheStore.key(deviceKey, sessionId); + const slice = this.slices.get(key); + if (!slice) { + return { messages: [], lastMessageId: '' }; + } + this.touch(key); + return { messages: slice.messages.slice(), lastMessageId: slice.lastMessageId }; + } + + async appendMessages( + deviceKey: string, + sessionId: string, + startSeq: number, + messages: ChatMessage[], + lastMessageId: string + ): Promise { + this.failIfNeeded(); + this.appendCount += 1; + const key = FakeRemoteChatCacheStore.key(deviceKey, sessionId); + const existing = this.slices.get(key); + const kept = existing ? existing.messages.slice(0, startSeq) : []; + this.slices.set(key, { messages: kept.concat(messages), lastMessageId }); + this.touch(key); + } + + async replaceMessages( + deviceKey: string, + sessionId: string, + messages: ChatMessage[], + lastMessageId: string + ): Promise { + this.failIfNeeded(); + this.replaceCount += 1; + const key = FakeRemoteChatCacheStore.key(deviceKey, sessionId); + this.slices.set(key, { messages: messages.slice(), lastMessageId }); + this.touch(key); + } + + async deleteSession(deviceKey: string, sessionId: string): Promise { + this.failIfNeeded(); + this.drop(FakeRemoteChatCacheStore.key(deviceKey, sessionId)); + } + + async pruneSessions(deviceKey: string, keepMostRecent: number): Promise { + this.failIfNeeded(); + this.pruneRequests.push(keepMostRecent); + const prefix = `${deviceKey}::`; + const owned = this.recency.filter((key: string): boolean => key.startsWith(prefix)); + owned.slice(0, Math.max(0, owned.length - keepMostRecent)) + .forEach((key: string): void => this.drop(key)); + } + + async clearAll(): Promise { + this.failIfNeeded(); + this.clearCount += 1; + this.slices.clear(); + this.recency = []; + } + + private touch(key: string): void { + this.recency = this.recency.filter((stored: string): boolean => stored !== key); + this.recency.push(key); + } + + private drop(key: string): void { + this.slices.delete(key); + this.recency = this.recency.filter((stored: string): boolean => stored !== key); + } + + private failIfNeeded(): void { + if (this.shouldFail) { + throw new Error('Expected remote chat cache failure.'); + } + } + + private static key(deviceKey: string, sessionId: string): string { + return `${deviceKey}::${sessionId}`; + } +} + +/** + * A cache that never initializes, so every call is a miss. + * + * That is the shape most controller tests want: the cache is transparent and + * the assertions stay about the network path. + */ +export function inertRemoteChatCache(): RemoteChatCache { + return new RemoteChatCache(new FakeRemoteChatCacheStore(), (): string => 'device-1'); +} + +/** A cache backed by [store] and ready to read and write. */ +export async function readyRemoteChatCache( + store: FakeRemoteChatCacheStore, + deviceKey: string = 'device-1' +): Promise { + const cache = new RemoteChatCache(store, (): string => deviceKey); + await cache.init({} as Context); + return cache; +} + +export class FakeRemoteSessionListStore implements RemoteSessionListStore { + readonly lists: Map = new Map(); + lastDeviceKey: string = ''; + saveCount: number = 0; + clearCount: number = 0; + shouldFail: boolean = false; + + async init(_context: Context): Promise { + this.failIfNeeded(); + } + + async loadList(deviceKey: string): Promise { + this.failIfNeeded(); + const slice = this.lists.get(deviceKey); + if (!slice) { + return { sessions: [], hasMore: false }; + } + return { sessions: slice.sessions.slice(), hasMore: slice.hasMore }; + } + + async loadLastList(): Promise { + this.failIfNeeded(); + return await this.loadList(this.lastDeviceKey); + } + + async saveList(deviceKey: string, sessions: RemoteSession[], hasMore: boolean): Promise { + this.failIfNeeded(); + this.saveCount += 1; + this.lists.set(deviceKey, { sessions: sessions.slice(), hasMore }); + this.lastDeviceKey = deviceKey; + } + + async clearAll(): Promise { + this.failIfNeeded(); + this.clearCount += 1; + this.lists.clear(); + this.lastDeviceKey = ''; + } + + private failIfNeeded(): void { + if (this.shouldFail) { + throw new Error('Expected remote session list cache failure.'); + } + } +} + +/** A session list cache backed by [store] and ready to read and write. */ +export async function readyRemoteSessionListCache( + store: FakeRemoteSessionListStore, + deviceKey: string = 'device-1' +): Promise { + const cache = new RemoteSessionListCache(store, (): string => deviceKey); + await cache.init({} as Context); + return cache; +} + export class FakeGeneralChatCommandClient implements GeneralChatCommandClient { currentSessions: RemoteSession[] = [remoteSession('chat-1', 'Chat 1')]; messagesBySession: Map = new Map(); 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 471cc8ef7b..7e4ded06f4 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -48,6 +48,7 @@ import { import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelProviderSseParser'; import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; import { MarkdownParseCache, MarkdownParser } from '../main/ets/services/MarkdownParser'; +import { RemoteSessionListCache } from '../main/ets/services/RemoteSessionListCache'; import { ToolFileInputPayload, ToolFileReferenceResolver @@ -118,6 +119,11 @@ import { } from '../main/ets/pages/policy/ConversationLayoutPolicy'; import { SessionActionPolicy, SessionActionScope } from '../main/ets/pages/policy/SessionActionPolicy'; import { ConversationSessionFilterPolicy } from '../main/ets/pages/policy/ConversationSessionFilterPolicy'; +import { + SessionListInputs, + SessionListProjectionCache, + SessionListProjector +} from '../main/ets/pages/policy/SessionListProjection'; import { ConversationModelPresentationPolicy } from '../main/ets/pages/policy/ConversationModelPresentationPolicy'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, @@ -201,6 +207,11 @@ import { FakeRemoteSessionClient, RemoteSessionProjectionRecord, FakeRemoteChatCommandClient, + FakeRemoteChatCacheStore, + FakeRemoteSessionListStore, + inertRemoteChatCache, + readyRemoteChatCache, + readyRemoteSessionListCache, RemoteChatMessagesProjectionRecord, FakeGeneralChatCommandClient, FakeGeneralChatConfigStore @@ -1239,7 +1250,7 @@ export default function remoteControllersUnitTest() { onStartHeartbeat: () => {} }); - await controller.create('code', false, true, async (session: SessionSummary): Promise => { + const created = await controller.create('code', false, true, async (session: SessionSummary): Promise => { openedSessions.push(session); }); await controller.open(remoteSession('opened-session', 'Opened Session'), '/workspace', false, true, @@ -1248,6 +1259,7 @@ export default function remoteControllersUnitTest() { }); expect(client.createRequests[0]).assertEqual('code::'); + expect(created?.sessionId || '').assertEqual('created-session'); expect(activeSessions.length).assertEqual(2); expect(activeSessions[0].sessionId).assertEqual('created-session'); expect(activeSessions[1].sessionId).assertEqual('opened-session'); @@ -1255,6 +1267,27 @@ export default function remoteControllersUnitTest() { expect(openedSessions.length).assertEqual(2); }); + it('returns no created session when the remote create command fails', 0, async () => { + const client = new FakeRemoteSessionClient(); + client.shouldFailCreate = true; + const controller = new RemoteSessionController(client, 8, { + onSessions: (_sessions: RemoteSession[], _hasMore: boolean) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onStatusText: (_statusText: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onLoading: (_isLoading: boolean) => {}, + onSessionError: (_errorText: string) => {}, + onReconnecting: () => {}, + onConnected: () => {}, + onConnectionFailed: (_err: Object) => {}, + onStartHeartbeat: () => {} + }); + + const created = await controller.create('code', false, true, async (_session: SessionSummary): Promise => {}); + + expect(created === undefined).assertTrue(); + }); + it('deletes active sessions and backfills list when more pages exist', 0, async () => { const client = new FakeRemoteSessionClient(); const projection = new RemoteSessionProjectionRecord(); @@ -1305,6 +1338,82 @@ export default function remoteControllersUnitTest() { }); }); + describe('RemoteSessionListCache', () => { + it('restores the list of the device it last stored one for', 0, async () => { + const store = new FakeRemoteSessionListStore(); + const cache = await readyRemoteSessionListCache(store, 'desktop-a'); + + await cache.save([remoteSession('session-1', 'Session 1')], true); + const restored = await cache.restoreLast(); + + expect(restored.sessions.length).assertEqual(1); + expect(restored.sessions[0].id).assertEqual('session-1'); + expect(restored.hasMore).assertTrue(); + }); + + it('keeps each desktop list to itself', 0, async () => { + const store = new FakeRemoteSessionListStore(); + const first = await readyRemoteSessionListCache(store, 'desktop-a'); + const second = await readyRemoteSessionListCache(store, 'desktop-b'); + + await first.save([remoteSession('session-1', 'Session 1')], false); + await second.save([remoteSession('session-2', 'Session 2')], false); + + const fromFirst = await first.load(); + const fromSecond = await second.load(); + expect(fromFirst.sessions[0].id).assertEqual('session-1'); + expect(fromSecond.sessions[0].id).assertEqual('session-2'); + }); + + it('writes once for a list that has not changed', 0, async () => { + const store = new FakeRemoteSessionListStore(); + const cache = await readyRemoteSessionListCache(store); + + await cache.save([remoteSession('session-1', 'Session 1')], false); + await cache.save([remoteSession('session-1', 'Session 1')], false); + expect(store.saveCount).assertEqual(1); + + await cache.save([remoteSession('session-1', 'Renamed')], false); + expect(store.saveCount).assertEqual(2); + }); + + it('leaves a stored list alone when the sessions go away', 0, async () => { + const store = new FakeRemoteSessionListStore(); + const cache = await readyRemoteSessionListCache(store); + + await cache.save([remoteSession('session-1', 'Session 1')], false); + await cache.save([], false); + + const restored = await cache.restoreLast(); + expect(restored.sessions.length).assertEqual(1); + }); + + it('reads nothing back once signing out clears it', 0, async () => { + const store = new FakeRemoteSessionListStore(); + const cache = await readyRemoteSessionListCache(store); + + await cache.save([remoteSession('session-1', 'Session 1')], false); + await cache.clear(); + + const restored = await cache.restoreLast(); + expect(restored.sessions.length).assertEqual(0); + expect(store.clearCount).assertEqual(1); + }); + + it('reports a miss instead of failing when the store is unusable', 0, async () => { + const store = new FakeRemoteSessionListStore(); + store.shouldFail = true; + const cache = new RemoteSessionListCache(store, (): string => 'device-1'); + await cache.init({} as Context); + + await cache.save([remoteSession('session-1', 'Session 1')], false); + const restored = await cache.restoreLast(); + + expect(restored.sessions.length).assertEqual(0); + expect(store.saveCount).assertEqual(0); + }); + }); + describe('RemoteChatCommandController', () => { it('loads active messages and ignores stale route projections', 0, async () => { const client = new FakeRemoteChatCommandClient(); @@ -1326,6 +1435,7 @@ export default function remoteControllersUnitTest() { projection.pollVersion = pollVersion; projection.knownMessageCount = knownMessageCount; }, + onTimelineReady: () => {}, onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, onSendFailed: ( _rawText: string, @@ -1338,9 +1448,10 @@ export default function remoteControllersUnitTest() { onStatusText: (statusText: string) => { statuses.push(statusText); }, + onToast: (_message: string) => {}, onBusy: (_isBusy: boolean) => {}, onPollRequested: () => {} - }); + }, inertRemoteChatCache()); await controller.loadMessages('session-1', (_sessionId: string) => true); await controller.loadMessages('session-2', (_sessionId: string) => false); @@ -1356,8 +1467,272 @@ export default function remoteControllersUnitTest() { expect(statuses[1]).assertEqual(RemoteI18n.t('status.messagesSynced')); }); + it('caches a fetched transcript and reopens it without a request', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + const store = new FakeRemoteChatCacheStore(); + const projection = new RemoteChatMessagesProjectionRecord(); + const statuses: string[] = []; + let timelineReadyCount = 0; + client.messageResults = [{ + messages: [ + chatMessage('message-1', 'user', 'Hello'), + chatMessage('message-2', 'assistant', 'Hi') + ], + hasMore: false + }]; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { + projection.messages = messages; + projection.hasMoreMessages = hasMoreMessages; + }, + onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { + projection.pollVersion = pollVersion; + projection.knownMessageCount = knownMessageCount; + }, + onTimelineReady: () => { + timelineReadyCount += 1; + }, + onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, + onSendFailed: ( + _rawText: string, + _images: SelectedImageAttachment[], + _localMessageId: string, + _pendingActiveId: string + ) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onSessionTitleChanged: (_sessionId: string, _title: string) => {}, + onStatusText: (statusText: string) => { + statuses.push(statusText); + }, + onToast: (_message: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onPollRequested: () => {} + }, await readyRemoteChatCache(store)); + + await controller.loadMessages('session-1', (_sessionId: string) => true); + await controller.loadMessages('session-1', (_sessionId: string) => true); + + expect(client.messageRequests.length).assertEqual(1); + expect(projection.messages.length).assertEqual(2); + expect(projection.messages[1].id).assertEqual('message-2'); + // A cached open still resets the poll version: the desktop restarts its + // version tracker, so a carried-over one would match by accident. + expect(projection.pollVersion).assertEqual(0); + expect(projection.knownMessageCount).assertEqual(2); + expect(timelineReadyCount).assertEqual(2); + expect(statuses[statuses.length - 1]).assertEqual(RemoteI18n.t('status.messagesRestored')); + }); + + it('caches a transcript the user switched away from before it arrived', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + const store = new FakeRemoteChatCacheStore(); + const projection = new RemoteChatMessagesProjectionRecord(); + let timelineReadyCount = 0; + client.messageResults = [{ + messages: [ + chatMessage('message-1', 'user', 'Hello'), + chatMessage('message-2', 'assistant', 'Hi') + ], + hasMore: false + }]; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { + projection.messages = messages; + projection.hasMoreMessages = hasMoreMessages; + }, + onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { + projection.pollVersion = pollVersion; + projection.knownMessageCount = knownMessageCount; + }, + onTimelineReady: () => { + timelineReadyCount += 1; + }, + onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, + onSendFailed: ( + _rawText: string, + _images: SelectedImageAttachment[], + _localMessageId: string, + _pendingActiveId: string + ) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onSessionTitleChanged: (_sessionId: string, _title: string) => {}, + onStatusText: (_statusText: string) => {}, + onToast: (_message: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onPollRequested: () => {} + }, await readyRemoteChatCache(store)); + + // Opened, then switched away from before the fetch landed. Nothing may + // reach the timeline that now belongs to another session. + await controller.loadMessages('session-1', (_sessionId: string) => false); + expect(projection.messages.length).assertEqual(0); + expect(timelineReadyCount).assertEqual(0); + + // Reopening it has to be free: the transcript was already paid for. + await controller.loadMessages('session-1', (_sessionId: string) => true); + + expect(client.messageRequests.length).assertEqual(1); + expect(projection.messages.length).assertEqual(2); + expect(projection.messages[1].id).assertEqual('message-2'); + expect(projection.knownMessageCount).assertEqual(2); + expect(timelineReadyCount).assertEqual(1); + }); + + it('appends polled messages to the cache instead of rewriting it', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + const store = new FakeRemoteChatCacheStore(); + const cache = await readyRemoteChatCache(store); + client.messageResults = [{ + messages: [chatMessage('message-1', 'user', 'Hello')], + hasMore: false + }]; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, + onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, + onTimelineReady: () => {}, + onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, + onSendFailed: ( + _rawText: string, + _images: SelectedImageAttachment[], + _localMessageId: string, + _pendingActiveId: string + ) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onSessionTitleChanged: (_sessionId: string, _title: string) => {}, + onStatusText: (_statusText: string) => {}, + onToast: (_message: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onPollRequested: () => {} + }, cache); + + await controller.loadMessages('session-1', (_sessionId: string) => true); + await cache.sync('session-1', [ + chatMessage('message-1', 'user', 'Hello'), + chatMessage('message-2', 'assistant', 'Hi') + ]); + // Nothing new arrived, so the second sync must not touch the store. + await cache.sync('session-1', [ + chatMessage('message-1', 'user', 'Hello'), + chatMessage('message-2', 'assistant', 'Hi') + ]); + + expect(store.replaceCount).assertEqual(1); + expect(store.appendCount).assertEqual(1); + expect((await cache.load('session-1')).length).assertEqual(2); + }); + + it('rewrites the cache when the stored prefix stops matching', 0, async () => { + const store = new FakeRemoteChatCacheStore(); + const cache = await readyRemoteChatCache(store); + + await cache.sync('session-1', [chatMessage('message-1', 'user', 'Hello')]); + // Same length, different first message: an append here would splice two + // different transcripts together. + await cache.sync('session-1', [chatMessage('message-9', 'user', 'Rewritten')]); + + const restored = await cache.load('session-1'); + expect(store.appendCount).assertEqual(0); + expect(store.replaceCount).assertEqual(2); + expect(restored.length).assertEqual(1); + expect(restored[0].id).assertEqual('message-9'); + }); + + it('evicts only when a session enters the cache, not on every poll tick', 0, async () => { + const store = new FakeRemoteChatCacheStore(); + const cache = await readyRemoteChatCache(store); + + await cache.sync('session-1', [chatMessage('message-1', 'user', 'Hello')]); + await cache.sync('session-1', [ + chatMessage('message-1', 'user', 'Hello'), + chatMessage('message-2', 'assistant', 'Hi') + ]); + + expect(store.appendCount).assertEqual(1); + expect(store.pruneRequests.length).assertEqual(1); + expect(store.pruneRequests[0]).assertEqual(20); + }); + + it('drops the oldest transcript once the per-desktop budget is full', 0, async () => { + const store = new FakeRemoteChatCacheStore(); + const cache = await readyRemoteChatCache(store); + + // One past the budget the prune above reported. + for (let index = 0; index <= 20; index++) { + await cache.sync(`session-${index}`, [chatMessage(`message-${index}`, 'user', 'Hello')]); + } + + expect((await cache.load('session-0')).length).assertEqual(0); + expect((await cache.load('session-1')).length).assertEqual(1); + expect((await cache.load('session-20')).length).assertEqual(1); + }); + + it('keeps a reopened transcript ahead of ones only ever written once', 0, async () => { + const store = new FakeRemoteChatCacheStore(); + const cache = await readyRemoteChatCache(store); + + for (let index = 0; index < 20; index++) { + await cache.sync(`session-${index}`, [chatMessage(`message-${index}`, 'user', 'Hello')]); + } + // Reopening is what recency is supposed to be about: this read has to + // outrank the nineteen sessions written after it. + expect((await cache.load('session-0')).length).assertEqual(1); + await cache.sync('session-20', [chatMessage('message-20', 'user', 'Hello')]); + + expect((await cache.load('session-0')).length).assertEqual(1); + expect((await cache.load('session-1')).length).assertEqual(0); + }); + + it('drops a session from the cache when it is forgotten', 0, async () => { + const store = new FakeRemoteChatCacheStore(); + const cache = await readyRemoteChatCache(store); + + await cache.sync('session-1', [chatMessage('message-1', 'user', 'Hello')]); + await cache.forget('session-1'); + + expect((await cache.load('session-1')).length).assertEqual(0); + }); + + it('keeps working when the cache store is unusable', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + const store = new FakeRemoteChatCacheStore(); + const projection = new RemoteChatMessagesProjectionRecord(); + store.shouldFail = true; + client.messageResults = [{ + messages: [chatMessage('message-1', 'assistant', 'Hello')], + hasMore: false + }]; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { + projection.messages = messages; + projection.hasMoreMessages = hasMoreMessages; + }, + onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, + onTimelineReady: () => {}, + onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, + onSendFailed: ( + _rawText: string, + _images: SelectedImageAttachment[], + _localMessageId: string, + _pendingActiveId: string + ) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onSessionTitleChanged: (_sessionId: string, _title: string) => {}, + onStatusText: (_statusText: string) => {}, + onToast: (_message: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onPollRequested: () => {} + }, await readyRemoteChatCache(store)); + + await controller.loadMessages('session-1', (_sessionId: string) => true); + + expect(client.messageRequests.length).assertEqual(1); + expect(projection.messages.length).assertEqual(1); + }); + it('loads older messages with busy and cursor callbacks', 0, async () => { const client = new FakeRemoteChatCommandClient(); + const store = new FakeRemoteChatCacheStore(); + const cache = await readyRemoteChatCache(store); const projection = new RemoteChatMessagesProjectionRecord(); const busyEvents: boolean[] = []; client.messageResults = [{ @@ -1376,6 +1751,7 @@ export default function remoteControllersUnitTest() { projection.pollVersion = pollVersion; projection.knownMessageCount = knownMessageCount; }, + onTimelineReady: () => {}, onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, onSendFailed: ( _rawText: string, @@ -1386,11 +1762,12 @@ export default function remoteControllersUnitTest() { onActiveSession: (_session: SessionSummary) => {}, onSessionTitleChanged: (_sessionId: string, _title: string) => {}, onStatusText: (_statusText: string) => {}, + onToast: (_message: string) => {}, onBusy: (isBusy: boolean) => { busyEvents.push(isBusy); }, onPollRequested: () => {} - }); + }, cache); await controller.loadOlderMessages('session-1', 7, true, false); @@ -1401,6 +1778,9 @@ export default function remoteControllersUnitTest() { expect(projection.knownMessageCount).assertEqual(2); expect(busyEvents[0]).assertTrue(); expect(busyEvents[1]).assertFalse(); + // What came back is a full transcript, so it belongs in the cache for the + // same reason the first load's does. + expect((await cache.load('session-1')).length).assertEqual(2); }); it('sends prepared remote messages and reports failed payloads', 0, async () => { @@ -1411,6 +1791,7 @@ export default function remoteControllersUnitTest() { const controller = new RemoteChatCommandController(client, { onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, + onTimelineReady: () => {}, onSendSucceeded: (turnId: string, pendingActiveId: string) => { succeeded.push(`${turnId}:${pendingActiveId}`); }, @@ -1425,11 +1806,12 @@ export default function remoteControllersUnitTest() { onActiveSession: (_session: SessionSummary) => {}, onSessionTitleChanged: (_sessionId: string, _title: string) => {}, onStatusText: (_statusText: string) => {}, + onToast: (_message: string) => {}, onBusy: (isBusy: boolean) => { busyEvents.push(isBusy); }, onPollRequested: () => {} - }); + }, inertRemoteChatCache()); await controller.sendPreparedMessage( 'session-1', @@ -1474,10 +1856,12 @@ export default function remoteControllersUnitTest() { it('stops active tasks and reports guard states', 0, async () => { const client = new FakeRemoteChatCommandClient(); const statuses: string[] = []; + const toasts: string[] = []; let pollCount = 0; const controller = new RemoteChatCommandController(client, { onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, + onTimelineReady: () => {}, onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, onSendFailed: ( _rawText: string, @@ -1490,11 +1874,14 @@ export default function remoteControllersUnitTest() { onStatusText: (statusText: string) => { statuses.push(statusText); }, + onToast: (message: string) => { + toasts.push(message); + }, onBusy: (_isBusy: boolean) => {}, onPollRequested: () => { pollCount += 1; } - }); + }, inertRemoteChatCache()); await controller.stopTask('session-1', '', '', true); await controller.stopTask('session-1', 'active-turn', 'turn-1', true); @@ -1504,6 +1891,45 @@ export default function remoteControllersUnitTest() { expect(statuses[1]).assertEqual(RemoteI18n.t('status.stoppingTask')); expect(statuses[2]).assertEqual(RemoteI18n.t('status.stopRequested')); expect(pollCount).assertEqual(1); + // The status line is hidden while connected, so a refused stop is only + // ever visible as a toast. + expect(toasts[0]).assertEqual(RemoteI18n.t('status.noRunningTask')); + expect(toasts.length).assertEqual(1); + }); + + it('surfaces a refused stop instead of leaving the task silently running', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + client.shouldFailCancel = true; + const statuses: string[] = []; + const toasts: string[] = []; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, + onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, + onTimelineReady: () => {}, + onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, + onSendFailed: ( + _rawText: string, + _images: SelectedImageAttachment[], + _localMessageId: string, + _pendingActiveId: string + ) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onSessionTitleChanged: (_sessionId: string, _title: string) => {}, + onStatusText: (statusText: string) => { + statuses.push(statusText); + }, + onToast: (message: string) => { + toasts.push(message); + }, + onBusy: (_isBusy: boolean) => {}, + onPollRequested: () => {} + }, inertRemoteChatCache()); + + await controller.stopTask('session-1', 'active-turn', 'turn-1', true); + + expect(toasts.length).assertEqual(1); + expect(toasts[0]).assertEqual(statuses[statuses.length - 1]); + expect(toasts[0] === RemoteI18n.t('status.stopRequested')).assertFalse(); }); it('renames active sessions and updates active/list projections', 0, async () => { @@ -1514,6 +1940,7 @@ export default function remoteControllersUnitTest() { const controller = new RemoteChatCommandController(client, { onMessagesLoaded: (_messages: ChatMessage[], _hasMoreMessages: boolean) => {}, onMessageCountKnown: (_pollVersion: number, _knownMessageCount: number) => {}, + onTimelineReady: () => {}, onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, onSendFailed: ( _rawText: string, @@ -1528,11 +1955,12 @@ export default function remoteControllersUnitTest() { titleUpdates.push(`${sessionId}:${title}`); }, onStatusText: (_statusText: string) => {}, + onToast: (_message: string) => {}, onBusy: (isBusy: boolean) => { busyEvents.push(isBusy); }, onPollRequested: () => {} - }); + }, inertRemoteChatCache()); await controller.renameActiveSession({ sessionId: 'session-1', @@ -1563,12 +1991,17 @@ export default function remoteControllersUnitTest() { expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.surface).assertEqual(ChatSurface.General); expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertFalse(); expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.requiresRemoteConnection).assertFalse(); + // Nothing queues a message on this surface, so a draft written mid-reply + // has nowhere to go until the reply finishes. + expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.supportsMidRunSend).assertFalse(); }); it('keeps remote composer attachments behind the remote connection', 0, () => { expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.surface).assertEqual(ChatSurface.Remote); expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertTrue(); expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.requiresRemoteConnection).assertTrue(); + // The desktop queues a mid-run message and lets the current turn yield. + expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.supportsMidRunSend).assertTrue(); }); }); @@ -1784,6 +2217,125 @@ export default function remoteControllersUnitTest() { }); }); + describe('SessionListProjection', () => { + const session = ( + id: string, + title: string, + agentType: string, + workspacePath: string, + status: string = 'idle' + ): RemoteSession => { + return { + id, + title, + agentType, + status, + updatedAt: '', + createdAt: '', + messageCount: 1, + workspacePath + }; + }; + + const inputs = (sessions: RemoteSession[], query: string = ''): SessionListInputs => { + return { + sessions, + query, + sortMode: 'project', + workspaceName: 'BitFun', + workspacePath: '/workspace/bitfun', + workspaceKind: 'normal', + recentWorkspaces: [ + { path: '/workspace/bitfun', name: 'BitFun', lastOpened: '', workspaceKind: 'normal' }, + { path: '/workspace/notes', name: 'Notes', lastOpened: '', workspaceKind: 'assistant' } + ], + workspaceFilter: '', + agentFilter: '', + statusFilter: '', + showWorkspaceMetadata: false, + showUpdatedMetadata: false, + showStatusMetadata: false + }; + }; + + const all: RemoteSession[] = [ + session('code-1', 'Fix layout', 'code', '/workspace/bitfun'), + session('code-2', 'Trailing slash', 'code', '/workspace/bitfun/'), + session('chat-1', 'Product notes', 'claw', ''), + session('chat-2', 'Assistant workspace', 'code', '/workspace/notes'), + session('gone-1', 'Archived', 'code', '/workspace/bitfun', 'archived') + ]; + + it('splits chats from project sessions in one pass', 0, () => { + const projection = SessionListProjector.project(inputs(all)); + expect(projection.filtered.length).assertEqual(4); + expect(projection.chats.map((item: RemoteSession) => item.id).join(',')).assertEqual('chat-1,chat-2'); + // A trailing slash is the same workspace, so both code sessions land in + // the one bucket the sidebar renders under that project. + const bucket = SessionListProjector.sessionsForProject(projection, '/workspace/bitfun/'); + expect(bucket.map((item: RemoteSession) => item.id).join(',')).assertEqual('code-1,code-2'); + expect(projection.projects.map((item: RecentWorkspaceEntry) => item.path).join(',')) + .assertEqual('/workspace/bitfun'); + expect(projection.hasActiveFilter).assertFalse(); + expect(SessionListProjector.sessionsForProject(projection, '/nowhere').length).assertEqual(0); + }); + + it('drops projects a filter has emptied', 0, () => { + const filtered = SessionListProjector.project(inputs(all, 'product')); + expect(filtered.hasActiveFilter).assertTrue(); + expect(filtered.chats.length).assertEqual(1); + expect(filtered.projects.length).assertEqual(0); + }); + + it('reuses the projection until an input changes', 0, () => { + const cache = new SessionListProjectionCache(); + const first = cache.get(inputs(all)); + expect(cache.get(inputs(all)) === first).assertTrue(); + + // A caller that rebuilds the array every call — which is what a filtered + // getter bound to a @Param does — still gets the cached projection. + const copied = all.map((item: RemoteSession) => item); + expect(cache.get(inputs(copied)) === first).assertTrue(); + + expect(cache.get(inputs(all, 'layout')) === first).assertFalse(); + + const renamed = all.map((item: RemoteSession): RemoteSession => { + return item.id === 'code-1' ? session('code-1', 'Renamed', 'code', '/workspace/bitfun') : item; + }); + const afterRename = cache.get(inputs(renamed)); + expect(afterRename === first).assertFalse(); + expect(afterRename.filtered[0].title).assertEqual('Renamed'); + }); + + 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(); + const old = session('old-1', 'Older', 'code', '/workspace/bitfun'); + old.updatedAt = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); + + const byProject = SessionListProjector.project(inputs([recent, old])); + expect(byProject.byTime.length).assertEqual(0); + + const timeInputs = inputs([old, recent]); + timeInputs.sortMode = 'time'; + const byTime = SessionListProjector.project(timeInputs); + expect(byTime.byTime.map((item: RemoteSession) => item.id).join(',')).assertEqual('recent-1,old-1'); + expect(byTime.today.map((item: RemoteSession) => item.id).join(',')).assertEqual('recent-1'); + expect(byTime.earlier.map((item: RemoteSession) => item.id).join(',')).assertEqual('old-1'); + }); + + it('labels rows only for the metadata that is switched on', 0, () => { + const plain = SessionListProjector.project(inputs(all)); + expect(SessionListProjector.metadataFor(plain, 'code-1')).assertEqual(''); + + const labelled = inputs(all); + labelled.showStatusMetadata = true; + const projection = SessionListProjector.project(labelled); + expect(SessionListProjector.metadataFor(projection, 'code-1')).assertEqual('idle'); + }); + }); + describe('ConversationModelPresentationPolicy', () => { const primaryModel: ConversationUiModel = { id: 'anthropic/claude-sonnet-4', @@ -2060,6 +2612,47 @@ export default function remoteControllersUnitTest() { expect(errors.length).assertEqual(0); }); + it('flags a transcript the desktop shortened', 0, async () => { + const manager = new FakePollSessionManager(); + const snapshots: ChatSessionSnapshot[] = []; + manager.results = [pollResult({ + version: 2, + changed: true, + sessionState: 'idle', + title: 'Session', + newMessages: [], + totalMessageCount: 2 + }), pollResult({ + version: 3, + changed: true, + sessionState: 'idle', + title: 'Session', + newMessages: [chatMessage('message-6', 'assistant', 'Reply')], + totalMessageCount: 6 + })]; + const controller = new ChatSessionController(manager, { + onSnapshot: (snapshot: ChatSessionSnapshot) => { + snapshots.push(snapshot); + }, + onError: (_error: Object) => {}, + canPoll: (_sessionId: string) => true + }); + + controller.start('session-1', { + pollVersion: 1, + knownMessageCount: 5, + knownModelCatalogVersion: 0 + }); + await delay(20); + await controller.pollNow(); + controller.stop(); + + // 2 < 5: whatever the desktop is counting is not what this session had. + expect(snapshots[0].historyRewritten).assertTrue(); + // 6 >= 2: an ordinary tail, resumable from the offset already agreed on. + expect(snapshots[1].historyRewritten).assertFalse(); + }); + it('preserves active turn when unchanged poll omits active turn', 0, async () => { const manager = new FakePollSessionManager(); const snapshots: ChatSessionSnapshot[] = []; 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 d083ef6550..22dcfcb623 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -1,7 +1,7 @@ import { describe, it, expect } from '@ohos/hypium'; import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; import { ChatSessionController, ChatSessionSnapshot } from '../main/ets/services/ChatSessionController'; -import { ChatComposerPolicy } from '../main/ets/services/ChatComposerPolicy'; +import { ChatComposerPolicy, ComposerPrimaryAction } from '../main/ets/services/ChatComposerPolicy'; import { ChatTimelineItem, ChatTimelineProjector } from '../main/ets/services/ChatTimelineProjector'; import { ChatTimelineStore } from '../main/ets/services/ChatTimelineStore'; import { ConversationEvent } from '../main/ets/services/ConversationEvent'; @@ -53,6 +53,7 @@ import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelP import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; import { MarkdownParser } from '../main/ets/services/MarkdownParser'; import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; +import { toRemoteQuestionAnswer } from '../main/ets/pages/components/ConversationUiModels'; import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; @@ -719,6 +720,39 @@ export default function transportAndGeneralChatUnitTest() { expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'connected')).assertEqual(true); expect(ChatComposerPolicy.canSend('运行任务', 0, false, true, 'reconnecting')).assertEqual(true); }); + + it('lets a draft outrank a running turn where the message can be handed over', 0, () => { + expect(ChatComposerPolicy.primaryAction('追加要求', 0, false, false, true, true, true, 'connected', true)) + .assertEqual(ComposerPrimaryAction.Send); + expect(ChatComposerPolicy.primaryAction('', 0, false, false, true, true, true, 'connected', true)) + .assertEqual(ComposerPrimaryAction.Stop); + }); + + it('keeps a running turn in front of the draft where nothing queues it', 0, () => { + expect(ChatComposerPolicy.primaryAction('追加要求', 0, true, false, true, false, false, 'connected', true)) + .assertEqual(ComposerPrimaryAction.Stop); + expect(ChatComposerPolicy.primaryAction('', 0, true, false, true, false, false, 'connected', true)) + .assertEqual(ComposerPrimaryAction.Stop); + }); + + it('keeps dictation in front of the draft it is still writing', 0, () => { + expect(ChatComposerPolicy.primaryAction('说到一半', 0, false, true, false, true, true, 'connected', true)) + .assertEqual(ComposerPrimaryAction.Stop); + expect(ChatComposerPolicy.primaryAction('说到一半', 0, false, true, true, true, true, 'connected', true)) + .assertEqual(ComposerPrimaryAction.Stop); + }); + + it('still shows a draft it cannot carry as blocked rather than idle', 0, () => { + expect(ChatComposerPolicy.primaryAction('断线时写的', 0, false, false, true, true, true, 'failed', true)) + .assertEqual(ComposerPrimaryAction.SendBlocked); + }); + + it('offers dictation only once no turn is running and no draft exists', 0, () => { + expect(ChatComposerPolicy.primaryAction('', 0, false, false, false, true, true, 'connected', true)) + .assertEqual(ComposerPrimaryAction.Voice); + expect(ChatComposerPolicy.primaryAction('', 0, false, false, false, true, true, 'connected', false)) + .assertEqual(ComposerPrimaryAction.Idle); + }); }); describe('GeneralChatApiClient', () => { @@ -1203,14 +1237,25 @@ export default function transportAndGeneralChatUnitTest() { it('builds tool and file commands', 0, () => { const answers: RemoteQuestionAnswerPayload = { answer: 'yes', '0': 'yes' }; + const multipleAnswers: RemoteQuestionAnswerPayload = { '0': 'yes', '1': ['tests', 'docs'] }; expectCommandJson(RemoteCommandFactory.confirmTool('tool-1'), '{"cmd":"confirm_tool","tool_id":"tool-1"}'); expectCommandJson(RemoteCommandFactory.rejectTool('tool-1', 'no'), '{"cmd":"reject_tool","tool_id":"tool-1","reason":"no"}'); expectCommandJson(RemoteCommandFactory.cancelTool('tool-1', 'stop'), '{"cmd":"cancel_tool","tool_id":"tool-1","reason":"stop"}'); expectCommandJson(RemoteCommandFactory.answerQuestion('tool-2', answers), '{"cmd":"answer_question","tool_id":"tool-2","answers":{"0":"yes","answer":"yes"}}'); + expectCommandJson(RemoteCommandFactory.answerQuestion('tool-3', multipleAnswers), '{"cmd":"answer_question","tool_id":"tool-3","answers":{"0":"yes","1":["tests","docs"]}}'); expectCommandJson(RemoteCommandFactory.getFileInfo('/tmp/a.txt', 's1'), '{"cmd":"get_file_info","path":"/tmp/a.txt","session_id":"s1"}'); expectCommandJson(RemoteCommandFactory.readFileChunk('/tmp/a.txt', 12, 64, 's1'), '{"cmd":"read_file_chunk","path":"/tmp/a.txt","offset":12,"limit":64,"session_id":"s1"}'); }); + + it('preserves indexed question answers when dispatching from the UI', 0, () => { + const answers = toRemoteQuestionAnswer({ '0': 'tests', '1': ['docs', 'src'] }); + + expectCommandJson( + RemoteCommandFactory.answerQuestion('tool-4', answers), + '{"cmd":"answer_question","tool_id":"tool-4","answers":{"0":"tests","1":["docs","src"]}}' + ); + }); }); describe('RemoteResponseMapper', () => { diff --git a/src/crates/services/relay-service/src/db.rs b/src/crates/services/relay-service/src/db.rs index cb9f53dd0c..ab518700bf 100644 --- a/src/crates/services/relay-service/src/db.rs +++ b/src/crates/services/relay-service/src/db.rs @@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS devices ( device_id TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, device_name TEXT, + device_kind TEXT, public_key TEXT, last_seen_at INTEGER, online INTEGER NOT NULL DEFAULT 0, @@ -141,6 +142,10 @@ const MIGRATE_AUTH_TOKEN_REQUEST_ID: &str = r#" ALTER TABLE auth_tokens ADD COLUMN request_id TEXT; "#; +const MIGRATE_DEVICE_KIND: &str = r#" +ALTER TABLE devices ADD COLUMN device_kind TEXT; +"#; + /// Open (or create) the SQLite database and ensure the schema exists. pub async fn connect(db_path: &str) -> Result { connect_with_presence_reset(db_path, true).await @@ -216,6 +221,15 @@ async fn connect_with_presence_reset(db_path: &str, reset_presence: bool) -> Res } } migrate_account_scoped_devices(&pool).await?; + // Runs after the account-scoping migration because that path rebuilds + // `devices` from the legacy schema; adding the column last covers both the + // rebuilt table and databases that never needed rebuilding. A NULL kind + // means "registered before clients reported one" and is read as a desktop. + if let Err(error) = sqlx::query(MIGRATE_DEVICE_KIND).execute(&pool).await { + if !error.to_string().contains("duplicate column name") { + return Err(anyhow!("migrate device kinds: {error}")); + } + } sqlx::query( "CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_request_id \ ON auth_tokens(request_id) WHERE request_id IS NOT NULL", @@ -615,11 +629,36 @@ fn lockout_until(attempts: i64, now: i64) -> i64 { // ── Devices ───────────────────────────────────────────────────────────── +/// Only desktops can host a remote-control session, so the device list is +/// filtered on this. Phones and watches still register — they need a device +/// row to hold their auth token — they just aren't offered as control targets. +pub const DEVICE_KIND_DESKTOP: &str = "desktop"; +pub const DEVICE_KIND_MOBILE: &str = "mobile"; +pub const DEVICE_KIND_WATCH: &str = "watch"; + +pub const DEVICE_KINDS: [&str; 3] = [ + DEVICE_KIND_DESKTOP, + DEVICE_KIND_MOBILE, + DEVICE_KIND_WATCH, +]; + +pub fn is_valid_device_kind(kind: &str) -> bool { + DEVICE_KINDS.contains(&kind) +} + +/// A missing kind predates client-side reporting, and is read as a desktop: +/// hiding a real desktop would break remote control outright, while a stale +/// phone row corrects itself the next time that phone logs in. +pub fn device_kind_is_desktop(kind: Option<&str>) -> bool { + matches!(kind, None | Some(DEVICE_KIND_DESKTOP)) +} + #[derive(Debug, Clone, sqlx::FromRow)] pub struct DeviceRow { pub device_id: String, pub user_id: String, pub device_name: Option, + pub device_kind: Option, pub public_key: Option, pub last_seen_at: Option, pub online: i64, @@ -631,20 +670,27 @@ impl DeviceRow { device_id: &str, user_id: &str, device_name: &str, + device_kind: Option<&str>, public_key: Option<&str>, ) -> Result<()> { let now = Utc::now().timestamp(); + // `device_kind` is only overwritten when the caller actually reported + // one. A client build that predates the field would otherwise erase a + // known kind on every login and put the device back in the list. sqlx::query( - "INSERT INTO devices (device_id, user_id, device_name, public_key, last_seen_at, online) \ - VALUES (?, ?, ?, ?, ?, 0) \ + "INSERT INTO devices \ + (device_id, user_id, device_name, device_kind, public_key, last_seen_at, online) \ + VALUES (?, ?, ?, ?, ?, ?, 0) \ ON CONFLICT(user_id, device_id) DO UPDATE SET \ device_name = excluded.device_name, \ + device_kind = COALESCE(excluded.device_kind, devices.device_kind), \ public_key = excluded.public_key, \ last_seen_at = excluded.last_seen_at", ) .bind(device_id) .bind(user_id) .bind(device_name) + .bind(device_kind) .bind(public_key) .bind(now) .execute(pool) @@ -675,7 +721,7 @@ impl DeviceRow { pub async fn list_by_user(pool: &DbPool, user_id: &str) -> Result> { let rows = sqlx::query_as::<_, DeviceRow>( - "SELECT device_id, user_id, device_name, public_key, last_seen_at, online \ + "SELECT device_id, user_id, device_name, device_kind, public_key, last_seen_at, online \ FROM devices WHERE user_id = ?", ) .bind(user_id) @@ -756,6 +802,7 @@ impl AuthToken { user_id: &str, device_id: &str, device_name: &str, + device_kind: Option<&str>, request_id: &str, ) -> Result> { let mut tx = pool @@ -801,12 +848,13 @@ impl AuthToken { let inserted = sqlx::query( "INSERT OR IGNORE INTO devices \ - (device_id, user_id, device_name, public_key, last_seen_at, online) \ - VALUES (?, ?, ?, NULL, ?, 0)", + (device_id, user_id, device_name, device_kind, public_key, last_seen_at, online) \ + VALUES (?, ?, ?, ?, NULL, ?, 0)", ) .bind(device_id) .bind(user_id) .bind(device_name) + .bind(device_kind) .bind(now) .execute(&mut *tx) .await @@ -2361,7 +2409,7 @@ mod tests { UserRow::create(&runtime_pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") .await .unwrap(); - DeviceRow::upsert(&runtime_pool, "d1", "u1", "Laptop", None) + DeviceRow::upsert(&runtime_pool, "d1", "u1", "Laptop", None, None) .await .unwrap(); DeviceRow::set_online(&runtime_pool, "u1", "d1", true) @@ -2418,7 +2466,7 @@ mod tests { UserRow::create(&pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") .await .unwrap(); - DeviceRow::upsert(&pool, "d1", "u1", "Laptop", None) + DeviceRow::upsert(&pool, "d1", "u1", "Laptop", None, None) .await .unwrap(); let tok = AuthToken::create(&pool, "u1", "d1").await.unwrap(); @@ -2452,10 +2500,10 @@ mod tests { .await .unwrap(); - DeviceRow::upsert(&pool, "shared-install", "u1", "Alice laptop", None) + DeviceRow::upsert(&pool, "shared-install", "u1", "Alice laptop", None, None) .await .unwrap(); - DeviceRow::upsert(&pool, "shared-install", "u2", "Bob laptop", None) + DeviceRow::upsert(&pool, "shared-install", "u2", "Bob laptop", None, None) .await .unwrap(); let token_u1 = AuthToken::create(&pool, "u1", "shared-install") @@ -2652,7 +2700,7 @@ mod tests { .await .unwrap() .is_none()); - DeviceRow::upsert(&migrated, "shared-install", "u1", "Alice laptop", None) + DeviceRow::upsert(&migrated, "shared-install", "u1", "Alice laptop", None, None) .await .unwrap(); assert_eq!( @@ -2673,6 +2721,34 @@ mod tests { let _ = std::fs::remove_file(db_path); } + #[tokio::test] + async fn reopening_a_database_keeps_the_device_kind_column_and_its_values() { + let db_path = std::env::temp_dir().join(format!( + "bitfun-relay-device-kind-migration-{}-{}.db", + std::process::id(), + rand::random::() + )); + let db_path_text = db_path.to_string_lossy().to_string(); + + let first = connect(&db_path_text).await.unwrap(); + UserRow::create(&first, "u1", "alice", "s", "ks", "{}", "hash", "wmk") + .await + .unwrap(); + DeviceRow::upsert(&first, "phone", "u1", "Phone", Some(DEVICE_KIND_MOBILE), None) + .await + .unwrap(); + first.close().await; + + // The ALTER runs on every startup and must tolerate the column already + // being there, rather than failing the whole boot. + let second = connect(&db_path_text).await.unwrap(); + let rows = DeviceRow::list_by_user(&second, "u1").await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].device_kind.as_deref(), Some(DEVICE_KIND_MOBILE)); + second.close().await; + let _ = std::fs::remove_file(db_path); + } + #[tokio::test] async fn legacy_pages_receive_nonempty_authorization_generations() { let db_path = std::env::temp_dir().join(format!( diff --git a/src/crates/services/relay-service/src/routes/auth.rs b/src/crates/services/relay-service/src/routes/auth.rs index ae47a51d0d..c7551b3a03 100644 --- a/src/crates/services/relay-service/src/routes/auth.rs +++ b/src/crates/services/relay-service/src/routes/auth.rs @@ -61,6 +61,12 @@ fn valid_login_request_id(value: &str) -> bool { uuid::Uuid::parse_str(value).is_ok() } +/// Absent is legal — clients that predate the field still log in, and their +/// stored kind is left untouched rather than overwritten with a guess. +fn valid_optional_device_kind(value: Option<&str>) -> bool { + value.is_none_or(crate::db::is_valid_device_kind) +} + fn decoy_login_challenge(username: &str) -> LoginChallengeResponse { static SECRET: OnceLock<[u8; 32]> = OnceLock::new(); let secret = SECRET.get_or_init(rand::random); @@ -217,6 +223,10 @@ pub struct LoginRequest { pub password_hash: String, pub device_id: String, pub device_name: String, + /// `desktop` | `mobile` | `watch`. Absent from clients that predate the + /// field; see `device_kind_is_desktop` for how those rows are read. + #[serde(default)] + pub device_kind: Option, #[serde(default)] pub request_id: Option, } @@ -225,6 +235,8 @@ pub struct LoginRequest { pub struct ProvisionDeviceRequest { pub device_id: String, pub device_name: String, + #[serde(default)] + pub device_kind: Option, pub request_id: String, } @@ -410,6 +422,7 @@ pub async fn login( ) -> Result, (StatusCode, Json)> { if !valid_device_id(&body.device_id) || !valid_bounded_text(&body.device_name, MAX_DEVICE_NAME_BYTES) + || !valid_optional_device_kind(body.device_kind.as_deref()) || body .request_id .as_deref() @@ -445,12 +458,19 @@ pub async fn login( ) .await?; - DeviceRow::upsert(db, &body.device_id, &user.user_id, &body.device_name, None) - .await - .map_err(|e| { - tracing::error!("login: failed to upsert device: {e}"); - err("internal error", StatusCode::INTERNAL_SERVER_ERROR) - })?; + DeviceRow::upsert( + db, + &body.device_id, + &user.user_id, + &body.device_name, + body.device_kind.as_deref(), + None, + ) + .await + .map_err(|e| { + tracing::error!("login: failed to upsert device: {e}"); + err("internal error", StatusCode::INTERNAL_SERVER_ERROR) + })?; let token = match body.request_id.as_deref() { Some(request_id) => { @@ -624,6 +644,7 @@ pub async fn provision_device( .bytes() .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) || !valid_bounded_text(&body.device_name, MAX_DEVICE_NAME_BYTES) + || !valid_optional_device_kind(body.device_kind.as_deref()) || !valid_login_request_id(&body.request_id) { return Err(err( @@ -654,11 +675,18 @@ pub async fn provision_device( return Err(err("forbidden", StatusCode::FORBIDDEN)); } + // This route only ever bootstraps a machine over SSH, so an unreported + // kind is a desktop rather than an unknown. let provisioned = AuthToken::provision_new_device( db, &auth.user_id, &body.device_id, body.device_name.trim(), + Some( + body.device_kind + .as_deref() + .unwrap_or(crate::db::DEVICE_KIND_DESKTOP), + ), &body.request_id, ) .await @@ -716,7 +744,7 @@ mod tests { UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") .await .unwrap(); - DeviceRow::upsert(&db, "owner-device", "owner", "Owner", None) + DeviceRow::upsert(&db, "owner-device", "owner", "Owner", None, None) .await .unwrap(); let token = AuthToken::create(&db, "owner", "owner-device") diff --git a/src/crates/services/relay-service/src/routes/devices.rs b/src/crates/services/relay-service/src/routes/devices.rs index c92e427a4d..4d3e751155 100644 --- a/src/crates/services/relay-service/src/routes/devices.rs +++ b/src/crates/services/relay-service/src/routes/devices.rs @@ -8,7 +8,7 @@ //! workspaces/sessions and dispatch tasks, without requiring a direct WS //! connection or proxying through another desktop. -use axum::extract::{Path, State}; +use axum::extract::{DefaultBodyLimit, Path, State}; use axum::http::{header, HeaderMap, StatusCode}; use axum::routing::{delete, get, post}; use axum::{Json, Router}; @@ -28,6 +28,15 @@ const MAX_DEVICE_ID_BYTES: usize = 128; const MAX_ENCRYPTED_PAYLOAD_BYTES: usize = 48 * 1024 * 1024; const MAX_NONCE_BYTES: usize = 256; +/// Body ceiling for `/api/devices/:id/rpc`. +/// +/// Without this the route inherits Axum's 2 MB default, which rejects the body +/// before `is_valid_encrypted_payload` ever runs — so a mobile client sending a +/// photo got a bare 413 while the payload validator above claimed to allow +/// 48 MB. Derived from that constant so the two cannot drift apart again; the +/// slack covers the JSON envelope and the base64 nonce. +const RPC_BODY_LIMIT_BYTES: usize = MAX_ENCRYPTED_PAYLOAD_BYTES + 64 * 1024; + fn is_valid_device_id(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_DEVICE_ID_BYTES @@ -67,7 +76,10 @@ async fn validate_user(state: &AppState, headers: &HeaderMap) -> Result Router { Router::new() .route("/api/devices", get(list_devices)) - .route("/api/devices/{target_device_id}/rpc", post(device_rpc)) + .route( + "/api/devices/{target_device_id}/rpc", + post(device_rpc).layer(DefaultBodyLimit::max(RPC_BODY_LIMIT_BYTES)), + ) .route("/api/devices/{target_device_id}", delete(delete_device)) } @@ -77,11 +89,15 @@ pub fn device_router() -> Router { pub struct DeviceListEntry { pub device_id: String, pub device_name: String, + pub device_kind: Option, pub online: bool, pub last_seen_at: Option, } -/// `GET /api/devices` — list all devices for the account (online + offline). +/// `GET /api/devices` — list the account's remote-control targets. +/// +/// Phones and watches register device rows too (they need one to hold an auth +/// token), but they cannot host a session, so they are never listed here. async fn list_devices( State(state): State, headers: HeaderMap, @@ -96,13 +112,19 @@ async fn list_devices( // Get all registered devices from the DB (online + offline) let mut devices = Vec::new(); + let mut hidden_ids: std::collections::HashSet = std::collections::HashSet::new(); if let Some(db) = &state.db { if let Ok(db_devices) = crate::db::DeviceRow::list_by_user(db, &user_id).await { for row in db_devices { + if !crate::db::device_kind_is_desktop(row.device_kind.as_deref()) { + hidden_ids.insert(row.device_id); + continue; + } let is_online = online_ids.contains(&row.device_id); devices.push(DeviceListEntry { device_id: row.device_id, device_name: row.device_name.unwrap_or_default(), + device_kind: row.device_kind, online: is_online, last_seen_at: row.last_seen_at, }); @@ -110,12 +132,18 @@ async fn list_devices( } } - // Also include any online-only devices not yet in the DB + // Also include any online-only devices not yet in the DB. The in-memory + // registry does not carry a kind, so these are treated like a NULL row — + // except for ids the DB just told us to hide, which must stay hidden. for (id, name) in &online { + if hidden_ids.contains(id) { + continue; + } if !devices.iter().any(|d| &d.device_id == id) { devices.push(DeviceListEntry { device_id: id.clone(), device_name: name.clone(), + device_kind: None, online: true, last_seen_at: None, }); @@ -315,13 +343,13 @@ mod tests { UserRow::create(&db, "other", "bob", "s", "ks", "{}", "hash", "wmk") .await .unwrap(); - DeviceRow::upsert(&db, "owner-device", "owner", "Owner", None) + DeviceRow::upsert(&db, "owner-device", "owner", "Owner", None, None) .await .unwrap(); - DeviceRow::upsert(&db, "target-device", "owner", "Target", None) + DeviceRow::upsert(&db, "target-device", "owner", "Target", None, None) .await .unwrap(); - DeviceRow::upsert(&db, "other-device", "other", "Other", None) + DeviceRow::upsert(&db, "other-device", "other", "Other", None, None) .await .unwrap(); @@ -389,6 +417,119 @@ mod tests { .status() } + async fn listed_device_ids(app: &axum::Router, token: &str) -> Vec { + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/devices") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let entries: Vec = serde_json::from_slice(&body).unwrap(); + entries + .into_iter() + .map(|entry| entry["device_id"].as_str().unwrap().to_string()) + .collect() + } + + async fn rpc( + app: &axum::Router, + token: &str, + device_id: &str, + payload_bytes: usize, + ) -> StatusCode { + let body = serde_json::json!({ + "encrypted_data": "A".repeat(payload_bytes), + "nonce": "AAAA", + }) + .to_string(); + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/devices/{device_id}/rpc")) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap() + .status() + } + + #[tokio::test] + async fn device_list_hides_mobile_devices_and_keeps_unlabeled_rows() { + let ctx = setup_app().await; + DeviceRow::upsert(&ctx.db, "phone", "owner", "HarmonyOS Phone", Some("mobile"), None) + .await + .unwrap(); + DeviceRow::upsert(&ctx.db, "mac", "owner", "MacBook", Some("desktop"), None) + .await + .unwrap(); + + let ids = listed_device_ids(&ctx.app, &ctx.owner_token).await; + + assert!(!ids.contains(&"phone".to_string())); + assert!(ids.contains(&"mac".to_string())); + // owner-device and target-device were registered before the kind + // existed; a NULL kind must still be offered as a control target. + assert!(ids.contains(&"owner-device".to_string())); + assert!(ids.contains(&"target-device".to_string())); + } + + #[tokio::test] + async fn a_login_without_a_kind_does_not_erase_a_known_one() { + let ctx = setup_app().await; + DeviceRow::upsert(&ctx.db, "phone", "owner", "HarmonyOS Phone", Some("mobile"), None) + .await + .unwrap(); + + // An older client build logs in again and reports no kind. + DeviceRow::upsert(&ctx.db, "phone", "owner", "HarmonyOS Phone", None, None) + .await + .unwrap(); + + let ids = listed_device_ids(&ctx.app, &ctx.owner_token).await; + assert!(!ids.contains(&"phone".to_string())); + } + + #[tokio::test] + async fn device_rpc_accepts_payloads_above_the_default_body_limit() { + let ctx = setup_app().await; + + // A phone sending a photo lands here: base64 of the image, encrypted and + // base64ed again, clears Axum's 2 MB default by itself. NOT_FOUND means + // the body was read and the offline target was the only complaint. + let status = rpc(&ctx.app, &ctx.owner_token, "target-device", 3 * 1024 * 1024).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn device_rpc_still_rejects_payloads_past_the_route_limit() { + let ctx = setup_app().await; + + let status = rpc( + &ctx.app, + &ctx.owner_token, + "target-device", + RPC_BODY_LIMIT_BYTES + 1, + ) + .await; + + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + } + #[tokio::test] async fn deleting_owned_device_revokes_token_before_device_row() { let ctx = setup_app().await; diff --git a/src/crates/services/relay-service/src/routes/pages.rs b/src/crates/services/relay-service/src/routes/pages.rs index 7f3f35121a..60d1214f90 100644 --- a/src/crates/services/relay-service/src/routes/pages.rs +++ b/src/crates/services/relay-service/src/routes/pages.rs @@ -3093,10 +3093,10 @@ mod tests { UserRow::create(&pool, "u2", "bob", "s", "ks", "{}", "hash", "wmk") .await .unwrap(); - DeviceRow::upsert(&pool, "d1", "u1", "Laptop", None) + DeviceRow::upsert(&pool, "d1", "u1", "Laptop", None, None) .await .unwrap(); - DeviceRow::upsert(&pool, "d2", "u2", "Phone", None) + DeviceRow::upsert(&pool, "d2", "u2", "Phone", None, None) .await .unwrap(); let tok_alice = AuthToken::create(&pool, "u1", "d1").await.unwrap(); diff --git a/src/crates/services/relay-service/src/routes/websocket.rs b/src/crates/services/relay-service/src/routes/websocket.rs index a1a5fa1181..3a848c0d0d 100644 --- a/src/crates/services/relay-service/src/routes/websocket.rs +++ b/src/crates/services/relay-service/src/routes/websocket.rs @@ -88,6 +88,10 @@ pub enum InboundMessage { AuthConnect { token: String, device_name: String, + /// `desktop` | `mobile` | `watch`. Absent from clients that predate + /// the field, which leaves the stored kind untouched. + #[serde(default)] + device_kind: Option, }, /// Route an encrypted payload to another device in the same account. DeviceMessage { @@ -540,7 +544,11 @@ async fn handle_text_message( } } - InboundMessage::AuthConnect { token, device_name } => { + InboundMessage::AuthConnect { + token, + device_name, + device_kind, + } => { if state.room_manager.has_connection(conn_id) || state.device_manager.has_connection(conn_id) { @@ -548,6 +556,9 @@ async fn handle_text_message( } if !crate::db::is_valid_auth_token(&token) || !is_valid_display_text(&device_name, MAX_DEVICE_NAME_BYTES) + || device_kind + .as_deref() + .is_some_and(|kind| !crate::db::is_valid_device_kind(kind)) { return reject_protocol(out_tx, "invalid authentication parameters"); } @@ -598,6 +609,7 @@ async fn handle_text_message( &auth.user_id, &auth.device_id, &device_name, + device_kind.as_deref(), conn_id, ) .await @@ -853,6 +865,7 @@ async fn activate_pending_device_if_authorized( expected_user_id: &str, expected_device_id: &str, device_name: &str, + device_kind: Option<&str>, conn_id: ConnId, ) -> anyhow::Result { // Serialize the final token lookup, durable device update, activation, and @@ -872,9 +885,15 @@ async fn activate_pending_device_if_authorized( return Ok(false); } - if let Err(error) = - crate::db::DeviceRow::upsert(db, expected_device_id, expected_user_id, device_name, None) - .await + if let Err(error) = crate::db::DeviceRow::upsert( + db, + expected_device_id, + expected_user_id, + device_name, + device_kind, + None, + ) + .await { device_manager.disconnect_pending(conn_id); return Err(error); @@ -1006,7 +1025,7 @@ mod tests { UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") .await .unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); let token = AuthToken::create(&db, "owner", "device-a") @@ -1037,7 +1056,7 @@ mod tests { .unwrap() ); assert!(!activate_pending_device_if_authorized( - &db, &manager, &token, "owner", "device-a", "Device A", 1, + &db, &manager, &token, "owner", "device-a", "Device A", None, 1, ) .await .unwrap()); @@ -1052,7 +1071,7 @@ mod tests { UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") .await .unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); DeviceRow::set_online(&db, "owner", "device-a", true) @@ -1105,7 +1124,7 @@ mod tests { .await .unwrap(); assert!(!activate_pending_device_if_authorized( - &db, &manager, &token, "owner", "device-a", "Device A", 2, + &db, &manager, &token, "owner", "device-a", "Device A", None, 2, ) .await .unwrap()); @@ -1137,10 +1156,10 @@ mod tests { UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") .await .unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); - DeviceRow::upsert(&db, "device-c", "owner", "Device C", None) + DeviceRow::upsert(&db, "device-c", "owner", "Device C", None, None) .await .unwrap(); DeviceRow::set_online(&db, "owner", "device-a", true) @@ -1230,7 +1249,7 @@ mod tests { UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") .await .unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); let token = AuthToken::create(&db, "owner", "device-a") @@ -1248,7 +1267,7 @@ mod tests { .await .unwrap() ); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); DeviceRow::set_online(&db, "owner", "device-a", true) @@ -1281,7 +1300,7 @@ mod tests { UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") .await .unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); let token = AuthToken::create(&db, "owner", "device-a") @@ -1299,7 +1318,7 @@ mod tests { manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); assert!(!activate_pending_device_if_authorized( - &db, &manager, &token, "owner", "device-a", "Device A", 1, + &db, &manager, &token, "owner", "device-a", "Device A", None, 1, ) .await .unwrap()); @@ -1317,7 +1336,7 @@ mod tests { UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") .await .unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); let token = AuthToken::create(&db, "owner", "device-a") @@ -1332,7 +1351,7 @@ mod tests { assert!(manager.online_devices("owner").is_empty()); assert!(!manager.route_message("owner", "device-a", "opaque")); assert!(activate_pending_device_if_authorized( - &db, &manager, &token, "owner", "device-a", "Device A", 1, + &db, &manager, &token, "owner", "device-a", "Device A", None, 1, ) .await .unwrap()); diff --git a/src/crates/services/services-integrations/src/remote_connect/account.rs b/src/crates/services/services-integrations/src/remote_connect/account.rs index 9f5ff0693e..e6b6313d8e 100644 --- a/src/crates/services/services-integrations/src/remote_connect/account.rs +++ b/src/crates/services/services-integrations/src/remote_connect/account.rs @@ -478,6 +478,7 @@ impl AccountClient { "password_hash": password_hash, "device_id": device.device_id, "device_name": device.device_name, + "device_kind": "desktop", "request_id": uuid::Uuid::new_v4().to_string(), }); let request = self @@ -865,6 +866,11 @@ impl AccountClient { } /// Register a new account device and mint its full routing token. + /// + /// `device_kind` is what keeps the minted row out of the wrong lists: this + /// route serves both an SSH host being bootstrapped (`"desktop"`) and a + /// keyboard-less peer that cannot type a password (`"watch"`), and only the + /// caller knows which one it is holding. /// `request_id` makes an ambiguous HTTP response safe to replay. pub async fn provision_device_token( &self, @@ -872,11 +878,13 @@ impl AccountClient { session: &AccountSession, device_id: &str, device_name: &str, + device_kind: &str, request_id: uuid::Uuid, ) -> Result { let body = serde_json::json!({ "device_id": device_id, "device_name": device_name, + "device_kind": device_kind, "request_id": request_id.to_string(), }); let resp = send_with_retry( diff --git a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs index e7fad23361..4dd59c9030 100644 --- a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs +++ b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs @@ -64,6 +64,7 @@ pub enum RelayMessage { AuthConnect { token: String, device_name: String, + device_kind: String, }, /// Route an encrypted payload to another device in the same account. DeviceMessage { @@ -354,6 +355,7 @@ impl RelayClient { let reauth = RelayMessage::AuthConnect { token: ctx.token.clone(), device_name: ctx.device_name.clone(), + device_kind: "desktop".to_string(), }; let _ = new_cmd_tx.send(reauth); info!("Re-sent AuthConnect after reconnect"); @@ -534,9 +536,12 @@ impl RelayClient { } drop(guard); + // Only desktops hold a relay WebSocket — phones and watches talk HTTP — + // so the kind is a constant here rather than a parameter. self.send(RelayMessage::AuthConnect { token: token.to_string(), device_name: device_name.to_string(), + device_kind: "desktop".to_string(), }) .await }