diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets index a6b837d7e3..2500638bf1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets @@ -4,6 +4,7 @@ import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { AccountDeviceSelectionPolicy } from '../policy/AccountDeviceSelectionPolicy'; import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; +import { NavigationBackButton } from './NavigationBackButton'; /** Account identity and device management shared by Settings and account entry. */ @ComponentV2 @@ -115,18 +116,10 @@ export struct AccountProfilePanel { @Builder private NavigationHeader() { Row() { - Button() { - SymbolGlyph($r('sys.symbol.chevron_left')) - .fontSize(23) - .fontColor([INK]) - } - .width(44) - .height(44) - .padding(0) - .type(ButtonType.Circle) - .backgroundColor(CARD) - .accessibilityText(RemoteI18n.t('common.back')) - .onClick(() => this.onBack()) + NavigationBackButton({ + controlSize: 44, + onBack: (): void => this.onBack() + }) Text(RemoteI18n.t('remote.settings.profile')) .fontSize(MobileDesignTypography.headlineMedium.size) .fontWeight(FontWeight.Bold) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets index 3efdf954fb..88c24eb110 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets @@ -1,14 +1,30 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { DEFAULT_CLOUD_RELAY_URL } from '../../services/CloudAccountClient'; -import { CARD, INK, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SUBTLE } from './Theme'; -import { TemplateIcon } from './TemplateIcon'; +import { + CARD, + INK, + LINE, + MUTED, + PAGE_BG_FADE, + RED, + SUBTLE +} from './Theme'; +import { SheetActionFooter } from './SheetActionFooter'; +import { SheetCloseHeader } from './SheetCloseHeader'; +import { + SHEET_CONTENT_BOTTOM_PADDING, + SHEET_CONTENT_MAX_WIDTH, + SHEET_HORIZONTAL_PADDING +} from './SheetLayout'; + +const CREDENTIAL_ROW_HEIGHT: number = 60; @ComponentV2 export struct BitFunAccountLoginPage { @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; - @Event onBack: () => void = () => {}; + @Event onClose: () => void = () => {}; @Event onLoginSuccess: () => void = () => {}; @Local relayUrl: string = DEFAULT_CLOUD_RELAY_URL; @Local username: string = ''; @@ -19,152 +35,204 @@ export struct BitFunAccountLoginPage { build() { Column({ space: 0 }) { - this.NavigationHeader() + SheetCloseHeader({ onClose: this.onClose }) Scroll() { - Column({ space: 0 }) { - Text(RemoteI18n.t('remote.settings.accountLoginBody')) - .fontSize(MobileDesignTypography.bodyLarge.size) - .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) - .fontColor(MUTED) - .width('100%') - .margin({ top: 12, bottom: 42 }) - - TextInput({ placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), text: this.username }) - .height(58) - .fontSize(MobileDesignTypography.bodyLarge.size) - .fontColor(INK) - .placeholderColor(SUBTLE) - .backgroundColor(CARD) - .borderRadius(18) - .padding({ left: 20, right: 20 }) - .onChange((value: string) => { this.username = value; }) - - TextInput({ placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), text: this.password }) - .height(58) - .fontSize(MobileDesignTypography.bodyLarge.size) - .fontColor(INK) - .placeholderColor(SUBTLE) - .backgroundColor(CARD) - .borderRadius(18) - .padding({ left: 20, right: 20 }) - .margin({ top: 14 }) - .type(InputType.Password) - .onChange((value: string) => { this.password = value; }) - - Row() { - Text(RemoteI18n.t('sheet.advancedOptions')) - .fontSize(MobileDesignTypography.bodySmall.size) - .fontColor(MUTED) - Blank() - if (this.showAdvanced) { - SymbolGlyph($r('sys.symbol.chevron_up')) - .fontSize(12) - .fontColor([MUTED]) - } else { - SymbolGlyph($r('sys.symbol.chevron_down')) - .fontSize(12) - .fontColor([MUTED]) - } - } - .width('100%') - .height(44) - .padding({ left: 4, right: 4 }) - .margin({ top: 12 }) - .alignItems(VerticalAlign.Center) - .onClick(() => { - this.showAdvanced = !this.showAdvanced; - }) - - if (this.showAdvanced) { - Text(RemoteI18n.t('remote.settings.loginServer')) - .fontSize(MobileDesignTypography.bodySmall.size) - .fontColor(MUTED) - .width('100%') - .margin({ left: 4, top: 8, bottom: 8 }) - - TextInput({ placeholder: RemoteI18n.t('remote.settings.relayUrlPlaceholder'), text: this.relayUrl }) - .height(52) - .fontSize(MobileDesignTypography.bodyMedium.size) - .fontColor(INK) - .placeholderColor(SUBTLE) - .backgroundColor(CARD) - .borderRadius(16) - .padding({ left: 18, right: 18 }) - .onChange((value: string) => { this.relayUrl = value; }) - } - - if (this.errorText.length > 0) { - Text(this.errorText) - .fontSize(MobileDesignTypography.bodySmall.size) - .lineHeight(MobileDesignTypography.bodySmall.lineHeight) - .fontColor(RED) - .width('100%') - .margin({ top: 12 }) - } + Column() { + this.LoginForm() } .width('100%') - .padding({ left: 28, right: 28, top: 24, bottom: 18 }) - .alignItems(HorizontalAlign.Start) + .padding({ + left: SHEET_HORIZONTAL_PADDING, + right: SHEET_HORIZONTAL_PADDING, + top: 0, + bottom: SHEET_CONTENT_BOTTOM_PADDING + }) + .alignItems(HorizontalAlign.Center) } .width('100%') .layoutWeight(1) .scrollBar(BarState.Off) - Row() { - Button(this.isBusy ? RemoteI18n.t('remote.settings.accountSigningIn') : - RemoteI18n.t('remote.settings.accountSignIn')) - .height(56) + SheetActionFooter({ + label: this.isBusy ? RemoteI18n.t('remote.settings.accountSigningIn') : + RemoteI18n.t('remote.settings.accountSignIn'), + primary: true, + isEnabled: this.canSubmit(), + onAction: (): void => { + void this.submit(); + } + }) + } + .width('100%') + .height('100%') + } + + @Builder + private LoginForm() { + Column({ space: 0 }) { + Text(RemoteI18n.t('remote.settings.accountLoginTitle')) + .fontSize(MobileDesignTypography.displayMedium.size) + .lineHeight(MobileDesignTypography.displayMedium.lineHeight) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .width('100%') + .textAlign(TextAlign.Center) + + Text(RemoteI18n.t('remote.settings.accountLoginBody')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .lineHeight(MobileDesignTypography.bodyMedium.lineHeight) + .fontColor(MUTED) + .width('100%') + .textAlign(TextAlign.Center) + .margin({ top: 8, bottom: 24 }) + + this.CredentialCard() + this.AdvancedCard() + + if (this.errorText.length > 0) { + Text(this.errorText) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(RED) + .width('100%') + .margin({ top: 12, left: 4, right: 4 }) + } + } + .width('100%') + .constraintSize({ maxWidth: SHEET_CONTENT_MAX_WIDTH }) + .alignItems(HorizontalAlign.Center) + } + + @Builder + private CredentialCard() { + Column() { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.person')) + .fontSize(21) + .fontColor([MUTED]) + .width(24) + .height(24) + TextInput({ + placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), + text: this.username + }) + .height(CREDENTIAL_ROW_HEIGHT) .layoutWeight(1) - .fontSize(MobileDesignTypography.labelLarge.size) - .fontWeight(FontWeight.Bold) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .borderRadius(18) - .opacity(this.canSubmit() ? 1 : 0.28) - .enabled(this.canSubmit()) - .onClick(async () => { - await this.submit(); - }) + .fontSize(MobileDesignTypography.bodyLarge.size) + .fontColor(INK) + .placeholderColor(SUBTLE) + .backgroundColor(PAGE_BG_FADE) + .padding({ left: 0, right: 4 }) + .onChange((value: string) => { this.username = value; }) + } + .width('100%') + .height(CREDENTIAL_ROW_HEIGHT) + .padding({ left: 18, right: 12 }) + .alignItems(VerticalAlign.Center) + + Divider() + .strokeWidth(1) + .color(LINE) + .margin({ left: 54, right: 16 }) + + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.lock')) + .fontSize(20) + .fontColor([MUTED]) + .width(24) + .height(24) + TextInput({ + placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), + text: this.password + }) + .height(CREDENTIAL_ROW_HEIGHT) + .layoutWeight(1) + .fontSize(MobileDesignTypography.bodyLarge.size) + .fontColor(INK) + .placeholderColor(SUBTLE) + .backgroundColor(PAGE_BG_FADE) + .padding({ left: 0, right: 4 }) + .type(InputType.Password) + .showPasswordIcon(true) + .onChange((value: string) => { this.password = value; }) } .width('100%') - .padding({ left: 28, right: 28, top: 12, bottom: 28 }) + .height(CREDENTIAL_ROW_HEIGHT) + .padding({ left: 18, right: 12 }) + .alignItems(VerticalAlign.Center) } .width('100%') - .height('100%') + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(24) + .clip(true) } @Builder - private NavigationHeader() { - Row() { - Button() { - TemplateIcon({ - src: $r('app.media.remote_ref_back'), - iconWidth: 15, - iconHeight: 23 - }) + private AdvancedCard() { + Column() { + Row({ space: 14 }) { + SymbolGlyph($r('sys.symbol.gearshape')) + .fontSize(21) + .fontColor([MUTED]) + .width(24) + .height(24) + Text(RemoteI18n.t('sheet.advancedOptions')) + .fontSize(MobileDesignTypography.titleSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .layoutWeight(1) + SymbolGlyph(this.showAdvanced ? + $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + .fontSize(13) + .fontColor([MUTED]) } - .width(44) - .height(44) - .padding(0) - .type(ButtonType.Circle) - .backgroundColor('#00000000') - .accessibilityText(RemoteI18n.t('common.back')) + .width('100%') + .height(58) + .padding({ left: 18, right: 18 }) + .alignItems(VerticalAlign.Center) .onClick(() => { - this.onBack(); + this.showAdvanced = !this.showAdvanced; }) - Text(RemoteI18n.t('remote.settings.accountLoginTitle')) - .fontSize(MobileDesignTypography.headlineMedium.size) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .layoutWeight(1) - .margin({ left: 10 }) + if (this.showAdvanced) { + Divider() + .strokeWidth(1) + .color(LINE) + .margin({ left: 18, right: 18 }) + + Column({ space: 8 }) { + Text(RemoteI18n.t('remote.settings.loginServer')) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED) + .width('100%') + + TextInput({ + placeholder: RemoteI18n.t('remote.settings.relayUrlPlaceholder'), + text: this.relayUrl + }) + .height(48) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontColor(INK) + .placeholderColor(SUBTLE) + .backgroundColor(PAGE_BG_FADE) + .border({ width: 1, color: LINE }) + .borderRadius(14) + .padding({ left: 14, right: 14 }) + .onChange((value: string) => { this.relayUrl = value; }) + } + .width('100%') + .padding({ left: 18, right: 18, top: 14, bottom: 18 }) + .alignItems(HorizontalAlign.Start) + } } .width('100%') - .height(72) - .padding({ left: 18, right: 18 }) - .alignItems(VerticalAlign.Center) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(20) + .clip(true) + .margin({ top: 14 }) } private canSubmit(): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets index 0921e42782..ca70ba69b7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets @@ -3,6 +3,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; import { AccountDeviceSelectionPolicy } from '../policy/AccountDeviceSelectionPolicy'; +import { NavigationBackButton } from './NavigationBackButton'; @ComponentV2 export struct ConnectAccountDevicePage { @@ -27,18 +28,10 @@ export struct ConnectAccountDevicePage { build() { Column() { Row({ space: 16 }) { - Stack() { - // No .width/.height here on purpose. A chevron's natural advance box - // is about half as wide as it is tall, and SymbolGlyph draws the glyph - // left-anchored inside a box you force wider than that — so forcing a - // square 26vp box pushed this arrow ~7vp left of the circle's centre. - // Left unsized, the Stack centres the natural box and the arrow sits - // where the circle says it should. - SymbolGlyph($r('sys.symbol.chevron_left')) - .fontSize(23).fontColor([INK]) - } - .width(48).height(48).backgroundColor(SOFT).borderRadius(24) - .onClick(() => this.onBack()) + NavigationBackButton({ + controlSize: 48, + onBack: (): void => this.onBack() + }) Column({ space: 4 }) { Text(RemoteI18n.t('connect.accountDevicesTitle')) .fontSize(MobileDesignTypography.headlineLarge.size).fontWeight(FontWeight.Bold).fontColor(INK).width('100%') @@ -230,4 +223,3 @@ export struct ConnectAccountDevicePage { return presence; } } - 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 6ea5e92494..7e43baf710 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 @@ -3,6 +3,14 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ConnectAccountDevicePage } from './ConnectAccountDevicePage'; import { ConnectManualPairingOverlay } from './ConnectManualPairingOverlay'; +import { NavigationBackButton } from './NavigationBackButton'; +import { SheetActionFooter } from './SheetActionFooter'; +import { SheetCloseHeader } from './SheetCloseHeader'; +import { + SHEET_CONTENT_BOTTOM_PADDING, + SHEET_CONTENT_MAX_WIDTH, + SHEET_HORIZONTAL_PADDING +} from './SheetLayout'; import { CONNECT_INTENT_AUTO, CONNECT_INTENT_SCAN } from '../state/AppShellState'; import { ConnectSheetLandingPolicy, @@ -12,8 +20,13 @@ import { import { DetectedUrlAction } from '../../services/ConnectScanDecisionPolicy'; import { InlineQrScanner } from './platform/InlineQrScanner'; import { 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'; + CONNECT_HERO_SURFACE, CONNECT_SCAN_ACCENT, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, + PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; + +const SCAN_FRAME_SIZE: number = 248; +const SCAN_CORNER_SIZE: number = 56; +const SCAN_CORNER_INSET: number = 20; + @ComponentV2 export struct ConnectView { @Param remoteUrl: string = ''; @@ -175,55 +188,77 @@ export struct ConnectView { @Builder ScanPairingPage() { Column() { - Stack() { - this.HeroWash() - this.BackButton() - } - .width('100%') - .height(252) - - Column({ space: 22 }) { - this.CameraFrame() - Text(RemoteI18n.t('connect.scanPairCode')) - .fontSize(MobileDesignTypography.displayLarge.size) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .textAlign(TextAlign.Center) - .width('100%') - if (this.shouldShowStatusStrip()) { - this.StatusStrip() + SheetCloseHeader({ + onClose: (): void => { + this.onBack(); } - if (this.inlineScanError.length > 0) { - Text(this.inlineScanError) - .fontSize(MobileDesignTypography.bodySmall.size) - .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + }) + + Scroll() { + Column({ space: 0 }) { + Text(RemoteI18n.t('connect.scanTitle')) + .fontSize(MobileDesignTypography.displayMedium.size) + .lineHeight(MobileDesignTypography.displayMedium.lineHeight) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .textAlign(TextAlign.Center) + .width('100%') + + Text(RemoteI18n.t('connect.scanBody')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .lineHeight(MobileDesignTypography.bodyMedium.lineHeight) .fontColor(MUTED) .textAlign(TextAlign.Center) - .width('78%') + .width('100%') + .margin({ top: 8, bottom: 24 }) + + this.CameraFrame() + + if (this.shouldShowStatusStrip()) { + Column() { + this.StatusStrip() + } + .width('100%') + .margin({ top: 18 }) + } + if (this.inlineScanError.length > 0) { + Text(this.inlineScanError) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .width('100%') + .padding({ left: 14, right: 14, top: 12, bottom: 12 }) + .backgroundColor(SOFT) + .borderRadius(16) + .margin({ top: 14 }) + } } + .width('100%') + .constraintSize({ maxWidth: SHEET_CONTENT_MAX_WIDTH }) + .alignItems(HorizontalAlign.Center) } + .width('100%') .layoutWeight(1) - .alignItems(HorizontalAlign.Center) - .margin({ top: -50 }) - - Button(RemoteI18n.t('connect.switchManualPair')) - .width('78%') - .height(58) - .fontSize(MobileDesignTypography.labelLarge.size) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .backgroundColor(CARD) - .borderRadius(35) - .border({ width: 1.5, color: LINE }) - .onClick(() => { + .scrollBar(BarState.Off) + .padding({ + left: SHEET_HORIZONTAL_PADDING, + right: SHEET_HORIZONTAL_PADDING, + bottom: SHEET_CONTENT_BOTTOM_PADDING + }) + + SheetActionFooter({ + label: RemoteI18n.t('connect.switchManualPair'), + onAction: (): void => { this.showManualPairing = true; this.onRemoteUrlInputVisibleChange(true); - }) + } + }) } .width('100%') .height('100%') - .padding({ bottom: 34 }) - .backgroundColor(CARD) + .backgroundColor(PAGE_BG) + .alignItems(HorizontalAlign.Center) } @Builder @@ -262,29 +297,18 @@ export struct ConnectView { @Builder BackButton() { - Stack() { - this.BackGlyph() - } - .width(48) - .height(48) - .backgroundColor(SOFT) - .borderRadius(24) - .position({ x: 28, y: 18 }) - .onClick(() => { + NavigationBackButton({ + controlSize: 48, + onBack: (): void => { if (this.openIntent !== CONNECT_INTENT_SCAN && this.currentStep() === ConnectSheetStep.Scan && this.remoteUrl.trim().length === 0 && !this.showManualPairing) { this.pairingStep = this.isAccountAuthenticated() ? ConnectSheetStep.Account : ConnectSheetStep.Intro; return; } this.onBack(); - }) - } - - @Builder - BackGlyph() { - SymbolGlyph($r('sys.symbol.chevron_left')) - .fontSize(21) - .fontColor([INK]) + } + }) + .position({ x: 28, y: 18 }) } @Builder @@ -311,6 +335,7 @@ export struct ConnectView { CameraFrame() { Stack() { InlineQrScanner({ + scannerSize: SCAN_FRAME_SIZE, paused: this.showManualPairing, restartRevision: this.scanRestartRevision, onDetected: (value: string) => this.handleScannedRemoteUrl(value), @@ -322,37 +347,55 @@ export struct ConnectView { } }) Text('') - .width(282) - .height(282) + .width(SCAN_FRAME_SIZE) + .height(SCAN_FRAME_SIZE) .backgroundColor('#18000000') - .borderRadius(40) - this.ScanCorner(32, 32, true, true) - this.ScanCorner(204, 32, false, true) - this.ScanCorner(32, 204, true, false) - this.ScanCorner(204, 204, false, false) + .borderRadius(28) + this.ScanCorner(SCAN_CORNER_INSET, SCAN_CORNER_INSET, true, true) + this.ScanCorner( + SCAN_FRAME_SIZE - SCAN_CORNER_INSET - SCAN_CORNER_SIZE, + SCAN_CORNER_INSET, + false, + true + ) + this.ScanCorner( + SCAN_CORNER_INSET, + SCAN_FRAME_SIZE - SCAN_CORNER_INSET - SCAN_CORNER_SIZE, + true, + false + ) + this.ScanCorner( + SCAN_FRAME_SIZE - SCAN_CORNER_INSET - SCAN_CORNER_SIZE, + SCAN_FRAME_SIZE - SCAN_CORNER_INSET - SCAN_CORNER_SIZE, + false, + false + ) } - .width(282) - .height(282) + .width(SCAN_FRAME_SIZE) + .height(SCAN_FRAME_SIZE) + .border({ width: 1, color: LINE }) + .borderRadius(28) + .clip(true) } @Builder ScanCorner(x: number, y: number, isLeft: boolean, isTop: boolean) { Stack() { Text('') - .width(42) + .width(36) .height(4) .borderRadius(2) .backgroundColor(CONNECT_SCAN_ACCENT) - .position({ x: isLeft ? 0 : 22, y: isTop ? 0 : 60 }) + .position({ x: isLeft ? 0 : 20, y: isTop ? 0 : 52 }) Text('') .width(4) - .height(42) + .height(36) .borderRadius(2) .backgroundColor(CONNECT_SCAN_ACCENT) - .position({ x: isLeft ? 0 : 60, y: isTop ? 0 : 22 }) + .position({ x: isLeft ? 0 : 52, y: isTop ? 0 : 20 }) } - .width(64) - .height(64) + .width(SCAN_CORNER_SIZE) + .height(SCAN_CORNER_SIZE) .position({ x, y }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets index 9dea576566..9f5eb1be00 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets @@ -2,6 +2,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION_TEXT, SOFT } from './Theme'; import { CompactMenuButton } from './CompactMenuButton'; +import { NavigationBackButton } from './NavigationBackButton'; import { TemplateIcon } from './TemplateIcon'; import { SidebarToggleButton } from './SidebarToggleButton'; @@ -100,22 +101,11 @@ export struct ConversationHeader { onToggle: this.onRestoreSidebar }) } else if (this.showBackButton) { - Stack({ alignContent: Alignment.Center }) { - TemplateIcon({ - src: $r('app.media.remote_ref_back'), - iconWidth: 15, - iconHeight: 23 - }) - } - .width(MobileDesignGeometry.controlTouchSize) - .height(MobileDesignGeometry.controlTouchSize) - .backgroundColor(CARD) - .border({ width: 1, color: LINE }) - .borderRadius(MobileDesignGeometry.controlTouchSize / 2) - .shadow({ radius: 10, color: LINE, offsetY: 3 }) - .accessibilityText(RemoteI18n.t('common.back')) - .onClick(() => { - this.onBack(); + NavigationBackButton({ + controlSize: MobileDesignGeometry.controlTouchSize, + onBack: (): void => { + this.onBack(); + } }) } else if (this.showSidebarButton) { CompactMenuButton({ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets index 8bf978dbc8..41a1e4dec4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets @@ -13,6 +13,7 @@ import { FilePreviewState } from '../state/FilePreviewState'; import { MarkdownContent } from './MarkdownContent'; +import { NavigationBackButton } from './NavigationBackButton'; import { CARD, CODE_COMMENT, @@ -70,16 +71,11 @@ export struct FilePreviewSurface { @Builder Header() { Row({ space: 10 }) { - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.chevron_left')) - .fontSize(22) - .fontColor([INK]) - } - .width(44) - .height(44) - .accessibilityText(RemoteI18n.t('common.close')) - .onClick(() => { - this.onClose(); + NavigationBackButton({ + controlSize: 44, + onBack: (): void => { + this.onClose(); + } }) Column({ space: 2 }) { @@ -350,6 +346,7 @@ export struct FilePreviewSurface { .scrollBar(BarState.Auto) .layoutWeight(1) .width('100%') + .align(Alignment.TopStart) .onAppear(() => { this.restoreTextScroll(); }) @@ -379,6 +376,7 @@ export struct FilePreviewSurface { .scrollBar(BarState.Auto) .layoutWeight(1) .width('100%') + .align(Alignment.TopStart) .onAppear(() => { this.restoreMarkdownScroll(); }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets index ef78fb25ca..80c506921d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets @@ -34,6 +34,8 @@ export struct MarkdownContent { MarkdownBlockView(block: ParsedMarkdownBlock) { if (block.type === 'code') { this.CodeBlock(block.language, block.text) + } else if (block.type === 'frontmatter') { + this.FrontmatterBlock(block.text) } else if (block.type === 'heading') { this.InlineText(block.inlines, this.headingFontSize(block.level), this.headingLineHeight(block.level), INK, true) } else if (block.type === 'quote') { @@ -69,6 +71,17 @@ export struct MarkdownContent { } } + @Builder + FrontmatterBlock(body: string) { + Text(body) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .fontFamily('monospace') + .textSelectable(TextSelectableMode.SELECTABLE_UNFOCUSABLE) + } + @Builder InlineText(inlines: ParsedMarkdownInline[], fontSize: number, lineHeight: number, color: ResourceColor, bold: boolean) { Text() { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets index 2eb0433631..4d3bf4d313 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets @@ -6,6 +6,7 @@ import { GENERAL_CHAT_LOCAL_MODEL_ID } from '../../services/general-chat/General import { ModelServiceSettingsPolicy } from '../policy/ModelServiceSettingsPolicy'; import { SettingsSheetState } from '../state/SettingsSheetState'; import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; +import { NavigationBackButton } from './NavigationBackButton'; @ComponentV2 export struct ModelServiceSettingsPanel { @@ -94,20 +95,12 @@ export struct ModelServiceSettingsPanel { Header() { Row() { if (this.sheetPage() !== 'overview') { - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.chevron_left')) - .fontSize(20) - .fontColor([INK]) - } - .width(42) - .height(42) - .margin({ right: 8 }) - .accessibilityText(RemoteI18n.t('common.back')) - .onClick(() => { - if (!this.isSaving && !this.isTesting) { - this.onClose(); - } + NavigationBackButton({ + controlSize: 44, + isEnabled: !this.isSaving && !this.isTesting, + onBack: this.onClose }) + .margin({ right: 8 }) } Text(this.headerTitle()) .fontSize(MobileDesignTypography.headlineSmall.size) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/NavigationBackButton.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/NavigationBackButton.ets new file mode 100644 index 0000000000..a5d51eb38f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/NavigationBackButton.ets @@ -0,0 +1,33 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, LINE } from './Theme'; +import { TemplateIcon } from './TemplateIcon'; + +/** App-wide floating page return affordance. */ +@ComponentV2 +export struct NavigationBackButton { + @Param controlSize: number = 48; + @Param isEnabled: boolean = true; + @Event onBack: () => void = () => {}; + + build() { + Stack({ alignContent: Alignment.Center }) { + TemplateIcon({ + src: $r('app.media.remote_ref_back'), + iconWidth: 15, + iconHeight: 23 + }) + } + .width(this.controlSize) + .height(this.controlSize) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(this.controlSize / 2) + .shadow({ radius: 12, color: LINE, offsetY: 4 }) + .opacity(this.isEnabled ? 1 : 0.4) + .enabled(this.isEnabled) + .accessibilityText(RemoteI18n.t('common.back')) + .onClick(() => { + this.onBack(); + }) + } +} 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 b45f19eb17..3aafa10796 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 @@ -6,6 +6,7 @@ import { RemotePermissionMode } from '../../model/RemoteModels'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; import { BitFunAccountLoginPage } from './BitFunAccountLoginPage'; import { AccountProfilePanel } from './AccountProfilePanel'; +import { SheetCloseButton } from './SheetCloseButton'; import { RemoteControlSettingsPage, RemoteControlSettingsPagePolicy @@ -430,7 +431,7 @@ export struct RemoteControlSettingsSheet { private LoginPage() { BitFunAccountLoginPage({ cloudLogin: this.cloudLogin, - onBack: (): void => this.leaveAccountPage(), + onClose: (): void => this.leaveAccountPage(), onLoginSuccess: (): void => { this.showLogin = false; this.showProfile = true; @@ -465,21 +466,12 @@ export struct RemoteControlSettingsSheet { @Builder private CloseButton() { - Button() { - SymbolGlyph($r('sys.symbol.xmark')) - .fontSize(21) - .fontColor([INK]) - } - .width(50) - .height(50) - .padding(0) - .type(ButtonType.Circle) - .backgroundColor(CARD) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) - .margin({ top: 22, right: 18 }) - .onClick(() => { - this.onClose(); + SheetCloseButton({ + onClose: (): void => { + this.onClose(); + } }) + .margin({ top: 7, right: 8 }) } private isConnectedOrConnecting(): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index 458db8cdc3..924c114fd2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -7,6 +7,7 @@ import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { AccountProfilePanel } from './AccountProfilePanel'; import { LanguageSettingsPanel } from './LanguageSettingsPanel'; import { ModelServiceSettingsPanel } from './ModelServiceSettingsPanel'; +import { SheetCloseButton } from './SheetCloseButton'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; @ComponentV2 @@ -91,24 +92,15 @@ export struct SettingsSheet { .fontWeight(FontWeight.Bold) .fontColor(INK) Blank() - Button() { - SymbolGlyph($r('sys.symbol.xmark')) - .fontSize(18) - .fontColor([INK]) - } - .width(40) - .height(40) - .padding(0) - .type(ButtonType.Circle) - .backgroundColor(CARD) - .accessibilityText(RemoteI18n.t('common.close')) - .onClick(() => { - this.onClose(); + SheetCloseButton({ + onClose: (): void => { + this.onClose(); + } }) } .width('100%') .height(84) - .padding({ left: 28, right: 18, top: 10 }) + .padding({ left: 28, right: 8, top: 10 }) .alignItems(VerticalAlign.Center) .backgroundColor(PAGE_BG) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetActionFooter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetActionFooter.ets new file mode 100644 index 0000000000..bb64bd88e1 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetActionFooter.ets @@ -0,0 +1,48 @@ +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { + SHEET_CONTENT_MAX_WIDTH, + SHEET_FOOTER_BOTTOM_PADDING, + SHEET_FOOTER_TOP_PADDING, + SHEET_HORIZONTAL_PADDING +} from './SheetLayout'; +import { CARD, INK, LINE, PRIMARY_ACTION, PRIMARY_ACTION_TEXT } from './Theme'; + +/** Standard single-action footer for full-height modal sheets. */ +@ComponentV2 +export struct SheetActionFooter { + @Param label: string = ''; + @Param primary: boolean = false; + @Param isEnabled: boolean = true; + @Event onAction: () => void = () => {}; + + build() { + Column() { + Row() { + Button(this.label) + .height(54) + .layoutWeight(1) + .fontSize(MobileDesignTypography.labelLarge.size) + .fontWeight(this.primary ? FontWeight.Bold : FontWeight.Medium) + .fontColor(this.primary ? PRIMARY_ACTION_TEXT : INK) + .backgroundColor(this.primary ? PRIMARY_ACTION : CARD) + .border({ width: this.primary ? 0 : 1, color: LINE }) + .borderRadius(27) + .opacity(this.isEnabled ? 1 : 0.32) + .enabled(this.isEnabled) + .onClick(() => { + this.onAction(); + }) + } + .width('100%') + .constraintSize({ maxWidth: SHEET_CONTENT_MAX_WIDTH }) + } + .width('100%') + .padding({ + left: SHEET_HORIZONTAL_PADDING, + right: SHEET_HORIZONTAL_PADDING, + top: SHEET_FOOTER_TOP_PADDING, + bottom: SHEET_FOOTER_BOTTOM_PADDING + }) + .alignItems(HorizontalAlign.Center) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseButton.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseButton.ets new file mode 100644 index 0000000000..41d91a2ef1 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseButton.ets @@ -0,0 +1,35 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE } from './Theme'; + +/** Prominent top-right close affordance for modal sheets. */ +@ComponentV2 +export struct SheetCloseButton { + @Param touchSize: number = 44; + @Param visualSize: number = 36; + @Param isEnabled: boolean = true; + @Event onClose: () => void = () => {}; + + build() { + Stack({ alignContent: Alignment.Center }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(18) + .fontWeight(FontWeight.Medium) + .fontColor([INK]) + } + .width(this.visualSize) + .height(this.visualSize) + .backgroundColor(CARD) + .borderRadius(this.visualSize / 2) + .shadow({ radius: 8, color: LINE, offsetY: 3 }) + } + .width(this.touchSize) + .height(this.touchSize) + .opacity(this.isEnabled ? 1 : 0.4) + .enabled(this.isEnabled) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => { + this.onClose(); + }) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseHeader.ets new file mode 100644 index 0000000000..0e0e66f537 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetCloseHeader.ets @@ -0,0 +1,21 @@ +import { SHEET_HEADER_HEIGHT, SHEET_HEADER_SIDE_PADDING } from './SheetLayout'; +import { SheetCloseButton } from './SheetCloseButton'; + +/** Standard close-only header for a modal sheet whose title lives in its body. */ +@ComponentV2 +export struct SheetCloseHeader { + @Event onClose: () => void = () => {}; + + build() { + Row() { + Blank() + SheetCloseButton({ + onClose: this.onClose + }) + } + .width('100%') + .height(SHEET_HEADER_HEIGHT) + .padding({ left: SHEET_HEADER_SIDE_PADDING, right: SHEET_HEADER_SIDE_PADDING }) + .alignItems(VerticalAlign.Center) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetLayout.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetLayout.ets new file mode 100644 index 0000000000..dd9d41d91a --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SheetLayout.ets @@ -0,0 +1,8 @@ +/** Shared geometry for full-height modal sheets. */ +export const SHEET_CONTENT_MAX_WIDTH: number = 520; +export const SHEET_HORIZONTAL_PADDING: number = 24; +export const SHEET_HEADER_HEIGHT: number = 58; +export const SHEET_HEADER_SIDE_PADDING: number = 8; +export const SHEET_CONTENT_BOTTOM_PADDING: number = 18; +export const SHEET_FOOTER_TOP_PADDING: number = 10; +export const SHEET_FOOTER_BOTTOM_PADDING: number = 24; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets index ae3a63a039..5c4c1f6e64 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets @@ -293,7 +293,7 @@ struct SidebarWorkspaceGroup { * * The projection cache is a single slot whose key includes the current * workspace path, so one instance cannot serve two devices without missing - * every rebuild. This component exists so each desktop keeps its own cache. + * every rebuild. Its parent therefore keys this component boundary by device. */ @ComponentV2 export struct SidebarDeviceGroup { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets index 906a9e4a0d..1d7d1f5880 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets @@ -72,9 +72,12 @@ export struct SidebarWorkspaceSection { if (this.visibleDeviceCount < this.deviceEntries().length) { this.MoreDevicesRow() } - if (this.selectedEntry()) { - this.WorkspaceSection(this.selectedEntry() as DeviceDirectoryEntry) - } + // The workspace tree owns projection caches and local disclosure state. + // Key the single visible tree by device so switching the flat selector + // cannot reuse the outgoing device's component instance and projection. + ForEach(this.selectedEntries(), (entry: DeviceDirectoryEntry) => { + this.WorkspaceSection(entry) + }, (entry: DeviceDirectoryEntry): string => entry.deviceId) } } .width('100%') @@ -332,6 +335,11 @@ export struct SidebarWorkspaceSection { return entries.find((entry: DeviceDirectoryEntry): boolean => entry.deviceId === selectedId); } + private selectedEntries(): DeviceDirectoryEntry[] { + const selected = this.selectedEntry(); + return selected ? [selected] : []; + } + private isSelected(entry: DeviceDirectoryEntry): boolean { const selected = this.selectedEntry(); return !!selected && selected.deviceId === entry.deviceId; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/platform/InlineQrScanner.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/platform/InlineQrScanner.ets index 517859de51..4d539c2cd7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/platform/InlineQrScanner.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/platform/InlineQrScanner.ets @@ -9,6 +9,7 @@ export struct InlineQrScanner { private scanStartRetryCount: number = 0; private generation: number = 0; private cameraPermissionReady: boolean = false; + @Param scannerSize: number = 248; @Param paused: boolean = false; @Param restartRevision: number = 0; @Event onDetected: (value: string) => void = (_value: string) => {}; @@ -32,9 +33,9 @@ export struct InlineQrScanner { build() { XComponent({ id: 'remote-scan-preview', type: XComponentType.SURFACE, controller: this.scannerController }) - .width(282) - .height(282) - .borderRadius(40) + .width(this.scannerSize) + .height(this.scannerSize) + .borderRadius(28) .onLoad(() => { void this.start(); }) 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 7522cb99f5..738838ff18 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 @@ -288,6 +288,14 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } } + async retryDirectoryDevice(deviceId: string): Promise { + if (this.isConnectedDirectoryTarget(deviceId)) { + await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + return; + } + await this.deviceDirectoryViewModel.retryDevice(deviceId); + } + async loadWorkspaceSessionsOnDevice(deviceId: string, path: string): Promise { const targetDeviceId = deviceId.trim(); const targetPath = path.trim(); 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 1881e5a0d3..244642ed43 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 @@ -4,6 +4,7 @@ import { RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, + RecentWorkspaceEntry, SelectedImageAttachment, SessionSummary, WorkspaceInfo @@ -144,6 +145,7 @@ export abstract class AppRootRuntimeComposition { abstract reconcileCreatedRemoteSession(session: SessionSummary): Promise; abstract reconnect(): Promise; abstract reconnectActiveRemote(): Promise; + abstract retryDirectoryDevice(deviceId: string): Promise; abstract selectDirectoryDevice(deviceId: string): Promise; abstract selectAssistant(path: string): Promise; abstract selectWorkspace(path: string): Promise; @@ -388,6 +390,7 @@ export abstract class AppRootRuntimeComposition { this.workspaceCoordinator, { isRemoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + remoteTargetId: (): string => this.remotePageState.controlTargetDeviceId, isBusy: (): boolean => this.remotePageState.isBusy, onBusy: (isBusy: boolean): void => { this.remotePageState.setBusy(isBusy); @@ -399,6 +402,15 @@ export abstract class AppRootRuntimeComposition { this.remoteConnectionController.applyWorkspace(workspace); this.remoteSessionController.clearSessions(); }, + onCatalogLoading: (targetId: string): void => { + this.deviceDirectoryViewModel.beginLiveCatalog(targetId); + }, + onCatalogLoaded: (targetId: string, workspaces: RecentWorkspaceEntry[]): void => { + this.deviceDirectoryViewModel.completeLiveCatalog(targetId, workspaces); + }, + onCatalogFailed: (targetId: string): void => { + this.deviceDirectoryViewModel.failLiveCatalog(targetId); + }, onRefreshSessions: async (): Promise => { await this.remoteSessionViewModel.refreshSessions(); }, @@ -783,8 +795,11 @@ export abstract class AppRootRuntimeComposition { onBeforeControlTargetChange: (): void => { this.captureLiveControlTarget(); }, + onControlTargetLoading: (deviceId: string): void => { + this.deviceDirectoryViewModel.beginLiveCatalog(deviceId); + }, onControlTargetChanged: (): void => { - this.captureLiveControlTarget(); + this.activateLiveControlTarget(); } } } @@ -930,7 +945,7 @@ export abstract class AppRootRuntimeComposition { void this.openWorkspacePickerOnDevice(deviceId); }, retryDirectoryDevice: (deviceId: string): void => { - void this.deviceDirectoryViewModel.retryDevice(deviceId); + void this.retryDirectoryDevice(deviceId); }, loadWorkspaceSessions: async (deviceId: string, path: string): Promise => { await this.loadWorkspaceSessionsOnDevice(deviceId, path); @@ -1030,6 +1045,15 @@ export abstract class AppRootRuntimeComposition { ); } + private activateLiveControlTarget(): void { + const deviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; + this.deviceDirectoryViewModel.activateLive( + deviceId, + this.remotePageState.recentWorkspaces, + this.remotePageState.sessions + ); + } + enterCodeEntry(): void { this.enterRemoteSurface(); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets index b2a96d58ec..cc830f867f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets @@ -31,6 +31,8 @@ export class DeviceDirectoryViewModel { private readonly directory?: AccountDeviceDirectorySource; private readonly inFlight: Set = new Set(); private readonly workspaceInFlight: Set = new Set(); + /** Device whose control-target catalog request currently owns `loading`. */ + private liveCatalogDeviceId: string = ''; constructor( state: DeviceDirectoryState, @@ -94,11 +96,106 @@ export class DeviceDirectoryViewModel { entry.sessions ); } - if (entry.status === 'idle' || entry.status === 'cached') { + if (entry.status === 'loading') { + // A live-catalog request belongs to the control-target lifecycle. Once + // that target is being torn down, the request can no longer complete + // this row. Leave any captured snapshot as cache, otherwise make the row + // fetchable again instead of stranding it in a false loading state. + entry.status = entry.workspaces.length > 0 || entry.sessions.length > 0 ? 'cached' : 'idle'; + } else if (entry.status === 'idle' || entry.status === 'cached') { entry.status = 'ready'; } } + /** Marks the incoming target's catalog request before its handshake starts. */ + beginLiveCatalog(deviceId: string): void { + const target = deviceId.trim(); + if (target.length === 0) { + return; + } + if (this.liveCatalogDeviceId.length > 0 && this.liveCatalogDeviceId !== target) { + this.releaseAbandonedLiveCatalog(this.liveCatalogDeviceId); + } + const entry = this.state.find(target); + if (!entry) { + return; + } + this.liveCatalogDeviceId = target; + entry.status = 'loading'; + entry.errorText = ''; + } + + /** + * Starts the directory projection for a newly activated control target. + * + * This is deliberately different from `captureLive`: the outgoing snapshot + * ignores empty teardown values, while an incoming empty catalog is real and + * must replace whatever was cached for this row. Conflating the two lifecycle + * edges is what allowed one desktop's workspaces to be stamped onto another. + */ + activateLive( + deviceId: string, + workspaces: RecentWorkspaceEntry[], + sessions: RemoteSession[] + ): void { + const target = deviceId.trim(); + if (target.length === 0) { + return; + } + const entry = this.state.find(target); + if (!entry) { + return; + } + entry.workspaces = workspaces.slice(); + // The handshake is the authoritative snapshot for the incoming target. + // Merging the row's old cache here makes any historical cross-device + // misattribution immortal, because those stale sessions never disappear. + entry.sessions = stampRemoteSessionsDevice(sessions, target); + // The handshake only carries the current workspace and sessions. The full + // recent-workspace request starts immediately afterwards, so even an empty + // target-scoped projection is still loading rather than a confirmed empty + // catalog. + entry.status = 'loading'; + entry.errorText = ''; + } + + /** Publishes the authoritative full catalog returned by the live target. */ + completeLiveCatalog(deviceId: string, workspaces: RecentWorkspaceEntry[]): void { + const target = deviceId.trim(); + const entry = this.state.find(target); + if (!entry) { + return; + } + entry.workspaces = workspaces.slice(); + entry.status = 'ready'; + entry.errorText = ''; + if (this.liveCatalogDeviceId === target) { + this.liveCatalogDeviceId = ''; + } + } + + /** Ends a live catalog request as failed so retry reflects real transport state. */ + failLiveCatalog(deviceId: string): void { + const target = deviceId.trim(); + const entry = this.state.find(target); + if (!entry) { + return; + } + entry.status = 'failed'; + entry.errorText = ''; + if (this.liveCatalogDeviceId === target) { + this.liveCatalogDeviceId = ''; + } + } + + private releaseAbandonedLiveCatalog(deviceId: string): void { + const entry = this.state.find(deviceId); + if (entry && entry.status === 'loading') { + entry.status = entry.workspaces.length > 0 || entry.sessions.length > 0 ? 'cached' : 'idle'; + } + this.liveCatalogDeviceId = ''; + } + syncDevices(devices: CloudAccountDevice[]): void { const activeId = this.hooks.activeDeviceId(); const activeConnected = this.hooks.activeDeviceConnected(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets index 8d6f8fc076..c93ebf5132 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets @@ -6,10 +6,15 @@ import { RemotePageState } from '../state/RemotePageState'; export interface RemoteWorkspaceViewModelHooks { readonly isRemoteAvailable: () => boolean; + /** Stable owner of the current remote catalog; empty while no target owns it. */ + readonly remoteTargetId: () => string; readonly isBusy: () => boolean; readonly onBusy: (isBusy: boolean) => void; readonly onStatus: (statusText: string) => void; readonly onWorkspaceSelected: (workspace: WorkspaceInfo) => void; + readonly onCatalogLoading: (targetId: string) => void; + readonly onCatalogLoaded: (targetId: string, workspaces: RecentWorkspaceEntry[]) => void; + readonly onCatalogFailed: (targetId: string) => void; readonly onRefreshSessions: () => Promise; readonly onConnectionFailure: (error: Object) => void; } @@ -19,6 +24,7 @@ export class RemoteWorkspaceViewModel { private readonly pageState: RemotePageState; private readonly coordinator: RemoteWorkspaceCoordinator; private readonly hooks: RemoteWorkspaceViewModelHooks; + private catalogLoadVersion: number = 0; constructor( pageState: RemotePageState, @@ -59,9 +65,21 @@ export class RemoteWorkspaceViewModel { } async loadRecentWorkspacesInBackground(): Promise { + const loadVersion = ++this.catalogLoadVersion; + const targetId = this.hooks.remoteTargetId(); + if (targetId.length === 0) { + return; + } + this.hooks.onCatalogLoading(targetId); try { const recent = await this.coordinator.recentWorkspaces(); + if (!this.isCurrentCatalogLoad(loadVersion, targetId)) { + return; + } const assistants = await this.coordinator.assistants(); + if (!this.isCurrentCatalogLoad(loadVersion, targetId)) { + return; + } const assistantWorkspaces: RecentWorkspaceEntry[] = []; assistants.forEach((item) => { assistantWorkspaces.push({ @@ -78,7 +96,14 @@ export class RemoteWorkspaceViewModel { } }); this.pageState.setRecentWorkspaces(allWorkspaces); + this.hooks.onCatalogLoaded(targetId, allWorkspaces); + RemoteLogger.info( + `workspace catalog loaded target=${targetId} count=${allWorkspaces.length} first=${allWorkspaces.length > 0 ? allWorkspaces[0].path : ''}` + ); } catch (err) { + if (this.isCurrentCatalogLoad(loadVersion, targetId)) { + this.hooks.onCatalogFailed(targetId); + } RemoteLogger.warn(`background recent workspace load failed: ${String(err)}`); } } @@ -87,35 +112,59 @@ export class RemoteWorkspaceViewModel { if (!this.hooks.isRemoteAvailable()) { return; } + const loadVersion = ++this.catalogLoadVersion; + const targetId = this.hooks.remoteTargetId(); try { this.hooks.onBusy(true); this.hooks.onStatus(RemoteI18n.t('status.loadingRecentWorkspaces')); const recent = await this.coordinator.recentWorkspaces(); + if (!this.isCurrentCatalogLoad(loadVersion, targetId)) { + return; + } this.pageState.setRecentWorkspaces(recent); this.hooks.onStatus(recent.length > 0 ? RemoteI18n.t('status.chooseWorkspace') : RemoteI18n.t('status.noRecentWorkspaces')); } catch (err) { - this.hooks.onConnectionFailure(err); + if (this.isCurrentCatalogLoad(loadVersion, targetId)) { + this.hooks.onConnectionFailure(err); + } } finally { - this.hooks.onBusy(false); + if (this.isCurrentCatalogLoad(loadVersion, targetId)) { + this.hooks.onBusy(false); + } } } + private isCurrentCatalogLoad(loadVersion: number, targetId: string): boolean { + return targetId.length > 0 && + loadVersion === this.catalogLoadVersion && + targetId === this.hooks.remoteTargetId(); + } + private async loadAssistants(): Promise { if (!this.hooks.isRemoteAvailable()) { return; } + const loadVersion = ++this.catalogLoadVersion; + const targetId = this.hooks.remoteTargetId(); try { this.hooks.onBusy(true); this.hooks.onStatus(RemoteI18n.t('status.loadingAssistants')); const assistants = await this.coordinator.assistants(); + if (!this.isCurrentCatalogLoad(loadVersion, targetId)) { + return; + } this.pageState.setAssistants(assistants); this.hooks.onStatus(assistants.length > 0 ? RemoteI18n.t('status.chooseAssistant') : RemoteI18n.t('status.noAssistants')); } catch (err) { - this.hooks.onConnectionFailure(err); + if (this.isCurrentCatalogLoad(loadVersion, targetId)) { + this.hooks.onConnectionFailure(err); + } } finally { - this.hooks.onBusy(false); + if (this.isCurrentCatalogLoad(loadVersion, targetId)) { + this.hooks.onBusy(false); + } } } 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 cab23a76cf..fbacd26baf 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 @@ -51,6 +51,8 @@ export interface CloudAccountSettingsHooks { readonly onAccountDevices: (devices: CloudAccountDevice[]) => void; /** Snapshot the outgoing live device before teardown clears its lists. */ readonly onBeforeControlTargetChange: () => void; + /** The incoming device owns a catalog request from the start of its switch. */ + readonly onControlTargetLoading: (deviceId: string) => void; readonly onControlTargetChanged: () => void; } @@ -562,7 +564,7 @@ export class SettingsController { // Taken before the teardown, which is the last moment the outgoing device // is still readable off the page state. const previousTarget = this.restorableControlTarget(); - this.prepareAccountDeviceConnection(); + this.prepareAccountDeviceConnection(deviceId); try { const initialSync = await cloud.sessionManager.connectAccountDevice( cloud.client, @@ -574,6 +576,9 @@ export class SettingsController { RemoteLogger.info(`stale device switch completion ignored device=${deviceId}`); return; } + RemoteLogger.info( + `account device sync device=${deviceId} workspace=${initialSync.workspace.path} sessions=${initialSync.sessions.length}` + ); cloud.remoteState.setControlTarget('account_device', deviceId, device.deviceName); cloud.remoteState.setDesktopIdentity(device.deviceName, deviceId); cloud.remoteState.setWorkspace( @@ -826,7 +831,7 @@ export class SettingsController { cloud.remoteState.setAccountUsername(''); } - private prepareAccountDeviceConnection(): void { + private prepareAccountDeviceConnection(deviceId: string): void { const cloud = this.requireCloud(); cloud.hooks.onBeforeControlTargetChange(); cloud.hooks.invalidatePreview(); @@ -838,6 +843,12 @@ export class SettingsController { cloud.remoteState.setLoadingHome(true); cloud.remoteState.clearControlTarget(); cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + // The workspace catalog belongs to the control target, just like the + // workspace and session projection above. Keeping it alive across this + // boundary makes the outgoing desktop's catalog look like the incoming + // desktop's while the latter is still connecting. + cloud.remoteState.clearWorkspaceActions(); + cloud.hooks.onControlTargetLoading(deviceId); cloud.remoteState.setBusy(true); cloud.remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); cloud.remoteState.clearActiveSession(); @@ -858,6 +869,7 @@ export class SettingsController { if (clearWorkspace) { cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); cloud.remoteState.setAuthenticatedUserId(''); + cloud.remoteState.clearWorkspaceActions(); } cloud.remoteState.clearControlTarget(); cloud.remoteState.setConnectionState('disconnected'); 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 7b2e888848..05f0bcd718 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 @@ -88,7 +88,7 @@ export class ChatSessionController { }; this.activeTurn = activeTurn && activeTurn.id.length > 0 ? activeTurn : undefined; this.hasActiveRunningTurn = this.isRunningTurn(this.activeTurn); - this.awaitingPersistedTurnId = this.isCompletedTurn(this.activeTurn) ? + this.awaitingPersistedTurnId = this.isRunningTurn(this.activeTurn) || this.isCompletedTurn(this.activeTurn) ? this.turnId(this.activeTurn) : ''; this.turnJustEndedAt = 0; this.stopped = false; @@ -225,7 +225,7 @@ export class ChatSessionController { if (result.activeTurn && result.activeTurn.id.length > 0) { this.activeTurn = result.activeTurn; - if (this.isCompletedTurn(result.activeTurn)) { + if (this.isRunningTurn(result.activeTurn) || this.isCompletedTurn(result.activeTurn)) { this.awaitingPersistedTurnId = this.turnId(result.activeTurn); } } else if (result.changed && (hasAssistantMessage || this.shouldClearMissingActiveTurn(result))) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets index 876331bfd3..37825986a8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets @@ -60,6 +60,18 @@ export class MarkdownParser { const lines = text.replace(/\r\n/g, '\n').split('\n'); const blocks: ParsedMarkdownBlock[] = []; let index = 0; + const frontmatterEnd = MarkdownParser.frontmatterEnd(lines); + if (frontmatterEnd > 0) { + MarkdownParser.pushBlock( + blocks, + 'frontmatter', + 0, + '', + lines.slice(1, frontmatterEnd).join('\n').replace(/\s+$/, ''), + [] + ); + index = frontmatterEnd + 1; + } while (index < lines.length) { const raw = lines[index]; @@ -515,6 +527,19 @@ export class MarkdownParser { return type === 'paragraph' || type === 'heading' || type === 'quote'; } + private static frontmatterEnd(lines: string[]): number { + if (lines.length < 3 || lines[0].trim() !== '---') { + return -1; + } + for (let index = 1; index < lines.length; index += 1) { + const marker = lines[index].trim(); + if (marker === '---' || marker === '...') { + return index > 1 ? index : -1; + } + } + return -1; + } + private static isHeading(line: string): boolean { return /^#{1,6}\s+/.test(line); } 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 112c9dc003..4611362c18 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -87,6 +87,7 @@ import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageSta import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; import { ConversationController } from '../main/ets/pages/viewmodel/ConversationController'; +import { RemoteWorkspaceViewModel } from '../main/ets/pages/viewmodel/RemoteWorkspaceViewModel'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES @@ -815,6 +816,20 @@ export default function conversationStateUnitTest() { }); describe('MarkdownParser', () => { + it('parses document-leading YAML frontmatter without rendering its markers as dividers', 0, () => { + const blocks = MarkdownParser.parse( + '---\nname: BitFun\nvibe: calm\n---\n\n# Identity\n\nBody\n\n---\n\nTail' + ); + + expect(blocks.length).assertEqual(5); + expect(blocks[0].type).assertEqual('frontmatter'); + expect(blocks[0].text).assertEqual('name: BitFun\nvibe: calm'); + expect(blocks[1].type).assertEqual('heading'); + expect(blocks[2].type).assertEqual('paragraph'); + expect(blocks[3].type).assertEqual('divider'); + expect(blocks[4].type).assertEqual('paragraph'); + }); + it('parses common markdown blocks into stable block types', 0, () => { const blocks = MarkdownParser.parse('# Title\n\n- first\n- **second**\n\n> quote\n\n```ts\nconst value = 1;\n```\n\n| A | B |\n| --- | --- |\n| 1 | 2 |'); @@ -1450,6 +1465,38 @@ export default function conversationStateUnitTest() { }); describe('RemoteWorkspaceCoordinator', () => { + it('does not publish a workspace catalog after its target changes', 0, async () => { + const source = new FakeRemoteWorkspaceDataSource(); + source.recent = [{ + path: '/desk-a', name: 'Desk A', lastOpened: '', workspaceKind: 'normal' + }]; + const state = new RemotePageState(); + let targetId = 'desk-a'; + const viewModel = new RemoteWorkspaceViewModel( + state, + new RemoteWorkspaceCoordinator(source), + { + isRemoteAvailable: (): boolean => true, + remoteTargetId: (): string => targetId, + isBusy: (): boolean => false, + onBusy: (_busy: boolean): void => {}, + onStatus: (_status: string): void => {}, + onWorkspaceSelected: (_workspace: WorkspaceInfo): void => {}, + onCatalogLoading: (_targetId: string): void => {}, + onCatalogLoaded: (_targetId: string, _workspaces: RecentWorkspaceEntry[]): void => {}, + onCatalogFailed: (_targetId: string): void => {}, + onRefreshSessions: async (): Promise => {}, + onConnectionFailure: (_error: Object): void => {} + } + ); + + const loading = viewModel.loadRecentWorkspacesInBackground(); + targetId = 'desk-b'; + await loading; + + expect(state.recentWorkspaces.length).assertEqual(0); + }); + it('deduplicates sessions discovered across recent workspaces', 0, async () => { const source = new FakeRemoteWorkspaceDataSource(); source.sessionsByPath.set('/one', [ diff --git a/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets index d38f9b20bc..f571ab139f 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets @@ -472,6 +472,79 @@ export default function deviceDirectoryUnitTest() { expect(state.find('desk-a')?.workspaces.length).assertEqual(1); }); + it('replaces a stale catalog when a device becomes the live target', 0, () => { + const state = new DeviceDirectoryState(); + const viewModel = new DeviceDirectoryViewModel(state, { + credentials: () => undefined, + activeDeviceId: () => 'desk-b', + activeDeviceConnected: () => true + }); + viewModel.syncDevices([ + { deviceId: 'desk-b', deviceName: 'Beta', online: true } + ]); + viewModel.captureLive('desk-b', [workspace('/wrong', 'Wrong')], [ + sessionOn('desk-b', 'cached', 'Cached') + ]); + + viewModel.activateLive('desk-b', [], [sessionOn('desk-b', 'fresh', 'Fresh')]); + + expect(state.find('desk-b')?.workspaces.length).assertEqual(0); + expect(state.find('desk-b')?.sessions.some((item: RemoteSession): boolean => item.id === 'fresh')).assertTrue(); + expect(state.find('desk-b')?.sessions.some((item: RemoteSession): boolean => item.id === 'cached')).assertFalse(); + expect(state.find('desk-b')?.status).assertEqual('loading'); + }); + + it('keeps an empty incoming catalog loading until the live request completes', 0, async () => { + const source = new FakeAccountDeviceDirectory(); + const state = new DeviceDirectoryState(); + let activeId = 'desk-a'; + const viewModel = new DeviceDirectoryViewModel(state, { + credentials: () => undefined, + activeDeviceId: () => activeId, + activeDeviceConnected: () => true + }, undefined, source); + viewModel.syncDevices([ + { deviceId: 'desk-a', deviceName: 'Alpha', online: true }, + { deviceId: 'desk-b', deviceName: 'Beta', online: true } + ]); + + // A completed side-channel lookup may legitimately be empty, but it is + // not the authoritative catalog of the target that is about to connect. + await viewModel.selectDevice('desk-b'); + expect(state.find('desk-b')?.status).assertEqual('ready'); + expect(state.find('desk-b')?.workspaces.length).assertEqual(0); + + viewModel.beginLiveCatalog('desk-b'); + activeId = 'desk-b'; + viewModel.activateLive('desk-b', [], [sessionOn('desk-b', 'fresh', 'Fresh')]); + expect(state.find('desk-b')?.status).assertEqual('loading'); + + viewModel.completeLiveCatalog('desk-b', [workspace('/mac/BitFun', 'BitFun')]); + expect(state.find('desk-b')?.status).assertEqual('ready'); + expect(state.find('desk-b')?.workspaces[0].path).assertEqual('/mac/BitFun'); + }); + + it('releases the abandoned loading owner during rapid target switches', 0, () => { + const state = new DeviceDirectoryState(); + const viewModel = new DeviceDirectoryViewModel(state, { + credentials: () => undefined, + activeDeviceId: () => '', + activeDeviceConnected: () => false + }); + viewModel.syncDevices([ + { deviceId: 'desk-a', deviceName: 'Alpha', online: true }, + { deviceId: 'desk-b', deviceName: 'Beta', online: true } + ]); + viewModel.captureLive('desk-a', [workspace('/a', 'A')], []); + + viewModel.beginLiveCatalog('desk-a'); + expect(state.find('desk-a')?.status).assertEqual('loading'); + viewModel.beginLiveCatalog('desk-b'); + + expect(state.find('desk-a')?.status).assertEqual('cached'); + expect(state.find('desk-b')?.status).assertEqual('loading'); + }); + it('refreshes one live workspace without dropping cached siblings', 0, () => { const state = new DeviceDirectoryState(); const viewModel = new DeviceDirectoryViewModel(state, { 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 948f65d850..2a845cc4c8 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -3397,6 +3397,43 @@ export default function remoteControllersUnitTest() { expect(snapshots[0].messageSnapshot ? snapshots[0].messageSnapshot.length : 0).assertEqual(1); }); + it('recognizes persisted completion when running turn transitions directly to idle', 0, async () => { + const manager = new FakePollSessionManager(); + const snapshots: ChatSessionSnapshot[] = []; + const persistedAssistant = chatMessage('turn-4-assistant', 'assistant', 'Final text'); + persistedAssistant.turnId = 'turn-4'; + manager.results = [pollResult({ + version: 4, + changed: true, + sessionState: 'idle', + title: 'Session', + newMessages: [persistedAssistant], + messageSnapshot: [ + chatMessage('turn-4-user', 'user', 'Question'), + persistedAssistant + ], + totalMessageCount: 2 + })]; + const controller = new ChatSessionController(manager, { + onSnapshot: (snapshot: ChatSessionSnapshot) => snapshots.push(snapshot), + onError: (_error: Object) => {}, + canPoll: (_sessionId: string) => true + }); + + controller.start('session-4', { + pollVersion: 3, + knownMessageCount: 1, + knownModelCatalogVersion: 0 + }, activeChatMessage('turn-4', '', 'active')); + await delay(20); + controller.stop(false); + + expect(snapshots.length).assertEqual(1); + expect(snapshots[0].sessionState).assertEqual('idle'); + expect(snapshots[0].activeTurn ? snapshots[0].activeTurn.id : '').assertEqual(''); + expect(snapshots[0].completedTurnId).assertEqual('turn-4'); + }); + it('resyncs when a completed assistant message is updated during the settle window', 0, async () => { const manager = new FakePollSessionManager(); const snapshots: ChatSessionSnapshot[] = []; diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index a29ea0232c..992290c0cf 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -447,6 +447,8 @@ class AccountDeviceSwitchHarness { cachedRemoteClears: number = 0; settingsClosings: number = 0; readonly accountDeviceSnapshots: CloudAccountDevice[][] = []; + readonly loadingCatalogTargets: string[] = []; + readonly changedWorkspaceSnapshots: string[][] = []; readonly controller: SettingsController; constructor( @@ -474,7 +476,14 @@ class AccountDeviceSwitchHarness { this.accountDeviceSnapshots.push(devices.slice()); }, onBeforeControlTargetChange: (): void => {}, - onControlTargetChanged: (): void => {} + onControlTargetLoading: (deviceId: string): void => { + this.loadingCatalogTargets.push(deviceId); + }, + onControlTargetChanged: (): void => { + this.changedWorkspaceSnapshots.push( + this.remoteState.recentWorkspaces.map((item: RecentWorkspaceEntry): string => item.path) + ); + } }; const cloud: CloudAccountSettingsDependencies = { client, @@ -1338,6 +1347,21 @@ export default function transportAndGeneralChatUnitTest() { expect(harness.sessionManager.dialled.join(',')).assertEqual('desk-a,desk-b,desk-a'); }); + it('ends the outgoing workspace catalog before activating another desktop', 0, async () => { + const harness = new AccountDeviceSwitchHarness(); + await harness.controller.selectCloudAccountDevice(harness.device('desk-a'), false); + harness.remoteState.setRecentWorkspaces([{ + path: '/desk-a-only', name: 'Desk A', lastOpened: '', workspaceKind: 'normal' + }]); + + await harness.controller.selectCloudAccountDevice(harness.device('desk-b'), false); + + expect(harness.remoteState.recentWorkspaces.length).assertEqual(0); + expect(harness.loadingCatalogTargets.join(',')).assertEqual('desk-a,desk-b'); + expect(harness.changedWorkspaceSnapshots.length).assertEqual(2); + expect(harness.changedWorkspaceSnapshots[1].length).assertEqual(0); + }); + it('gives up rather than looping when the desktop to restore is gone too', 0, async () => { const harness = new AccountDeviceSwitchHarness(); await harness.controller.selectCloudAccountDevice(harness.device('desk-a'), false);