From 62e1e63688bde276fb5a09c4b65d86071e41f7e2 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 10 Aug 2026 22:52:44 +0800 Subject: [PATCH 1/4] refactor(harmonyos): centralize composer action policy --- .../main/ets/pages/components/ComposerBar.ets | 37 +++++++----- .../main/ets/services/ChatComposerPolicy.ets | 57 +++++++++++++++++++ 2 files changed, 80 insertions(+), 14 deletions(-) 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 2c0aceb71..a64d509e4 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 { @@ -362,26 +362,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) @@ -513,8 +508,18 @@ export struct ComposerBar { ChatComposerPolicy.canUseVoice(this.inputText, this.selectedImages.length, this.isBusy); } - private hasComposedContent(): boolean { - return this.inputText.trim().length > 0 || this.selectedImages.length > 0; + // Dictation runs inside this app rather than in a system dialog, so listening + // outranks the draft here the same way a running turn does on both clients. + private primaryAction(): ComposerPrimaryAction { + return ChatComposerPolicy.primaryAction( + this.inputText, + this.selectedImages.length, + this.isBusy, + this.isVoiceListening || this.canStop, + this.capabilities.requiresRemoteConnection, + this.connectionState, + this.capabilities.showVoiceInput + ); } private shouldShowAddButton(): boolean { @@ -523,8 +528,12 @@ export struct ComposerBar { } 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.showQuickActions, + this.isModelSelectorExpanded() + ); } private isModelSelectorExpanded(): boolean { 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 36a5ab165..6fd8a8930 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,46 @@ 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. `isStopping` folds in whatever locally outranks the draft: a + // running turn on both clients, plus dictation on this one. + static primaryAction( + text: string, + attachmentCount: number, + isBusy: boolean, + isStopping: boolean, + requiresRemoteConnection: boolean, + connectionState: string, + showVoiceInput: boolean + ): ComposerPrimaryAction { + if (isStopping) { + 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 (!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, + quickActionsOpen: boolean, + modelSelectorOpen: boolean + ): boolean { + return inputFocused || quickActionsOpen || modelSelectorOpen || text.indexOf('\n') >= 0; + } } From 8271bb6eae5e489af99084859d8feab1d044b37a Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 11 Aug 2026 15:04:34 +0800 Subject: [PATCH 2/4] fix(harmonyos): make the conversation screen usable during a run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six things the session screen got wrong on device, all reported together. They land in one commit because they overlap in the same few files. The timeline never followed streaming output. `scrollEdge(Edge.Bottom)` only existed inside the floating button's `onClick`, and the list was explicitly `stackFromEnd(false)`, so a bubble growing every 40ms grew below the viewport. Flipping `stackFromEnd` handles the growth case; a `timelineRevision` monitor covers whole-message inserts, and a `stickToBottom` flag reads `isAtEnd()` so scrolling back through history is not fought by the follow. The composer locked to Stop for the whole run. `isStopping` folded dictation and a running turn into one flag, but they sit on opposite sides of the draft: dictation outranks it because the draft is still being spoken, a running turn only outranks it where the draft has nowhere to go. Split into `isVoiceListening` and `isTurnRunning`; remote sessions can hand a message over mid-run, so a draft now wins there and the relay queues it. Stop did not stop. Between sending a message and the server's turn_id arriving, `remoteActiveTurnId()` sliced the local `active-pending-` into `pending-` and sent it as a turn_id. The server read that as `StaleRequestedTurn` and kept running. It now returns '' in that window, which the server resolves as CancelCurrent. The failure was also invisible — it went to `statusText`, which only renders while disconnected — so the stop path now reports through the existing toast channel. AskUserQuestion options were cut off. `OptionRow` was pinned to 36px with no `maxLines` on the label and no `layoutWeight` on the description, so a long option lost its tail with nothing to indicate it. The row now grows with its content and label/description stack instead of competing for one line. Picked images rendered outside the input. They now sit in a strip inside the composer card, left-aligned — `Scroll` centres content narrower than its viewport, which parked a lone thumbnail mid-composer. The plus button opened an attachment panel offering quick prompts and a single image entry. A message can only carry images (`RemoteCommand::SendMessage` has no file channel), so a menu there was a menu of one; plus now opens the album directly, matching local chat. The panel and its orphaned strings are gone. Image encoding also got a size budget, since the transport doubles what it is handed: base64 into the command JSON, then base64 again after AES-GCM. --- .../entry/src/main/ets/i18n/RemoteI18n.ets | 13 +- .../entry/src/main/ets/model/RemoteModels.ets | 7 +- .../components/ChatComposerCapabilities.ets | 11 +- .../ets/pages/components/ChatTimeline.ets | 32 ++- .../main/ets/pages/components/ComposerBar.ets | 105 +++++----- .../pages/components/ConversationUiModels.ets | 26 ++- .../ets/pages/components/ConversationView.ets | 160 --------------- .../components/ToolInteractionPanels.ets | 184 +++++++++++++++--- .../ets/pages/components/ToolStatusList.ets | 19 +- .../components/remote/RemoteSurfaceHost.ets | 2 +- .../runtime/AppRootRuntimeComposition.ets | 34 ++-- .../ets/pages/state/ConversationViewState.ets | 3 + .../viewmodel/ConversationController.ets | 19 +- .../viewmodel/RemoteSessionViewModel.ets | 89 ++++++++- .../main/ets/services/ChatComposerPolicy.ets | 22 ++- .../ets/services/ChatTimelineProjector.ets | 60 ++++-- .../main/ets/services/ChatTimelineStore.ets | 51 ++++- .../main/ets/services/CloudAccountClient.ets | 15 +- .../main/ets/services/ImagePickerService.ets | 15 +- .../services/RemoteChatCommandController.ets | 22 ++- .../ets/services/RemoteSessionController.ets | 6 +- .../services/RemoteToolActionController.ets | 9 + .../ets/services/WatchProvisionController.ets | 10 +- .../src/test/ConversationStateUnit.test.ets | 58 +++++- .../src/test/RemoteControllersUnit.test.ets | 75 ++++++- .../test/TransportAndGeneralChatUnit.test.ets | 47 ++++- 26 files changed, 775 insertions(+), 319 deletions(-) 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 961dd743b..c7f2ff8d4 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', '重试'], @@ -391,17 +392,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', '正在思考'], @@ -548,6 +540,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 dc229cc3a..cef4f8673 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/components/ChatComposerCapabilities.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets index 6edfc38e4..b2f27d048 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 79ad92a1c..a498fbcd5 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 @@ -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,16 @@ export struct ChatTimeline { @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; + // `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 }) { @@ -57,10 +72,22 @@ export struct ChatTimeline { .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 +100,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/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index a64d509e4..5e32670d5 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 @@ -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(); }) } @@ -388,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(); } }) @@ -461,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('×') @@ -489,18 +499,8 @@ export struct ComposerBar { this.onRemoveImage(image.id); }) } - .width(64) - .height(64) - } - - private canSend(): boolean { - return ChatComposerPolicy.canSend( - this.inputText, - this.selectedImages.length, - this.isBusy, - this.capabilities.requiresRemoteConnection, - this.connectionState - ); + .width(COMPOSER_IMAGE_CARD_SIZE) + .height(COMPOSER_IMAGE_CARD_SIZE) } private canUseVoice(): boolean { @@ -509,13 +509,15 @@ export struct ComposerBar { } // Dictation runs inside this app rather than in a system dialog, so listening - // outranks the draft here the same way a running turn does on both clients. + // 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.isVoiceListening, + this.canStop, + this.capabilities.supportsMidRunSend, this.capabilities.requiresRemoteConnection, this.connectionState, this.capabilities.showVoiceInput @@ -527,11 +529,24 @@ export struct ComposerBar { (this.capabilities.supportsAttachments || this.capabilities.surface === ChatSurface.General); } + // 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; + } + + // 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 ChatComposerPolicy.isExpanded( this.inputText, this.inputFocused, - this.showQuickActions, this.isModelSelectorExpanded() ); } @@ -599,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/ConversationUiModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationUiModels.ets index 0e656fc1a..5de53cba7 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 db3f0fc7f..4399eaeef 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 @@ -90,8 +90,6 @@ 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; @@ -140,10 +138,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 +167,6 @@ export struct ConversationView { this.onBack(); }, onOpenActions: () => { - this.showQuickActions = false; this.showHeaderActions = !this.showHeaderActions; }, onActionsMenuStateChange: (visible: boolean) => { @@ -202,7 +195,6 @@ export struct ConversationView { this.onRestoreSidebar(); }, onOpenActions: () => { - this.showQuickActions = false; this.showHeaderActions = !this.showHeaderActions; }, onActionsMenuStateChange: (visible: boolean) => { @@ -330,10 +322,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 +353,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 +422,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/ToolInteractionPanels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets index 9af587e2a..8b930354b 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 bb8c2ed7e..fa8f893ae 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 11f1f41e0..08b3e4eae 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 @@ -89,7 +89,7 @@ export struct RemoteSurfaceHost { showStatusMetadata: this.presentationState.showStatusMetadata, hasMoreSessions: this.remotePageState.hasMoreSessions, isBusy: this.remotePageState.conversation.isBusy || this.remotePageState.isLoadingSessions, - selectedSessionId: this.remotePageState.pendingSessionId.length > 0 ? + selectedSessionId: this.remotePageState.isLoadingConversation ? this.remotePageState.pendingSessionId : (this.showSelectedSession ? this.remotePageState.conversation.activeSession.sessionId : ''), onCreate: () => this.createSession('code'), 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 99989467e..28c769dcd 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 @@ -70,7 +70,10 @@ 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 { + WatchProvisionController, + WatchProvisionPort +} from '../../services/WatchProvisionController'; import { PeerDeviceProvisionOutcome } from '../../services/RelayHttpClient'; import { ConversationViewModel } from '../viewmodel/ConversationViewModel'; import { FilePreviewState } from '../state/FilePreviewState'; @@ -151,17 +154,18 @@ export abstract class AppRootRuntimeComposition { readonly remotePageState: RemotePageState = new RemotePageState(); readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); readonly watchProvisionState: WatchProvisionState = new WatchProvisionState(); + private readonly watchProvisionPort: WatchProvisionPort = { + // 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() + }; 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); readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); readonly generalChatController: GeneralChatController = GeneralChatController.createDefault(this.generalChatConfigStore); @@ -404,11 +408,14 @@ 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(); } } ); @@ -452,7 +459,7 @@ export abstract class AppRootRuntimeComposition { this.remotePageState.setBusy(isBusy); }, () => { - this.remoteChatPollingLifecycleController.pollNow(); + this.remoteChatPollingLifecycleController.nudge(); } ); readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = @@ -815,4 +822,3 @@ export abstract class AppRootRuntimeComposition { } - 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 a555f25aa..fc137e964 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/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets index b43aa9661..14c4a6768 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'; @@ -260,12 +260,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 ? @@ -434,11 +441,7 @@ export class ConversationController { } 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[] { @@ -1014,7 +1017,7 @@ export class ConversationController { const runtime = this.requireRemoteRuntime(); runtime.filePreview.close(); this.remote.setConversationDismissed(false); - if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { + if (runtime.appShell.isRoute(AppRoute.RemoteCreate) || runtime.appShell.isRoute(AppRoute.RemoteChat)) { runtime.appShell.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); return; } 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 42ab00f63..d29c1b362 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 @@ -81,7 +81,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(), @@ -109,14 +131,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( 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 6fd8a8930..eb533f896 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 @@ -34,18 +34,26 @@ export class ChatComposerPolicy { // 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. `isStopping` folds in whatever locally outranks the draft: a - // running turn on both clients, plus dictation on this one. + // 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, - isStopping: boolean, + isVoiceListening: boolean, + isTurnRunning: boolean, + supportsMidRunSend: boolean, requiresRemoteConnection: boolean, connectionState: string, showVoiceInput: boolean ): ComposerPrimaryAction { - if (isStopping) { + if (isVoiceListening) { + return ComposerPrimaryAction.Stop; + } + if (isTurnRunning && !supportsMidRunSend) { return ComposerPrimaryAction.Stop; } if (text.trim().length > 0 || attachmentCount > 0) { @@ -53,6 +61,9 @@ export class ChatComposerPolicy { text, attachmentCount, isBusy, requiresRemoteConnection, connectionState); return sendable ? ComposerPrimaryAction.Send : ComposerPrimaryAction.SendBlocked; } + if (isTurnRunning) { + return ComposerPrimaryAction.Stop; + } if (!showVoiceInput) { return ComposerPrimaryAction.Idle; } @@ -67,9 +78,8 @@ export class ChatComposerPolicy { static isExpanded( text: string, inputFocused: boolean, - quickActionsOpen: boolean, modelSelectorOpen: boolean ): boolean { - return inputFocused || quickActionsOpen || modelSelectorOpen || text.indexOf('\n') >= 0; + return inputFocused || modelSelectorOpen || text.indexOf('\n') >= 0; } } 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 05a894ef7..7d3d44fc9 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 @@ -52,15 +52,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 +81,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 +98,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 +118,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 691a619a4..ef90f6c17 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 1ae3c6919..05b7e07ff 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 { @@ -199,6 +200,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`, @@ -296,11 +298,20 @@ 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); 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/ImagePickerService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ImagePickerService.ets index 524ab49dd..397aaa6c4 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/RemoteChatCommandController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets index 4791ede63..abd96a0c2 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,7 @@ import { } from '../model/RemoteModels'; import { RemoteI18n } from '../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; +import { RemoteLogger } from './RemoteLogger'; export interface RemoteChatCommandClient { getSessionMessages(sessionId: string): Promise; @@ -34,6 +35,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; } @@ -47,6 +51,11 @@ export class RemoteChatCommandController { this.callbacks = callbacks; } + private static shortId(value: string): string { + if (value.length <= 10) return value; + return `${value.slice(0, 6)}...${value.slice(-4)}`; + } + async loadMessages( sessionId: string, canApply: (sessionId: string) => boolean @@ -129,20 +138,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/RemoteSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets index 8bbcd94ef..bb20f0c3d 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 @@ -132,9 +132,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 +148,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); } 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 0112cf310..de4ddd166 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/WatchProvisionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets index 1f4ee28a8..95deacf23 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 @@ -16,9 +16,13 @@ const DATASYNC_PERMISSION: Permissions = 'ohos.permission.DISTRIBUTED_DATASYNC'; /** 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; + readonly canProvision: () => boolean; + readonly provision: ( + deviceId: string, + deviceName: string, + requestId: string + ) => Promise; + readonly relayUrl: () => string; } /** 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 57de0bb8d..9ee6f8737 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'); 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 471cc8ef7..99686af0c 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -1239,7 +1239,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 +1248,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 +1256,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(); @@ -1338,6 +1360,7 @@ export default function remoteControllersUnitTest() { onStatusText: (statusText: string) => { statuses.push(statusText); }, + onToast: (_message: string) => {}, onBusy: (_isBusy: boolean) => {}, onPollRequested: () => {} }); @@ -1386,6 +1409,7 @@ export default function remoteControllersUnitTest() { onActiveSession: (_session: SessionSummary) => {}, onSessionTitleChanged: (_sessionId: string, _title: string) => {}, onStatusText: (_statusText: string) => {}, + onToast: (_message: string) => {}, onBusy: (isBusy: boolean) => { busyEvents.push(isBusy); }, @@ -1425,6 +1449,7 @@ export default function remoteControllersUnitTest() { onActiveSession: (_session: SessionSummary) => {}, onSessionTitleChanged: (_sessionId: string, _title: string) => {}, onStatusText: (_statusText: string) => {}, + onToast: (_message: string) => {}, onBusy: (isBusy: boolean) => { busyEvents.push(isBusy); }, @@ -1474,6 +1499,7 @@ 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) => {}, @@ -1490,6 +1516,9 @@ export default function remoteControllersUnitTest() { onStatusText: (statusText: string) => { statuses.push(statusText); }, + onToast: (message: string) => { + toasts.push(message); + }, onBusy: (_isBusy: boolean) => {}, onPollRequested: () => { pollCount += 1; @@ -1504,6 +1533,44 @@ 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) => {}, + 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: () => {} + }); + + 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 () => { @@ -1528,6 +1595,7 @@ export default function remoteControllersUnitTest() { titleUpdates.push(`${sessionId}:${title}`); }, onStatusText: (_statusText: string) => {}, + onToast: (_message: string) => {}, onBusy: (isBusy: boolean) => { busyEvents.push(isBusy); }, @@ -1563,12 +1631,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(); }); }); 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 d083ef655..22dcfcb62 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', () => { From f7d109a26798193528c2af2411cc1ecca8adac3f Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 11 Aug 2026 15:05:10 +0800 Subject: [PATCH 3/4] fix(relay): let device rpc carry payloads its validator already allows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_valid_encrypted_payload` caps the ciphertext at 48 MB, but the route inherited Axum's 2 MB `DefaultBodyLimit`, which rejects the body before the handler — including its auth check — ever runs. A phone attaching a photo got a bare 413 from a route that claimed to accept 24x that. The limit is derived from `MAX_ENCRYPTED_PAYLOAD_BYTES` so the two cannot drift apart again, with slack for the JSON envelope and the base64 nonce. Two tests pin both ends: a 3 MB body reaches the handler, and one past the route ceiling is still refused. --- .../relay-service/src/routes/devices.rs | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/src/crates/services/relay-service/src/routes/devices.rs b/src/crates/services/relay-service/src/routes/devices.rs index c92e427a4..c100f0104 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)) } @@ -389,6 +401,59 @@ mod tests { .status() } + 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_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; From 636e74011a3dc32b99cccaa062e66d54fdd56b5c Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 13 Aug 2026 09:15:52 +0800 Subject: [PATCH 4/4] perf(mobile): make switching remote sessions feel instant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a session left the old transcript on screen, then a spinner, then the new one — about 1.6s of that was the UI thread refusing to paint anything at all, and it did not vary with transcript size (a 2-message session blocked 1163ms, a 12-message one 1159ms). A constant that ignores the payload is not the payload's fault. hitrace found it in the sidebar. Of a 1424.7ms FlushDirtyNodeUpdate, 1370.5ms was two RemoteSessionList ExecuteRerender slices with no child component slices beneath them, so the cost was JS inside the builder rather than layout. Three causes compounded there: The builder re-derived everything on every pass. filteredSessions() is reached roughly twenty times per build — the root, five times via visibleChatSessions(), several through projectEntries()/projectSessions(path), three per row via metadataText(item) — and each one rescanned the whole array, re-deciding isAssistantSession by searching recentWorkspaces. Selecting a row changes none of those inputs. SessionListProjection now derives the chat list, the project buckets, the day buckets and the row metadata in a single pass, with assistant workspaces hoisted into a Set and timestamps parsed once instead of inside a comparator. A cache in front of it answers from the last projection unless an input actually moved; when the contents match behind a fresh array it adopts the new reference so the next call takes the identity path. Relative timestamps mix the current minute into the key only while that column is switched on. visibleSessions() returned a new array per call and fed a @Param directly, so every parent rebuild marked the list dirty — including the selection-change rebuild, the one case where the list has not moved. Wide layouts kept a second, invisible RemoteSessionList mounted in the drawer behind an opacity of 0. It rebuilt along with the visible one, which is the second 679ms slice. Compact layouts still keep it mounted so the drawer can cross-fade. Two transport-side fixes sit underneath. The desktop answered device RPCs one at a time on the routing loop, so a command the webview took its time with (up to the 120s invoke timeout) stalled every other device behind it — a watch ping could wait 40s for no reason of its own. Each RPC now runs spawned under its own routing lease and correlation id, bounded by a semaphore so one client's burst cannot crowd out the next client's first request. And Encoding copied buffers a byte at a time where a single bulk Uint8Array copy does. Around that: transcripts and the session list get an RDB-backed store plus a small resident LRU, a cache hit skips the skeleton entirely, a loading state is deferred 140ms so short loads do not flash one, and stale responses from a session that has already been switched away from are dropped instead of applied. Measured on device, tap to settled: first open 2.15s -> 97ms, session to session 3.02s -> 8ms, resident-cache hit 2ms, uncached 663ms of which 656ms is the relay round trip. No blocked_ms stall was logged anywhere in the run. Four switches fired 400ms apart all serviced immediately, and the poll that returned after its session was abandoned did not overwrite the session that replaced it. Also here, because it lands in the same client and login path: devices report a kind (desktop, mobile, watch) when they register, and only desktops are offered as remote-control targets. A NULL kind predates client reporting and reads as a desktop, since hiding a real desktop breaks remote control outright while a stale phone row corrects itself at its next login. The shared-core and Android halves of that login change are not in this branch; they ship with the Android client, and the relay accepts the field as optional so either side can land first. Co-Authored-By: Claude Opus 5 --- scripts/check-harmonyos-architecture.mjs | 1 - .../desktop/src/api/remote_connect_api.rs | 92 ++- .../entry/src/main/ets/i18n/RemoteI18n.ets | 4 +- .../actions/AppRootPresentationActions.ets | 3 +- .../components/AppRootOverlaySurfaces.ets | 6 +- .../pages/components/AppRootPresentation.ets | 1 + .../main/ets/pages/components/AppShell.ets | 9 +- .../ets/pages/components/ChatTimeline.ets | 15 +- .../pages/components/CompactMenuButton.ets | 14 +- .../main/ets/pages/components/ConnectView.ets | 18 +- .../ets/pages/components/ConversationView.ets | 3 + .../pages/components/GeneralChatHeader.ets | 23 +- .../ets/pages/components/RemoteChatHeader.ets | 23 +- .../components/RemoteControlSettingsSheet.ets | 32 +- .../components/RemoteCreateSessionView.ets | 12 +- .../pages/components/RemoteSessionList.ets | 151 ++--- .../ets/pages/components/SidebarGlyphs.ets | 9 +- .../ets/pages/components/TemplateIcon.ets | 36 ++ .../components/remote/RemoteSurfaceHost.ets | 77 ++- .../pages/policy/ConnectOpenIntentPolicy.ets | 45 ++ .../ConversationSessionFilterPolicy.ets | 3 +- .../pages/policy/SessionListProjection.ets | 373 ++++++++++++ .../main/ets/pages/runtime/AppRootRuntime.ets | 103 +++- .../runtime/AppRootRuntimeComposition.ets | 119 +++- .../main/ets/pages/state/AppShellState.ets | 22 +- .../main/ets/pages/state/RemotePageState.ets | 23 +- .../ets/pages/viewmodel/AppShellViewModel.ets | 14 +- .../viewmodel/ConversationController.ets | 74 ++- .../viewmodel/RemoteActivityViewModel.ets | 60 +- .../viewmodel/RemoteConnectionController.ets | 12 +- .../viewmodel/RemoteSessionViewModel.ets | 77 ++- .../pages/viewmodel/SettingsController.ets | 144 ++++- .../ets/services/ChatSessionController.ets | 15 +- .../ets/services/ChatTimelineProjector.ets | 30 +- .../main/ets/services/CloudAccountClient.ets | 210 ++++--- .../main/ets/services/DeferredLoadingGate.ets | 69 +++ .../entry/src/main/ets/services/Encoding.ets | 32 +- .../ets/services/MainThreadStallProbe.ets | 58 ++ .../RemoteActivityLifecycleController.ets | 2 +- .../src/main/ets/services/RemoteChatCache.ets | 276 +++++++++ .../services/RemoteChatCommandController.ets | 119 +++- .../ets/services/RemoteChatLocalRdbStore.ets | 243 ++++++++ .../services/RemoteHeartbeatController.ets | 27 +- .../ets/services/RemoteSessionController.ets | 20 +- .../ets/services/RemoteSessionListCache.ets | 169 ++++++ .../services/RemoteSessionListRdbStore.ets | 126 +++++ .../services/RemoteWorkspaceCoordinator.ets | 43 +- .../main/ets/services/WatchHandoffStore.ets | 114 +++- .../ets/services/WatchProvisionController.ets | 48 +- .../entry/src/test/ArchitectureUnit.test.ets | 13 + .../src/test/ConversationStateUnit.test.ets | 33 ++ .../entry/src/test/LifecycleUnit.test.ets | 30 +- .../entry/src/test/LocalTestFixtures.ets | 198 +++++++ .../src/test/RemoteControllersUnit.test.ets | 532 +++++++++++++++++- src/crates/services/relay-service/src/db.rs | 96 +++- .../services/relay-service/src/routes/auth.rs | 42 +- .../relay-service/src/routes/devices.rs | 86 ++- .../relay-service/src/routes/pages.rs | 4 +- .../relay-service/src/routes/websocket.rs | 51 +- .../src/remote_connect/account.rs | 8 + .../src/remote_connect/relay_client.rs | 5 + 61 files changed, 3805 insertions(+), 492 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/TemplateIcon.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConnectOpenIntentPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/DeferredLoadingGate.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/MainThreadStallProbe.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCache.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatLocalRdbStore.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListCache.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionListRdbStore.ets diff --git a/scripts/check-harmonyos-architecture.mjs b/scripts/check-harmonyos-architecture.mjs index 2ccdeff49..46eed36b8 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 ce677630c..25e9dcddc 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 c7f2ff8d4..7085e03a0 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 @@ -228,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', '设备管理'], @@ -489,6 +486,7 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['status.sessionUnarchived', '会话已取消归档'], ['status.sessionExported', '已复制为 Markdown'], ['status.messagesSynced', '消息已同步'], + ['status.messagesRestored', '已恢复本地会话记录'], ['status.switchingModel', '正在切换模型'], ['status.modelSwitched', '模型已切换'], ['status.loadOlderMessages', '加载更早消息'], 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 63fecd397..8b7cea8c3 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 734b9f7ea..018255e63 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 cd5564c45..394d50871 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 1d71ccca6..770503e23 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/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index a498fbcd5..e2dbc9f92 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'; @@ -39,6 +39,17 @@ 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') @@ -67,7 +78,7 @@ 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%') 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 ab6f189da..b1ddaecdc 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/ConnectView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets index b01a2402d..986cc21b8 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/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 4399eaeef..a551a782c 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'; @@ -94,11 +95,13 @@ export struct ConversationView { 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); } 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 0382f2db6..8deb0fb9c 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 539ea93aa..40c69d054 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 3396da0e9..c3cda2916 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 a11ab7ee5..130cfd836 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 bfca15020..5f74954af 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 af5a2504d..11bdba719 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 000000000..86d16c223 --- /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/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index 08b3e4eae..0df472ef2 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,7 +99,10 @@ export struct RemoteSurfaceHost { showStatusMetadata: this.presentationState.showStatusMetadata, hasMoreSessions: this.remotePageState.hasMoreSessions, isBusy: this.remotePageState.conversation.isBusy || this.remotePageState.isLoadingSessions, - selectedSessionId: this.remotePageState.isLoadingConversation ? + // 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 : ''), onCreate: () => this.createSession('code'), @@ -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 000000000..70973453d --- /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 0ab0183dd..9ef633a22 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 000000000..3085e8af4 --- /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 75f39fa25..500169c05 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 28c769dcd..8272811c5 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'; @@ -72,9 +76,9 @@ import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; import { WatchProvisionState } from '../state/WatchProvisionState'; import { WatchProvisionController, + WatchProvisionOutcome, WatchProvisionPort } from '../../services/WatchProvisionController'; -import { PeerDeviceProvisionOutcome } from '../../services/RelayHttpClient'; import { ConversationViewModel } from '../viewmodel/ConversationViewModel'; import { FilePreviewState } from '../state/FilePreviewState'; import { FilePreviewRequest } from '../../model/FilePreviewTarget'; @@ -120,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; @@ -155,17 +160,70 @@ export abstract class AppRootRuntimeComposition { readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); readonly watchProvisionState: WatchProvisionState = new WatchProvisionState(); private readonly watchProvisionPort: WatchProvisionPort = { - // 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() + // 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, 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); @@ -184,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, @@ -238,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( @@ -248,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), @@ -255,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 => { @@ -332,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); @@ -375,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); @@ -417,7 +500,8 @@ export abstract class AppRootRuntimeComposition { onPollRequested: () => { this.remoteChatPollingLifecycleController.nudge(); } - } + }, + this.remoteChatCache ); readonly remoteFileDownloadController: RemoteFileDownloadController = new RemoteFileDownloadController( @@ -518,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(); }, @@ -572,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) @@ -601,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), @@ -619,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, @@ -780,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(), 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 e34e22c5d..6301298b0 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/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index 3284ff69f..e72fe6868 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 2b723f14d..889c48082 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 14c4a6768..74b5ef25b 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 @@ -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( @@ -411,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; @@ -435,6 +436,44 @@ 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'; @@ -1002,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; } @@ -1017,7 +1063,13 @@ export class ConversationController { const runtime = this.requireRemoteRuntime(); runtime.filePreview.close(); this.remote.setConversationDismissed(false); - if (runtime.appShell.isRoute(AppRoute.RemoteCreate) || runtime.appShell.isRoute(AppRoute.RemoteChat)) { + 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 8228df45d..632e3d468 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 40e66955c..d1e4ebd7c 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 d29c1b362..947ee6a13 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, @@ -113,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 @@ -201,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, @@ -222,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(); @@ -230,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)}`); } } @@ -245,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(); @@ -276,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 02e90e9f0..6f04721a4 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/ChatSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets index 84837768f..ee974c5e5 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 7d3d44fc9..ef2cdca7b 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; 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 05b7e07ff..c00e33d72 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 @@ -19,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 { @@ -41,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 { @@ -97,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(); @@ -125,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 { @@ -170,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 })); @@ -221,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); @@ -288,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}`; @@ -306,6 +356,12 @@ export class CloudAccountClient { } 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) { // The size belongs in the log: a body the relay refuses for being too // big is otherwise indistinguishable from one it refuses for auth. 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 000000000..0b758bb1a --- /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 610a847bf..c4cfc3c96 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/MainThreadStallProbe.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MainThreadStallProbe.ets new file mode 100644 index 000000000..c0c5a6f66 --- /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 9a3382f11..d26fb88e5 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 000000000..dac8913dd --- /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 abd96a0c2..304422b1b 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,7 @@ import { } from '../model/RemoteModels'; import { RemoteI18n } from '../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; +import { RemoteChatCache } from './RemoteChatCache'; import { RemoteLogger } from './RemoteLogger'; export interface RemoteChatCommandClient { @@ -25,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, @@ -45,10 +50,16 @@ 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 { @@ -56,28 +67,123 @@ export class RemoteChatCommandController { 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, @@ -94,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 { 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 000000000..dca1cbd25 --- /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 e71d644ce..4c306c83a 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 bb20f0c3d..79d01a5ac 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; @@ -165,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')); @@ -179,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 000000000..662d9f75a --- /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 000000000..0a7a30286 --- /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/RemoteWorkspaceCoordinator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets index b0cf3d2c5..f6244b3c4 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 1595d747e..4018f84be 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 95deacf23..4a15a8f04 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,16 +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. */ + /** True when some path to a credential exists — account or paired desktop. */ readonly canProvision: () => boolean; readonly provision: ( deviceId: string, deviceName: string, requestId: string - ) => Promise; - readonly relayUrl: () => string; + ) => Promise; } /** @@ -116,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); } @@ -171,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) { @@ -189,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 4161ebe9f..1f982f575 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 9ee6f8737..b941f9c53 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -1116,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 97a364d5b..4aece46c0 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 68a3341a7..92e4aac37 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 99686af0c..7e4ded06f 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 @@ -1327,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(); @@ -1348,6 +1435,7 @@ export default function remoteControllersUnitTest() { projection.pollVersion = pollVersion; projection.knownMessageCount = knownMessageCount; }, + onTimelineReady: () => {}, onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, onSendFailed: ( _rawText: string, @@ -1363,7 +1451,7 @@ export default function remoteControllersUnitTest() { onToast: (_message: string) => {}, onBusy: (_isBusy: boolean) => {}, onPollRequested: () => {} - }); + }, inertRemoteChatCache()); await controller.loadMessages('session-1', (_sessionId: string) => true); await controller.loadMessages('session-2', (_sessionId: string) => false); @@ -1379,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 = [{ @@ -1399,6 +1751,7 @@ export default function remoteControllersUnitTest() { projection.pollVersion = pollVersion; projection.knownMessageCount = knownMessageCount; }, + onTimelineReady: () => {}, onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, onSendFailed: ( _rawText: string, @@ -1414,7 +1767,7 @@ export default function remoteControllersUnitTest() { busyEvents.push(isBusy); }, onPollRequested: () => {} - }); + }, cache); await controller.loadOlderMessages('session-1', 7, true, false); @@ -1425,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 () => { @@ -1435,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}`); }, @@ -1454,7 +1811,7 @@ export default function remoteControllersUnitTest() { busyEvents.push(isBusy); }, onPollRequested: () => {} - }); + }, inertRemoteChatCache()); await controller.sendPreparedMessage( 'session-1', @@ -1504,6 +1861,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, @@ -1523,7 +1881,7 @@ export default function remoteControllersUnitTest() { onPollRequested: () => { pollCount += 1; } - }); + }, inertRemoteChatCache()); await controller.stopTask('session-1', '', '', true); await controller.stopTask('session-1', 'active-turn', 'turn-1', true); @@ -1547,6 +1905,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, @@ -1564,7 +1923,7 @@ export default function remoteControllersUnitTest() { }, onBusy: (_isBusy: boolean) => {}, onPollRequested: () => {} - }); + }, inertRemoteChatCache()); await controller.stopTask('session-1', 'active-turn', 'turn-1', true); @@ -1581,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, @@ -1600,7 +1960,7 @@ export default function remoteControllersUnitTest() { busyEvents.push(isBusy); }, onPollRequested: () => {} - }); + }, inertRemoteChatCache()); await controller.renameActiveSession({ sessionId: 'session-1', @@ -1857,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', @@ -2133,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/crates/services/relay-service/src/db.rs b/src/crates/services/relay-service/src/db.rs index cb9f53dd0..ab518700b 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 ae47a51d0..c7551b3a0 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 c100f0104..4d3e75115 100644 --- a/src/crates/services/relay-service/src/routes/devices.rs +++ b/src/crates/services/relay-service/src/routes/devices.rs @@ -89,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, @@ -108,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, }); @@ -122,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, }); @@ -327,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(); @@ -401,6 +417,30 @@ 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, @@ -427,6 +467,42 @@ mod tests { .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; diff --git a/src/crates/services/relay-service/src/routes/pages.rs b/src/crates/services/relay-service/src/routes/pages.rs index 7f3f35121..60d1214f9 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 a1a5fa118..3a848c0d0 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 9f5ff0693..e6b6313d8 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 e7fad2336..4dd59c903 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 }