Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions src/apps/mobile/harmonyos/docs/mobile-control-console-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Scope: `src/apps/mobile/harmonyos`。不改 relay、桌面端、CLI 和
`assembleHap`、LocalTest、`theme:color-audit:all`(浅色深色)全部通过,并已装到真机
`5ZU0226202001116`。「验证要求」里的六项真机手测在两台在线桌面上全部走过并通过。

2026-08-25 补齐二维码与账号设备的统一投影:二维码目标也固定画设备头;目标 `deviceId`
已在账号目录时复用该真实设备行,不在目录时作为临时设备置顶,同时保留账号下其他电脑。
鸿蒙仍是单活动控制目标,这次只统一导航树,不改变传输拓扑。

第 4 项(跨设备打开会话)第一次跑是失败的,成因和修法记在这里,因为它牵出的是一处传输层
缺陷而不是 UI 疏漏:目标桌面不应答时,`AccountDeviceCommandTransport.send` 把调用方给的
timeout 整个丢掉,一律吃 `deviceRpc` 写死的 130 秒,于是一次失败的设备切换要挂满两分钟,
Expand Down Expand Up @@ -53,17 +57,17 @@ presence(见「4. 拉取策略」后两条),以及 `ForEach` 键撞车导
控件的占位。

所以本设计不新增页面。**多设备这一层长在既有的 `SidebarWorkspaceSection` 上**:设备成为
工作区之上的一层分组。分区标题一律为「设备」;账号设备无论几台都画设备头,用
`listDevices` 的真名,不用「未连接」这种连接状态顶替设备名。没有账号设备列表的
二维码配对才摊平。离线设备默认收成灰行,缓存只在用户展开后出现。
工作区之上的一层分组。分区标题一律为「设备」;账号设备和二维码配对都画设备头,优先使用
`listDevices` 的真名,不用「未连接」这种连接状态顶替设备名。二维码目标按 `deviceId` 与账号
目录合并;目录里没有它时保留为临时设备行。离线设备默认收成灰行,缓存只在用户展开后出现。

## 目标

- 侧栏工作区分区呈现账号下**所有**桌面设备的工作区与会话,层级为「设备 > 工作区 > 会话」。
- 展开一层只预览 3 条:点开设备不列出全量工作区,点开工作区不列出全量会话;「还有 N 个」每次再展开 3 条,不一次铺开。
- 浏览另一台设备的内容不需要任何显式的"切换控制设备"动作。
- 浏览和创建都不改变被观察那台桌面自己正在打开的工作区。
- 账号桌面无论几台都画设备头,用 `listDevices` 的真名;没有账号设备列表的二维码配对才摊平
- 账号桌面与二维码桌面都画设备头;同 `deviceId` 合并,异 `deviceId` 并存

## 非目标

Expand Down Expand Up @@ -160,18 +164,16 @@ flowchart LR
`projectionCache`,天然做到一设备一缓存。`WorkspaceGroup` / `SessionRow` / `MoreSessionsRow`
三个 `@Builder` 从 `SidebarWorkspaceSection` 原样平移进来,逻辑不变,只多一级缩进。

**`ForEach` 的键必须区分摊平占位行**。`ForEach` 把子组件绑定在它**首次创建时**的那个 item 上,
只有键变了才会重新读 item。摊平用的 `unpairedEntry` 带的正是控制目标自己的 deviceId,所以不加
前缀就会和该桌面的真实设备行撞键:冷启动先走摊平(设备列表还没回来),账号设备列表落地后
`ForEach` 认为键没变、复用旧子组件,那台桌面于是一直渲染那个**没人再更新**的占位对象——灰着、
标着「离线」,连自己的展开都点不动(点击把真实条目的 `expanded` 翻了,画面纹丝不动)。真机上
表现为「另一台是亮的,正在对话的这台反而是灰的」。修法是 `deviceKey()`:摊平期的键加
`unpaired:` 前缀,真实列表一到键全变,子组件整体重建。注意这类失效只发生在 **`ForEach` 里
实例化的子组件**上;`@Builder` 渲染的行随父组件重建,不受影响。
**`ForEach` 的键必须区分临时设备行**。`ForEach` 把子组件绑定在它**首次创建时**的那个 item 上,
只有键变了才会重新读 item。二维码刚连上而账号目录尚未返回时使用 `transientEntry`;它带的正是
控制目标自己的 deviceId,所以无前缀会与稍后落地的真实设备行撞键。`deviceKey()` 给临时行加
`transient:` 前缀;同一设备进入账号目录后键发生变化,子组件才能重建到账号持有的可观察对象上。
注意这类失效只发生在 **`ForEach` 里实例化的子组件**上;`@Builder` 渲染的行随父组件重建,
不受影响。

`SidebarWorkspaceSection` 退化为"设备列表 + 分区头"。账号下的桌面无论几台都画设备头
否则断连后工作区名会被写成「未连接」,摊平后用户看不到这是哪台机器。没有账号设备列表
的二维码配对仍摊平
`SidebarWorkspaceSection` 退化为"设备列表 + 分区头"。所有桌面都画设备头;账号目录是基础列表
活动控制目标不在目录时作为临时设备置顶,在目录中时直接复用真实行。这样账号状态与二维码通道
可以并存,但同一台电脑不会重复出现,用户也始终能看见工作区属于哪台机器

### 4. 拉取策略:展开才拉,离线不拨

Expand Down Expand Up @@ -200,7 +202,7 @@ flowchart LR
设备的工作区路径批量 `list_sessions`。磁盘回填是 `cached`:先画出来,展开后仍要被 RPC
覆盖,不能把缓存当成已经加载完。
- 数据可以一次拉全,画面不能一次铺开。点开设备只预览 3 个工作区(工作区行默认收起),
点开工作区只预览 3 条会话;「还有 N 个」每次再展开 3 条。没有设备头的摊平行保持工作区默认展开
点开工作区只预览 3 条会话;「还有 N 个」每次再展开 3 条。活动设备默认展开
磁盘缓存仍存该设备的完整工作区/会话快照,不按预览窗口裁切;「还有 N 个」和继续展开
都靠这份全量,预览游标只活在组件 `@Local` 里,冷启动回到第一批 3 条。
- 批量并发复用 `RemoteWorkspaceCoordinator` 已有的形状(一次 4 个,按给定顺序合并,因此
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export const EN_US_MESSAGES: [string, string][] = [
['sidebar.searchPlaceholder', 'Search chats'],
['sidebar.noSearchResult', 'No matching sessions'],
['sidebar.signedOutNewChat', 'New chat'],
['sidebar.scanToConnect', 'Scan to connect a computer'],
['sidebar.signInBitFunAccount', 'Sign in to BitFun'],
['sidebar.collapse', 'Collapse sidebar'],
['sidebar.restore', 'Expand sidebar'],
Expand Down Expand Up @@ -242,7 +243,7 @@ export const EN_US_MESSAGES: [string, string][] = [
['remote.settings.loginServer', 'Sign-in server'],
['remote.settings.accountSignIn', 'Sign in'],
['remote.settings.accountSigningIn', 'Signing in…'],
['remote.settings.accountLoginFailed', 'Cloud account sign-in failed. Check the relay URL and password.'],
['remote.settings.accountLoginFailed', 'Sign-in failed. Check the account details or server settings.'],
['remote.settings.relayUrlPlaceholder', 'Relay URL, for example https://relay.example.com'],
['remote.settings.accountLogout', 'Sign out'],
['remote.settings.accountLoggingOut', 'Signing out…'],
Expand Down Expand Up @@ -284,9 +285,10 @@ export const EN_US_MESSAGES: [string, string][] = [
['remote.permissions.confirmFullAccess', 'Turn on full access'],

['connect.localTitle', 'Connect a local workspace'],
['connect.chooseConnectionTitle', 'Connect a computer'],
['connect.subtitle', 'After connecting the desktop, BitFun can access repositories, run commands, and handle local development tasks.'],
['connect.obtainPairCode', 'Get a pairing code'],
['connect.obtainPairCodeBody', 'Make sure BitFun Desktop and this device use the same workspace.'],
['connect.obtainPairCodeBody', 'Open Remote Control in BitFun Desktop to get a QR code or pairing code.'],
['connect.obtainPairCodeSteps', 'In the BitFun Desktop sidebar, tap Settings > Remote control to get a pairing code.'],
['connect.havePairCode', 'I have a pairing code'],
['connect.scanPairCode', 'Scan the QR code to pair'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ export const ZH_CN_MESSAGES: [string, string][] = [
['sidebar.searchPlaceholder', '搜索对话'],
['sidebar.noSearchResult', '没有匹配的会话'],
['sidebar.signedOutNewChat', '新聊天'],
['sidebar.signInBitFunAccount', '登录BitFun账号'],
['sidebar.scanToConnect', '扫码连接电脑'],
['sidebar.signInBitFunAccount', '登录 BitFun 账号'],
['sidebar.collapse', '收起侧边栏'],
['sidebar.restore', '展开侧边栏'],

Expand Down Expand Up @@ -242,7 +243,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [
['remote.settings.loginServer', '登录服务器'],
['remote.settings.accountSignIn', '登录'],
['remote.settings.accountSigningIn', '正在登录…'],
['remote.settings.accountLoginFailed', '云账号登录失败,请检查 relay 地址和账号密码。'],
['remote.settings.accountLoginFailed', '登录失败,请检查账号密码或服务器设置。'],
['remote.settings.relayUrlPlaceholder', 'Relay 地址,例如 https://relay.example.com'],
['remote.settings.accountLogout', '退出登录'],
['remote.settings.accountLoggingOut', '正在退出…'],
Expand Down Expand Up @@ -284,9 +285,10 @@ export const ZH_CN_MESSAGES: [string, string][] = [
['remote.permissions.confirmFullAccess', '开启完全访问'],

['connect.localTitle', '连接本地工作区'],
['connect.chooseConnectionTitle', '连接电脑'],
['connect.subtitle', '连接桌面端后,BitFun 可以访问代码仓库、运行命令和处理本地开发任务。'],
['connect.obtainPairCode', '获取配对码'],
['connect.obtainPairCodeBody', '请确保 BitFun 桌面版与此设备使用同一工作空间。'],
['connect.obtainPairCodeBody', '在电脑端打开 BitFun 的远程控制,获取二维码或配对码。'],
['connect.obtainPairCodeSteps', '在 BitFun 桌面版侧边栏中,点击“设置 远程控制”以获取配对码。'],
['connect.havePairCode', '我有配对码'],
['connect.scanPairCode', '扫描二维码以配对'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,10 @@ export interface SidebarPresentationActions {
readonly close: () => void;
readonly newChat: () => void;
readonly enterCode: () => void;
/**
* Pair with or pick a desktop. Distinct from `enterCode`, which only moves
* the visible surface to Remote: a row labelled "connect a desktop" has to
* open the connect sheet, or it navigates to a placeholder and stops there.
*/
readonly connectDesktop: () => void;
/** Opens the QR scanner directly from sidebar device actions. */
readonly scanDesktop: () => void;
/** Opens the QR scanner from the device-section add button. */
readonly addDesktop: () => void;
readonly settings: () => void;
readonly openAccount: () => void;
readonly openSession: (session: RemoteSession) => void;
Expand Down Expand Up @@ -106,6 +104,7 @@ export interface SettingsPresentationActions {

export interface ConnectPresentationActions {
readonly back: () => void;
readonly openAccount: () => void;
readonly connect: (password?: string) => void;
readonly clearPairing: () => void;
readonly urlChanged: (url: string) => void;
Expand Down Expand Up @@ -139,7 +138,7 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions {
selectWorkspace: () => {}, draftChanged: () => {}, voiceInput: () => {}, selectModel: () => {}, send: () => {}
},
onSidebar: {
close: () => {}, newChat: () => {}, enterCode: () => {}, connectDesktop: () => {},
close: () => {}, newChat: () => {}, enterCode: () => {}, scanDesktop: () => {}, addDesktop: () => {},
settings: () => {}, openAccount: () => {},
openSession: () => {}, archive: () => {}, exportSession: () => {}, deleteSession: () => {}
},
Expand All @@ -154,7 +153,8 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions {
setLanguage: async () => {}
},
onConnect: {
back: () => {}, connect: () => {}, clearPairing: () => {}, urlChanged: () => {}, userChanged: () => {},
back: () => {}, openAccount: () => {}, connect: () => {}, clearPairing: () => {},
urlChanged: () => {}, userChanged: () => {},
detected: () => DetectedUrlAction.INVALID, inputVisible: () => {}, paste: () => {}, scan: () => {},
cloudListDevices: async () => [], cloudSelectDevice: async () => {}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export struct AppSidebarSurface {
onClose: this.actions.onSidebar.close,
onNewChat: this.actions.onSidebar.newChat,
onEnterCode: this.actions.onSidebar.enterCode,
onScanDesktop: this.actions.onSidebar.scanDesktop,
onOpenViewSettings: this.onOpenRemoteViewSettings,
onSearchQueryChange: (query: string) => {
this.actions.onRemoteHome.queryChanged(query);
Expand All @@ -71,6 +72,7 @@ export struct AppSidebarSurface {
workspacePath: this.remotePageState.workspacePath,
workspaceKind: this.remotePageState.workspaceKind,
connectionState: this.remotePageState.connectionState,
controlTargetType: this.remotePageState.controlTargetType,
controlTargetDeviceId: this.remotePageState.controlTargetDeviceId,
controlTargetDeviceName: this.remotePageState.controlTargetDeviceName ||
this.remotePageState.desktopName,
Expand All @@ -86,7 +88,7 @@ export struct AppSidebarSurface {
},
onToggleDevice: this.actions.onRemoteHome.toggleDirectoryDevice,
onRetryDevice: this.actions.onRemoteHome.retryDirectoryDevice,
onConnect: this.actions.onSidebar.connectDesktop
onAdd: this.actions.onSidebar.addDesktop
})
}

Expand Down Expand Up @@ -186,6 +188,7 @@ export struct AppConnectSurface {
accountUsername: this.remotePageState.accountUsername,
openIntent: this.openIntent,
onBack: this.actions.onConnect.back,
onOpenAccount: this.actions.onConnect.openAccount,
onConnect: this.actions.onConnect.connect,
onRemoteUrlChange: this.actions.onConnect.urlChanged,
onUserIdChange: this.actions.onConnect.userChanged,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy';
import { SessionDetailsView } from './SessionDetailsView';
import { SidebarGlyph } from './SidebarGlyphs';
import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionActionPolicy';

/**
* How many conversations show before the rest go behind an overflow row.
Expand All @@ -37,6 +38,7 @@ export struct AppSidebar {
@Event onClose: () => void = () => {};
@Event onNewChat: () => void = () => {};
@Event onEnterCode: () => void = () => {};
@Event onScanDesktop: () => void = () => {};
@Event onCollapse: () => void = () => {};
@Event onOpenViewSettings: () => void = () => {};
@Event onSearchQueryChange: (query: string) => void = (_query: string) => {};
Expand Down Expand Up @@ -90,7 +92,7 @@ export struct AppSidebar {
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ bottom: 84 })
.padding({ bottom: this.usesCompactFooter() ? 84 : 120 })
}
.width('100%')
.height('100%')
Expand All @@ -103,7 +105,7 @@ export struct AppSidebar {
// visually sliced by the chat button sitting on top of it.
Column()
.width('100%')
.height(92)
.height(this.usesCompactFooter() ? 92 : 120)
.hitTestBehavior(HitTestMode.None)
.linearGradient({
angle: 180,
Expand Down Expand Up @@ -347,17 +349,46 @@ export struct AppSidebar {

@Builder
private SignedOutFooter() {
Button(RemoteI18n.t('sidebar.signInBitFunAccount'))
Column({ space: 8 }) {
if (SidebarConnectionActionPolicy.shouldShowScan(this.connectionState)) {
Button() {
Text(RemoteI18n.t('sidebar.scanToConnect'))
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor(INK)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Clip })
}
.width('100%')
.height(48)
.padding(0)
.backgroundColor(CARD)
.border({ width: 1, color: LINE })
.borderRadius(16)
.onClick(() => {
this.onScanDesktop();
})
}

Button() {
Text(RemoteI18n.t('sidebar.signInBitFunAccount'))
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(CARD)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Clip })
}
.width('100%')
.height(48)
.padding(0)
.backgroundColor(INK)
.borderRadius(16)
.onClick(() => {
this.onOpenAccount();
})
}
.width('100%')
.height(48)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(CARD)
.backgroundColor(INK)
.borderRadius(16)
.onClick(() => {
this.onOpenAccount();
})
.zIndex(2)
}

@Builder
Expand Down Expand Up @@ -690,4 +721,9 @@ export struct AppSidebar {
private isAccountAuthenticated(): boolean {
return this.accountUserId.trim().length > 0;
}

private usesCompactFooter(): boolean {
return this.isAccountAuthenticated() ||
!SidebarConnectionActionPolicy.shouldShowScan(this.connectionState);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export struct BitFunAccountLoginPage {
@Local password: string = '';
@Local errorText: string = '';
@Local isBusy: boolean = false;
@Local showAdvanced: boolean = false;

build() {
Stack({ alignContent: Alignment.TopStart }) {
Expand Down Expand Up @@ -55,21 +56,47 @@ export struct BitFunAccountLoginPage {
.type(InputType.Password)
.onChange((value: string) => { this.password = value; })

Text(RemoteI18n.t('remote.settings.loginServer'))
.fontSize(13)
.fontColor(MUTED)
.width('100%')
.margin({ left: 4, top: 26, bottom: 8 })
Row() {
Text(RemoteI18n.t('sheet.advancedOptions'))
.fontSize(13)
.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;
})

TextInput({ placeholder: RemoteI18n.t('remote.settings.relayUrlPlaceholder'), text: this.relayUrl })
.height(52)
.fontSize(14)
.fontColor(INK)
.placeholderColor(SUBTLE)
.backgroundColor(CARD)
.borderRadius(16)
.padding({ left: 18, right: 18 })
.onChange((value: string) => { this.relayUrl = value; })
if (this.showAdvanced) {
Text(RemoteI18n.t('remote.settings.loginServer'))
.fontSize(13)
.fontColor(MUTED)
.width('100%')
.margin({ left: 4, top: 8, bottom: 8 })

TextInput({ placeholder: RemoteI18n.t('remote.settings.relayUrlPlaceholder'), text: this.relayUrl })
.height(52)
.fontSize(14)
.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)
Expand Down
Loading