From 90f0aed42f27501b6f0602b55dba8bf2c77a07f3 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 25 Aug 2026 20:54:35 +0800 Subject: [PATCH] feat(harmonyos): organize sidebar by selected device --- .../wide-conversation-navigation-design.md | 11 +- .../entry/src/main/ets/i18n/EnUsMessages.ets | 4 + .../entry/src/main/ets/i18n/ZhCnMessages.ets | 4 + .../actions/AppRootPresentationActions.ets | 6 +- .../components/AppRootOverlaySurfaces.ets | 6 +- .../main/ets/pages/components/AppSidebar.ets | 5 +- .../pages/components/SidebarDeviceGroup.ets | 69 +++- .../components/SidebarWorkspacePicker.ets | 156 +++++++++ .../components/SidebarWorkspaceSection.ets | 301 +++++++++++++----- .../pages/components/WideConversationHost.ets | 6 +- .../main/ets/pages/runtime/AppRootRuntime.ets | 36 +++ .../runtime/AppRootRuntimeComposition.ets | 9 +- .../ets/pages/state/DeviceDirectoryState.ets | 7 + .../viewmodel/DeviceDirectoryViewModel.ets | 23 +- .../ConversationPresentationUnit.test.ets | 10 +- .../src/test/DeviceDirectoryUnit.test.ets | 57 ++-- 16 files changed, 552 insertions(+), 158 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets diff --git a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md index d1aeed2166..ee1b63b2c3 100644 --- a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md +++ b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md @@ -40,6 +40,8 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 - 固定来源选择器改为 `clear(false)` / `pushPath(..., false)` 后重新安装 HAP,并在点击后连续抓取中间帧;本地与远程完整布局直接更新,未出现页面滑入、缩放或淡入转场。该无转场路径只由宽屏来源选择器调用。 - 移除远程宽屏菜单入口后重新安装 HAP,在 Mate X7 展开态验证:远程 master 顶部只保留右侧设置按钮,控件树中该 header 仅有 `[897,177][1016,296]` 的右上点击区,没有左上全局侧边栏点击区;来源选择器仍可一步切回本地。 - 设备切换进入 busy 或工作区变化时清空远程项目创建菜单;空工作区路径不渲染创建按钮,也不能与菜单关闭哨兵值匹配,避免选择设备后误弹 Code/Cowork 菜单。 +- 设备目录采用“设备列表 → 当前设备的工作区树”,不再把工作区和会话分别嵌套在每一台设备下。当前设备使用唯一卡片选中态;点击在线设备同时切换控制目标并进入该设备 Remote Home,点击离线设备只查看缓存目录。 +- 工作区标题旁的加号打开当前设备已有工作区选择器,语义为“添加工作区”,不伪装成协议尚不支持的远端建目录;每个工作区行自己的加号在该工作区新建会话。设备/工作区目录模式不再在其上方重复本地最近对话。 - 未配对时远程设备身份保持为空,由界面显示“尚未连接桌面端”;只接受二维码 `dn` 参数或账号设备返回的真实名称,不再使用 `MacBook Pro` 伪默认值,清除配对时同步清空设备身份。 - 移除伪设备名后重新安装 HAP,在 Mate X7 展开未连接态验证:右侧远程占位标题显示“尚未连接桌面端”,控件树中不存在 `MacBook` 文本。 - 在同一设备的折叠单屏态(`1080 x 2444`)验证:页面保持原单屏头部和 Composer,可打开原侧边栏;点击 `Remote` 继续打开原“选择桌面设备”Sheet,系统返回可关闭 Sheet 并恢复本地 Home;本地历史会话的显示保持原样。 @@ -341,11 +343,10 @@ MasterDetail -> 双屏和三屏共同使用的 master-detail 保持现有远程能力: - 在远程标题区域与会话列表之间显示同一个来源选择器,并选中 `Remote`。 -- 当前设备和连接状态。 -- 工作区选择。 -- 远程会话搜索和分页。 -- 新建远程会话。 -- 打开和删除远程会话。 +- 先显示平铺设备列表和在线状态,当前设备只保留一个卡片选中态。 +- 下方只显示当前设备的工作区 → 会话树,不在每台设备行内复制目录。 +- 工作区标题加号添加桌面已有工作区,工作区行加号在其中新建远程会话。 +- 点击工作区切换工作区并展开会话;点击会话原位打开。 - 连接、重连和远程设置。 未连接时,仅将远程内容区域替换为断开状态和连接操作。来源选择器必须继续显示。 diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index 52383c847e..49a1a63cb0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -110,6 +110,10 @@ export const EN_US_MESSAGES: [string, string][] = [ ['sidebar.generalChat', 'General chat'], ['sidebar.conversations', 'Recent chats'], ['sidebar.devices', 'Devices'], + ['sidebar.workspaces', 'Workspaces'], + ['sidebar.deviceOnline', 'Online'], + ['sidebar.addWorkspace', 'Add workspace'], + ['sidebar.selectWorkspace', 'Select workspace'], ['sidebar.workspacesOffline', 'Disconnected'], ['sidebar.connectDesktop', 'Connect a computer'], ['sidebar.addConnection', 'Add connection'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index d8f442f8da..09abf57e2f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -110,6 +110,10 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['sidebar.generalChat', '普通对话'], ['sidebar.conversations', '最近对话'], ['sidebar.devices', '设备'], + ['sidebar.workspaces', '工作区'], + ['sidebar.deviceOnline', '在线'], + ['sidebar.addWorkspace', '添加工作区'], + ['sidebar.selectWorkspace', '选择工作区'], ['sidebar.workspacesOffline', '未连接'], ['sidebar.connectDesktop', '连接电脑'], ['sidebar.addConnection', '添加连接'], 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 d4c6013f2e..7f3c290baf 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 @@ -61,7 +61,8 @@ export interface RemoteHomePresentationActions { readonly createAssistant: () => void; readonly createInWorkspace: (path: string, agentType: string, deviceId?: string) => void; readonly createInWorkspaceInPlace: (path: string, agentType: string, deviceId?: string) => void; - readonly toggleDirectoryDevice: (deviceId: string) => void; + readonly selectDirectoryDevice: (deviceId: string) => void; + readonly addDirectoryWorkspace: (deviceId: string) => void; readonly retryDirectoryDevice: (deviceId: string) => void; readonly openSession: (session: RemoteSession) => void; readonly openSessionInPlace: (session: RemoteSession) => void; @@ -130,7 +131,8 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions { selectAssistant: () => {}, cancelWorkspace: () => {}, cancelAssistant: () => {}, queryChanged: () => {}, search: () => {}, loadMore: () => {}, reconnect: () => {}, disconnect: () => {}, clearPairing: () => {}, create: () => {}, createInPlace: () => {}, createAssistant: () => {}, createInWorkspace: () => {}, - createInWorkspaceInPlace: () => {}, toggleDirectoryDevice: () => {}, retryDirectoryDevice: () => {}, + createInWorkspaceInPlace: () => {}, selectDirectoryDevice: () => {}, addDirectoryWorkspace: () => {}, + retryDirectoryDevice: () => {}, openSession: () => {}, openSessionInPlace: () => {}, deleteSession: () => {} }, onRemoteCreate: { 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 bf0e844df0..df4d679bed 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 @@ -42,6 +42,7 @@ export struct AppSidebarSurface { accountUserId: this.remotePageState.accountUserId, showViewSettingsButton: false, showWorkspaceSection: true, + showConversationSection: false, contentSlot: () => { this.WorkspaceContent() }, @@ -78,6 +79,7 @@ export struct AppSidebarSurface { this.remotePageState.desktopName, selectedSessionId: this.remoteSelectedSessionId(), runningSessionId: this.remoteRunningSessionId(), + workspacePickerPlacement: this.sessionDetailsPlacement, onOpenSession: this.actions.onSidebar.openSession, onCreateInWorkspace: (deviceId: string, path: string) => { this.actions.onSidebar.close(); @@ -86,9 +88,9 @@ export struct AppSidebarSurface { onOpenWorkspace: (deviceId: string, path: string) => { this.actions.onRemoteHome.selectWorkspace(path, deviceId); }, - onToggleDevice: this.actions.onRemoteHome.toggleDirectoryDevice, + onSelectDevice: this.actions.onRemoteHome.selectDirectoryDevice, onRetryDevice: this.actions.onRemoteHome.retryDirectoryDevice, - onAdd: this.actions.onSidebar.addDesktop + onAddWorkspace: this.actions.onRemoteHome.addDirectoryWorkspace }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index 2c13be1dfe..e2f3965825 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -35,6 +35,7 @@ export struct AppSidebar { @Param showCollapseButton: boolean = false; @Param showViewSettingsButton: boolean = false; @Param showWorkspaceSection: boolean = false; + @Param showConversationSection: boolean = true; @Event onClose: () => void = () => {}; @Event onNewChat: () => void = () => {}; @Event onEnterCode: () => void = () => {}; @@ -85,7 +86,9 @@ export struct AppSidebar { // saying whether it runs here or on a desktop. Scroll() { Column() { - this.ConversationSection() + if (this.showConversationSection) { + this.ConversationSection() + } if (this.showWorkspaceSection) { this.contentSlot() } 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 ff8f29a97f..ba6ccf5db2 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 @@ -4,9 +4,9 @@ import { DeviceDirectoryEntry } from '../state/DeviceDirectoryState'; import { SessionListInputs, SessionListProjection, - SessionListProjectionCache, - SessionListProjector + SessionListProjectionCache } from '../policy/SessionListProjection'; +import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; import { SidebarDirectoryPreviewPolicy, SidebarPreviewCursor @@ -198,6 +198,15 @@ export struct SidebarDeviceGroup { @Builder private WorkspaceRow(entry: RecentWorkspaceEntry) { Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph(this.isWorkspaceExpanded(entry.path) ? + $r('sys.symbol.chevron_down') : $r('sys.symbol.chevron_right')) + .fontSize(13) + .fontColor([MUTED]) + .hitTestBehavior(HitTestMode.None) + } + .width(16) + .height(18) SymbolGlyph($r('sys.symbol.folder')) .fontSize(19) .fontColor([this.isCurrentWorkspace(entry.path) ? INK : MUTED]) @@ -211,23 +220,16 @@ export struct SidebarDeviceGroup { .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(16) - .fontColor([MUTED]) + SymbolGlyph($r('sys.symbol.plus')) + .fontSize(17) + .fontColor([INK]) } .width(30) .height(40) - .opacity(0.6) .accessibilityText(RemoteI18n.t('sidebar.newInWorkspace')) .onClick(() => { this.onCreateInWorkspace(entry.path); }) - SymbolGlyph(this.isWorkspaceExpanded(entry.path) ? - $r('sys.symbol.chevron_down') : $r('sys.symbol.chevron_right')) - .fontSize(13) - .fontColor([MUTED]) - .width(16) - .height(16) } .width('100%') .height(46) @@ -236,6 +238,7 @@ export struct SidebarDeviceGroup { .borderRadius(10) .onClick(() => { this.toggleWorkspace(entry.path); + this.onOpenWorkspace(entry.path); }) .gesture(LongPressGesture({ repeat: false }).onAction(() => { this.onOpenWorkspace(entry.path); @@ -271,7 +274,7 @@ export struct SidebarDeviceGroup { } .width('100%') .height(44) - .padding({ left: this.bodyIndent() + 16, right: 10 }) + .padding({ left: this.bodyIndent() + 34, right: 10 }) .alignItems(VerticalAlign.Center) .backgroundColor(item.id === this.selectedSessionId ? SOFT : '#00000000') .borderRadius(10) @@ -317,7 +320,7 @@ export struct SidebarDeviceGroup { } .width('100%') .height(40) - .padding({ left: this.bodyIndent() + 16 }) + .padding({ left: this.bodyIndent() + 34 }) .alignItems(VerticalAlign.Center) .onClick(() => { this.revealMoreSessions(path); @@ -359,7 +362,37 @@ export struct SidebarDeviceGroup { } private workspaceEntries(): RecentWorkspaceEntry[] { - return this.projection().projects.filter((entry: RecentWorkspaceEntry) => entry.path.length > 0); + // The shared projection intentionally removes assistant workspaces because + // the desktop conversation sidebar presents those sessions as top-level + // chats. The device directory has a different information architecture: + // every session belongs below its workspace, including Claw/assistant + // workspaces. Reintroduce the complete device catalog here without changing + // the shared desktop projection contract. + const entries = this.projection().projects.slice(); + if (this.workspacePath.length > 0 && + !entries.some((item: RecentWorkspaceEntry): boolean => item.path === this.workspacePath)) { + entries.unshift({ + path: this.workspacePath, + name: this.workspaceName, + lastOpened: '', + workspaceKind: this.workspaceKind + }); + } + this.recentWorkspaces.forEach((item: RecentWorkspaceEntry) => { + if (item.path.length > 0 && + !entries.some((entry: RecentWorkspaceEntry): boolean => entry.path === item.path)) { + entries.push(item); + } + }); + const query = this.query.trim().toLowerCase(); + if (query.length === 0) { + return entries; + } + return entries.filter((entry: RecentWorkspaceEntry): boolean => { + const label = (entry.name || this.basename(entry.path)).toLowerCase(); + return label.indexOf(query) >= 0 || entry.path.toLowerCase().indexOf(query) >= 0 || + this.sessionsFor(entry.path).length > 0; + }); } private visibleWorkspaceEntries(): RecentWorkspaceEntry[] { @@ -377,7 +410,11 @@ export struct SidebarDeviceGroup { } private sessionsFor(path: string): RemoteSession[] { - return SessionListProjector.sessionsForProject(this.projection(), path); + const target = ConversationSessionFilterPolicy.normalizeWorkspacePath(path); + return this.projection().filtered.filter((item: RemoteSession): boolean => { + const itemPath = item.workspacePath ? item.workspacePath : this.workspacePath; + return ConversationSessionFilterPolicy.normalizeWorkspacePath(itemPath) === target; + }); } private sessionVisibleCount(path: string): number { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets new file mode 100644 index 0000000000..88fd43b67c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets @@ -0,0 +1,156 @@ +import { RecentWorkspaceEntry } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; + +@ComponentV2 +export struct SidebarWorkspacePicker { + @Param deviceName: string = ''; + @Param workspaces: RecentWorkspaceEntry[] = []; + @Param selectedWorkspacePath: string = ''; + @Param loading: boolean = false; + @Event onClose: () => void = () => {}; + @Event onSelectWorkspace: (path: string) => void = (_path: string) => {}; + + build() { + Column() { + this.Header() + Divider().color(LINE) + if (this.loading && this.workspaces.length === 0) { + this.LoadingState() + } else if (this.workspaces.length === 0) { + this.EmptyState() + } else { + Scroll() { + Column({ space: 4 }) { + ForEach(this.workspaces, (workspace: RecentWorkspaceEntry) => { + this.WorkspaceRow(workspace) + }, (workspace: RecentWorkspaceEntry): string => workspace.path) + } + .width('100%') + .padding({ top: 10, bottom: 18 }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + } + .width('100%') + .height('100%') + .padding({ left: 18, right: 18, top: 10 }) + .backgroundColor(PAGE_BG) + } + + @Builder + private Header() { + Row({ space: 12 }) { + Column({ space: 2 }) { + Text(RemoteI18n.t('sidebar.selectWorkspace')) + .width('100%') + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + Text(this.deviceName) + .width('100%') + .fontSize(13) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(17) + .fontColor([MUTED]) + } + .width(40) + .height(40) + .borderRadius(20) + .backgroundColor(SOFT) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.onClose()) + } + .width('100%') + .height(66) + .alignItems(VerticalAlign.Center) + } + + @Builder + private WorkspaceRow(workspace: RecentWorkspaceEntry) { + Row({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.folder')) + .fontSize(20) + .fontColor([INK]) + } + .width(34) + .height(34) + .borderRadius(10) + .backgroundColor(CARD) + Column({ space: 3 }) { + Text(workspace.name || this.basename(workspace.path)) + .width('100%') + .fontSize(16) + .fontWeight(workspace.path === this.selectedWorkspacePath ? FontWeight.Medium : FontWeight.Regular) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(workspace.path) + .width('100%') + .fontSize(12) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph(workspace.path === this.selectedWorkspacePath ? + $r('sys.symbol.checkmark') : $r('sys.symbol.chevron_right')) + .fontSize(workspace.path === this.selectedWorkspacePath ? 17 : 15) + .fontColor([workspace.path === this.selectedWorkspacePath ? INK : MUTED]) + } + .width(40) + .height(40) + } + .width('100%') + .height(68) + .padding({ left: 10, right: 8 }) + .borderRadius(14) + .backgroundColor(workspace.path === this.selectedWorkspacePath ? SOFT : PAGE_BG) + .onClick(() => this.onSelectWorkspace(workspace.path)) + } + + @Builder + private LoadingState() { + Column({ space: 12 }) { + LoadingProgress().width(22).height(22).color(MUTED) + Text(RemoteI18n.t('common.loading')).fontSize(14).fontColor(MUTED) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + private EmptyState() { + Column({ space: 8 }) { + SymbolGlyph($r('sys.symbol.folder')).fontSize(30).fontColor([MUTED]) + Text(RemoteI18n.t('sidebar.emptyWorkspaces')) + .fontSize(14) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + private basename(path: string): string { + const normalized = path.replace(/\/+$/, ''); + const index = normalized.lastIndexOf('/'); + return index >= 0 ? normalized.substring(index + 1) : normalized; + } +} 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 b844ca0146..b3fefa0f76 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 @@ -5,18 +5,24 @@ import { DeviceDirectoryState, emptyDeviceDirectoryEntry } from '../state/DeviceDirectoryState'; -import { MUTED } from './Theme'; import { SidebarDirectoryPreviewPolicy } from '../policy/SidebarDirectoryPreviewPolicy'; import { SidebarDeviceProjectionPolicy } from '../policy/SidebarDeviceProjectionPolicy'; +import { + SettingsPlacement, + SettingsPlacementPolicy, + SettingsSheetKind +} from '../policy/SettingsPlacementPolicy'; +import { AdaptiveSheetOptions } from './AdaptiveSheetOptions'; +import { GREEN, INK, MUTED, PAGE_BG, SOFT, SUBTLE } from './Theme'; import { SidebarDeviceGroup } from './SidebarDeviceGroup'; +import { SidebarWorkspacePicker } from './SidebarWorkspacePicker'; /** - * The workspace half of the unified sidebar. + * Flat device selector followed by the selected device's workspace tree. * - * Account desktops and QR-paired desktops share the same hierarchy: - * device -> workspace -> session. A QR target already present in the account - * directory merges by device id; otherwise it is prepended as a transient - * device without hiding the account's other desktops. + * Device rows choose the remote context; they no longer each own a nested + * directory. This keeps one visible workspace hierarchy and makes its owner + * explicit without repeating the same tree under every account desktop. */ @ComponentV2 export struct SidebarWorkspaceSection { @@ -33,87 +39,182 @@ export struct SidebarWorkspaceSection { @Param selectedSessionId: string = ''; @Param runningSessionId: string = ''; @Param query: string = ''; + @Param workspacePickerPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SessionDetails); @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onSessionActions: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onCreateInWorkspace: (deviceId: string, path: string) => void = (_deviceId: string, _path: string) => {}; @Event onOpenWorkspace: (deviceId: string, path: string) => void = (_deviceId: string, _path: string) => {}; - @Event onToggleDevice: (deviceId: string) => void = (_deviceId: string) => {}; + @Event onSelectDevice: (deviceId: string) => void = (_deviceId: string) => {}; @Event onRetryDevice: (deviceId: string) => void = (_deviceId: string) => {}; - @Event onAdd: () => void = () => {}; + @Event onAddWorkspace: (deviceId: string) => void = (_deviceId: string) => {}; @Local visibleDeviceCount: number = SidebarDirectoryPreviewPolicy.PREVIEW_COUNT; + @Local showWorkspacePicker: boolean = false; + @Local workspacePickerDeviceId: string = ''; private readonly transientEntry: DeviceDirectoryEntry = emptyDeviceDirectoryEntry('', '', false); build() { Column() { - this.SectionHeader() + this.DeviceSectionHeader() if (this.deviceEntries().length > 0) { ForEach(this.visibleDeviceEntries(), (entry: DeviceDirectoryEntry) => { - SidebarDeviceGroup({ - device: entry, - sessions: this.sessionsForDevice(entry), - recentWorkspaces: this.workspacesForDevice(entry), - workspaceName: this.liveWorkspaceName(entry), - workspacePath: this.liveWorkspacePath(entry), - workspaceKind: this.liveWorkspaceKind(entry), - selectedSessionId: this.selectedSessionIdFor(entry), - runningSessionId: this.isActiveDevice(entry.deviceId) ? this.runningSessionId : '', - query: this.query, - showHeader: true, - isActive: this.isActiveDevice(entry.deviceId), - onToggle: () => { - this.toggleEntry(entry); - }, - onRetry: () => { - if (this.isDirectoryEntry(entry)) { - this.onRetryDevice(entry.deviceId); - } - }, - onOpenSession: this.onOpenSession, - onSessionActions: this.onSessionActions, - onCreateInWorkspace: (path: string) => { - this.onCreateInWorkspace(entry.deviceId, path); - }, - onOpenWorkspace: (path: string) => { - this.onOpenWorkspace(entry.deviceId, path); - } - }) + this.DeviceRow(entry) }, (entry: DeviceDirectoryEntry): string => this.deviceKey(entry)) if (this.visibleDeviceCount < this.deviceEntries().length) { this.MoreDevicesRow() } + if (this.selectedEntry()) { + this.WorkspaceSection(this.selectedEntry() as DeviceDirectoryEntry) + } } } .width('100%') .alignItems(HorizontalAlign.Start) .margin({ top: 18 }) + .bindSheet($$this.showWorkspacePicker, this.WorkspacePicker(), this.workspacePickerSheetOptions()) } @Builder - private SectionHeader() { - Row() { - Text(RemoteI18n.t('sidebar.devices')) - .id('sidebar-workspace-section') - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - Blank() + private DeviceSectionHeader() { + Text(RemoteI18n.t('sidebar.devices')) + .id('sidebar-workspace-section') + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .width('100%') + .height(38) + .textAlign(TextAlign.Start) + } + + @Builder + private DeviceRow(entry: DeviceDirectoryEntry) { + Row({ space: 12 }) { Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.plus')) - .fontSize(17) - .fontColor([MUTED]) + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(21) + .fontColor([entry.online ? INK : SUBTLE]) + .hitTestBehavior(HitTestMode.None) } - .width(32) + .width(28) .height(32) - .accessibilityText(RemoteI18n.t('sidebar.addConnection')) - .onClick(() => { - this.onAdd(); - }) + .hitTestBehavior(HitTestMode.None) + Column({ space: 2 }) { + Text(entry.deviceName) + .fontSize(15) + .fontWeight(this.isSelected(entry) ? FontWeight.Medium : FontWeight.Regular) + .fontColor(entry.online ? INK : SUBTLE) + .width('100%') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .hitTestBehavior(HitTestMode.None) + Row({ space: 6 }) { + if (entry.online) { + Text('') + .width(7) + .height(7) + .backgroundColor(GREEN) + .borderRadius(4) + .hitTestBehavior(HitTestMode.None) + } + Text(RemoteI18n.t(entry.online ? 'sidebar.deviceOnline' : 'sidebar.deviceOffline')) + .fontSize(12) + .fontColor(entry.online ? GREEN : SUBTLE) + .hitTestBehavior(HitTestMode.None) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + if (entry.status === 'loading') { + LoadingProgress() + .width(14) + .height(14) + .color(MUTED) + .hitTestBehavior(HitTestMode.None) + } + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13) + .fontColor([MUTED]) + .hitTestBehavior(HitTestMode.None) + } + .width(16) + .height(20) + .hitTestBehavior(HitTestMode.None) } .width('100%') - .height(38) + .height(66) + .padding({ left: 10, right: 8 }) .alignItems(VerticalAlign.Center) + .backgroundColor(this.isSelected(entry) ? SOFT : PAGE_BG) + .borderRadius(16) + .opacity(entry.online ? 1 : 0.72) + .onClick(() => { + this.onSelectDevice(entry.deviceId); + }) + } + + @Builder + private WorkspaceSection(entry: DeviceDirectoryEntry) { + Column() { + Row() { + Text(RemoteI18n.t('sidebar.workspaces')) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Blank() + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.plus')) + .fontSize(18) + .fontColor([this.canUseDevice(entry) ? INK : SUBTLE]) + } + .width(34) + .height(34) + .opacity(this.canUseDevice(entry) ? 1 : 0.38) + .accessibilityText(RemoteI18n.t('sidebar.addWorkspace')) + .onClick(() => { + if (this.canUseDevice(entry)) { + this.openWorkspacePicker(entry); + } + }) + } + .width('100%') + .height(48) + .margin({ top: 16 }) + .alignItems(VerticalAlign.Center) + + SidebarDeviceGroup({ + device: entry, + sessions: this.sessionsForDevice(entry), + recentWorkspaces: this.workspacesForDevice(entry), + workspaceName: this.liveWorkspaceName(entry), + workspacePath: this.liveWorkspacePath(entry), + workspaceKind: this.liveWorkspaceKind(entry), + selectedSessionId: this.selectedSessionIdFor(entry), + runningSessionId: this.isActiveDevice(entry.deviceId) ? this.runningSessionId : '', + query: this.query, + showHeader: false, + isActive: this.isActiveDevice(entry.deviceId), + onRetry: () => { + if (this.isDirectoryEntry(entry)) { + this.onRetryDevice(entry.deviceId); + } + }, + onOpenSession: this.onOpenSession, + onSessionActions: this.onSessionActions, + onCreateInWorkspace: (path: string) => { + this.onCreateInWorkspace(entry.deviceId, path); + }, + onOpenWorkspace: (path: string) => { + this.onOpenWorkspace(entry.deviceId, path); + } + }) + } + .width('100%') + .alignItems(HorizontalAlign.Start) } @Builder @@ -151,49 +252,77 @@ export struct SidebarWorkspaceSection { return [this.transientEntry].concat(directoryEntries); } + @Builder + private WorkspacePicker() { + SidebarWorkspacePicker({ + deviceName: this.workspacePickerDevice().deviceName, + workspaces: this.workspacesForDevice(this.workspacePickerDevice()), + selectedWorkspacePath: this.isActiveDevice(this.workspacePickerDevice().deviceId) ? + this.workspacePath : '', + loading: this.workspacePickerDevice().status === 'loading', + onClose: () => { + this.showWorkspacePicker = false; + }, + onSelectWorkspace: (path: string) => { + const deviceId = this.workspacePickerDeviceId; + this.showWorkspacePicker = false; + this.onOpenWorkspace(deviceId, path); + } + }) + } + + private openWorkspacePicker(entry: DeviceDirectoryEntry): void { + this.workspacePickerDeviceId = entry.deviceId; + this.showWorkspacePicker = true; + this.onAddWorkspace(entry.deviceId); + } + + private workspacePickerDevice(): DeviceDirectoryEntry { + const entry = this.deviceEntries().find((item: DeviceDirectoryEntry): boolean => + item.deviceId === this.workspacePickerDeviceId + ); + return entry || this.transientEntry; + } + + private workspacePickerSheetOptions(): SheetOptions { + return AdaptiveSheetOptions.fromPlacement(this.workspacePickerPlacement, true); + } + private visibleDeviceEntries(): DeviceDirectoryEntry[] { const entries = this.deviceEntries(); - if (entries.length <= 1) { - return entries; + return entries.length <= this.visibleDeviceCount ? entries : entries.slice(0, this.visibleDeviceCount); + } + + private selectedEntry(): DeviceDirectoryEntry | undefined { + const entries = this.deviceEntries(); + const selectedId = this.directoryState.selectedDeviceId.trim(); + const selected = entries.find((entry: DeviceDirectoryEntry): boolean => entry.deviceId === selectedId); + if (selected) { + return selected; } - return entries.length <= this.visibleDeviceCount ? - entries : entries.slice(0, this.visibleDeviceCount); + const activeId = this.controlTargetDeviceId.trim(); + return entries.find((entry: DeviceDirectoryEntry): boolean => entry.deviceId === activeId) || entries[0]; + } + + private isSelected(entry: DeviceDirectoryEntry): boolean { + const selected = this.selectedEntry(); + return !!selected && selected.deviceId === entry.deviceId; } - /** - * The transient row deliberately uses a different key. When the same device - * later appears in the account directory, ForEach must rebuild the child - * against the account-owned observable entry instead of retaining the - * temporary object. - */ private deviceKey(entry: DeviceDirectoryEntry): string { return this.isDirectoryEntry(entry) ? entry.deviceId : `transient:${entry.deviceId}`; } private updateTransientEntry(): void { const targetId = this.controlTargetDeviceId.trim(); - const connected = this.isConnected(); - if (this.transientEntry.deviceId !== targetId) { - this.transientEntry.deviceId = targetId; - this.transientEntry.expanded = connected; - } else if (this.transientEntry.online !== connected) { - this.transientEntry.expanded = connected; - } + this.transientEntry.deviceId = targetId; this.transientEntry.deviceName = this.controlTargetDeviceName.trim() || targetId; - this.transientEntry.online = connected; + this.transientEntry.online = this.isConnected(); this.transientEntry.status = 'ready'; this.transientEntry.workspaces = this.recentWorkspaces; this.transientEntry.sessions = this.sessions; } - private toggleEntry(entry: DeviceDirectoryEntry): void { - if (this.isDirectoryEntry(entry)) { - this.onToggleDevice(entry.deviceId); - return; - } - this.transientEntry.expanded = !this.transientEntry.expanded; - } - private isDirectoryEntry(entry: DeviceDirectoryEntry): boolean { return this.directoryState.find(entry.deviceId) === entry; } @@ -229,10 +358,8 @@ export struct SidebarWorkspaceSection { if (this.isActiveDevice(entry.deviceId)) { return this.selectedSessionId; } - if (entry.sessions.some((item: RemoteSession): boolean => item.id === this.selectedSessionId)) { - return this.selectedSessionId; - } - return ''; + return entry.sessions.some((item: RemoteSession): boolean => item.id === this.selectedSessionId) ? + this.selectedSessionId : ''; } private isActiveDevice(deviceId: string): boolean { @@ -243,6 +370,10 @@ export struct SidebarWorkspaceSection { return deviceId === activeId; } + private canUseDevice(entry: DeviceDirectoryEntry): boolean { + return entry.online || (this.isActiveDevice(entry.deviceId) && this.isConnected()); + } + private isConnected(): boolean { return this.connectionState === 'connected' || this.connectionState === 'reconnecting'; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index d5b08ae046..e660805520 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -158,6 +158,7 @@ export struct WideConversationHost { showCollapseButton: true, showViewSettingsButton: false, showWorkspaceSection: true, + showConversationSection: false, contentSlot: () => { this.WorkspaceMasterContent() }, @@ -206,6 +207,7 @@ export struct WideConversationHost { selectedSessionId: this.wideSelectedSessionId(), runningSessionId: this.remotePageState.hasRunningActiveTurn() ? this.remotePageState.activeSession.sessionId : '', + workspacePickerPlacement: this.sessionDetailsPlacement, onOpenSession: this.actions.onRemoteHome.openSessionInPlace, onCreateInWorkspace: (deviceId: string, path: string) => { this.actions.onRemoteHome.createInWorkspaceInPlace(path, 'code', deviceId); @@ -213,9 +215,9 @@ export struct WideConversationHost { onOpenWorkspace: (deviceId: string, path: string) => { this.actions.onRemoteHome.selectWorkspace(path, deviceId); }, - onToggleDevice: this.actions.onRemoteHome.toggleDirectoryDevice, + onSelectDevice: this.actions.onRemoteHome.selectDirectoryDevice, onRetryDevice: this.actions.onRemoteHome.retryDirectoryDevice, - onAdd: this.actions.onSidebar.addDesktop + onAddWorkspace: this.actions.onRemoteHome.addDirectoryWorkspace }) } 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 1392f9255d..f95e0521a5 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 @@ -233,6 +233,42 @@ export class AppRootRuntime extends AppRootRuntimeComposition { await this.selectWorkspace(path); } + async selectDirectoryDevice(deviceId: string): Promise { + await this.deviceDirectoryViewModel.selectDevice(deviceId); + const entry = this.deviceDirectoryState.find(deviceId); + if (entry && !entry.online && !this.isConnectedDirectoryTarget(deviceId)) { + return; + } + try { + await this.conversationController.ensureRemoteControlTarget(deviceId); + this.filePreviewController.close(); + if (this.appShellState.wideLayout) { + await this.enterRemoteSurfaceWide(); + } else { + await this.enterRemoteSurfaceCompact(); + } + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + } catch (err) { + RemoteLogger.warn(`device directory switch failed device=${deviceId}: ${String(err)}`); + } + } + + async openWorkspacePickerOnDevice(deviceId: string): Promise { + await this.deviceDirectoryViewModel.selectDevice(deviceId); + const entry = this.deviceDirectoryState.find(deviceId); + if (entry && !entry.online && !this.isConnectedDirectoryTarget(deviceId)) { + return; + } + if (this.isConnectedDirectoryTarget(deviceId)) { + await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + } + } + + private isConnectedDirectoryTarget(deviceId: string): boolean { + return deviceId === this.remotePageState.controlTargetDeviceId && + RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)); + } + async selectAssistant(path: string): Promise { this.filePreviewController.close(); await this.remoteWorkspaceViewModel.selectAssistant(path); 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 9bcfcc93da..67148ce3a0 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 @@ -134,11 +134,13 @@ export abstract class AppRootRuntimeComposition { abstract openAddConnectionFromSettings(): void; abstract openAppSidebar(): void; abstract openRemoteControlSettings(): void; + abstract openWorkspacePickerOnDevice(deviceId: string): Promise; abstract pickImages(): Promise; abstract publishRemoteSessions(sessions: RemoteSession[], hasMore: boolean): void; abstract reconcileCreatedRemoteSession(session: SessionSummary): Promise; abstract reconnect(): Promise; abstract reconnectActiveRemote(): Promise; + abstract selectDirectoryDevice(deviceId: string): Promise; abstract selectAssistant(path: string): Promise; abstract selectWorkspace(path: string): Promise; abstract selectWorkspaceOnDevice(path: string, deviceId?: string): Promise; @@ -867,8 +869,11 @@ export abstract class AppRootRuntimeComposition { createInWorkspaceInPlace: (path: string, agentType: string, deviceId?: string): void => { this.conversationController.createRemoteSessionInWorkspace(path, agentType, true, deviceId); }, - toggleDirectoryDevice: (deviceId: string): void => { - void this.deviceDirectoryViewModel.toggleDevice(deviceId); + selectDirectoryDevice: (deviceId: string): void => { + void this.selectDirectoryDevice(deviceId); + }, + addDirectoryWorkspace: (deviceId: string): void => { + void this.openWorkspacePickerOnDevice(deviceId); }, retryDirectoryDevice: (deviceId: string): void => { void this.deviceDirectoryViewModel.retryDevice(deviceId); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets index fd0c80afaf..3c37d12fa5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets @@ -23,6 +23,8 @@ export class DeviceDirectoryEntry { @ObservedV2 export class DeviceDirectoryState { @Trace devices: DeviceDirectoryEntry[] = []; + /** Device whose workspaces are projected below the flat device list. */ + @Trace selectedDeviceId: string = ''; replace(devices: DeviceDirectoryEntry[]): void { this.devices = devices; @@ -30,6 +32,11 @@ export class DeviceDirectoryState { clear(): void { this.devices = []; + this.selectedDeviceId = ''; + } + + select(deviceId: string): void { + this.selectedDeviceId = deviceId.trim(); } find(deviceId: string): DeviceDirectoryEntry | undefined { 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 a70365b381..b1b3c5a84d 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 @@ -22,7 +22,7 @@ export interface DeviceDirectoryViewModelHooks { readonly activeDeviceConnected: () => boolean; } -/** Expand / collapse / retry for the sidebar's per-device directory. */ +/** Selection, loading, and retry for the sidebar's device directory. */ export class DeviceDirectoryViewModel { private readonly state: DeviceDirectoryState; private readonly store?: RemoteSessionListStore; @@ -129,6 +129,14 @@ export class DeviceDirectoryViewModel { return created; }); this.state.replace(next); + if (this.state.find(this.state.selectedDeviceId)) { + return; + } + if (this.state.find(activeId)) { + this.state.select(activeId); + return; + } + this.state.select(activeId.length === 0 && next.length > 0 ? next[0].deviceId : ''); } async hydrateKnownDevices(): Promise { @@ -138,16 +146,13 @@ export class DeviceDirectoryViewModel { } } - async toggleDevice(deviceId: string): Promise { + async selectDevice(deviceId: string): Promise { const entry = this.state.find(deviceId); if (!entry) { return; } - entry.expanded = !entry.expanded; - RemoteLogger.info(`device directory toggle device=${deviceId} expanded=${entry.expanded}`); - if (!entry.expanded) { - return; - } + this.state.select(deviceId); + RemoteLogger.info(`device directory select device=${deviceId}`); if (this.shouldFetch(entry)) { entry.status = 'loading'; entry.errorText = ''; @@ -160,13 +165,13 @@ export class DeviceDirectoryViewModel { if (!entry) { return; } - entry.expanded = true; + this.state.select(deviceId); await this.loadDevice(deviceId); } async ensureLoaded(deviceId: string): Promise { const entry = this.state.find(deviceId); - if (!entry || !entry.expanded) { + if (!entry) { return; } if (deviceId === this.hooks.activeDeviceId()) { diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets index 3ff04d1777..5bbaaa3d04 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -373,11 +373,11 @@ export default function conversationPresentationUnitTest() { expect(RemoteI18n.t('sidebar.recent')).assertEqual('sidebar.recent'); }); - it('labels the workspace-header plus by the connection it adds, not a workspace it cannot create', 0, () => { - // Workspaces come from the desktop; the phone has no create-workspace - // call. The button opens the connect sheet, so that is what it says. - expect(RemoteI18n.t('sidebar.addConnection')).assertEqual('添加连接'); - expect(RemoteI18n.t('sidebar.addWorkspace')).assertEqual('sidebar.addWorkspace'); + it('labels the workspace-header plus as adding an existing desktop workspace', 0, () => { + // The phone still does not create a remote directory. The selected + // device owns the picker, and choosing one of its directories adds that + // existing workspace to the visible tree. + expect(RemoteI18n.t('sidebar.addWorkspace')).assertEqual('添加工作区'); }); it('hides the redundant scan action after a QR connection succeeds', 0, () => { 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 d3b034ed71..9f221b7b4e 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets @@ -187,13 +187,13 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-active', deviceName: 'Active', online: true }, { deviceId: 'desk-offline', deviceName: 'Offline', online: false } ]); - await viewModel.toggleDevice('desk-offline'); + await viewModel.selectDevice('desk-offline'); expect(credentialCalls).assertEqual(0); - expect(state.find('desk-offline')?.expanded).assertTrue(); + expect(state.selectedDeviceId).assertEqual('desk-offline'); expect(state.find('desk-offline')?.status).assertEqual('idle'); }); - it('does not expand an offline device even when it is the control target', 0, () => { + it('selects an offline control target without fetching it', 0, async () => { const state = new DeviceDirectoryState(); const viewModel = new DeviceDirectoryViewModel(state, { credentials: () => undefined, @@ -203,11 +203,13 @@ export default function deviceDirectoryUnitTest() { viewModel.syncDevices([ { deviceId: 'desk-offline', deviceName: 'Studio', online: false } ]); + await viewModel.selectDevice('desk-offline'); expect(state.find('desk-offline')?.deviceName).assertEqual('Studio'); - expect(state.find('desk-offline')?.expanded).assertFalse(); + expect(state.selectedDeviceId).assertEqual('desk-offline'); + expect(state.find('desk-offline')?.status).assertEqual('idle'); }); - it('collapses a device when it goes offline and keeps the last known name', 0, () => { + it('keeps the selected device and its last known name when it goes offline', 0, () => { const state = new DeviceDirectoryState(); const viewModel = new DeviceDirectoryViewModel(state, { credentials: () => undefined, @@ -217,12 +219,12 @@ export default function deviceDirectoryUnitTest() { viewModel.syncDevices([ { deviceId: 'desk-studio', deviceName: 'Studio', online: true } ]); - expect(state.find('desk-studio')?.expanded).assertTrue(); + expect(state.selectedDeviceId).assertEqual('desk-studio'); viewModel.syncDevices([ { deviceId: 'desk-studio', deviceName: '', online: false } ]); expect(state.find('desk-studio')?.deviceName).assertEqual('Studio'); - expect(state.find('desk-studio')?.expanded).assertFalse(); + expect(state.selectedDeviceId).assertEqual('desk-studio'); }); it('keeps the live conversation device online when the relay says otherwise', 0, () => { @@ -240,7 +242,7 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-other', deviceName: 'Other', online: false } ]); expect(state.find('desk-live')?.online).assertTrue(); - expect(state.find('desk-live')?.expanded).assertTrue(); + expect(state.selectedDeviceId).assertEqual('desk-live'); // The override must not outlive the link it is based on, or a device that // really did go away keeps a lit row forever. expect(state.find('desk-other')?.online).assertFalse(); @@ -249,10 +251,10 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-live', deviceName: 'Studio', online: false } ]); expect(state.find('desk-live')?.online).assertFalse(); - expect(state.find('desk-live')?.expanded).assertFalse(); + expect(state.selectedDeviceId).assertEqual('desk-live'); }); - it('keeps a collapsed active device collapsed across syncDevices', 0, async () => { + it('keeps an explicit device selection across syncDevices', 0, async () => { const state = new DeviceDirectoryState(); const viewModel = new DeviceDirectoryViewModel(state, { credentials: () => undefined, @@ -263,19 +265,19 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-active', deviceName: 'Active', online: true }, { deviceId: 'desk-other', deviceName: 'Other', online: true } ]); - expect(state.find('desk-active')?.expanded).assertTrue(); - await viewModel.toggleDevice('desk-active'); - expect(state.find('desk-active')?.expanded).assertFalse(); + expect(state.selectedDeviceId).assertEqual('desk-active'); + await viewModel.selectDevice('desk-other'); + expect(state.selectedDeviceId).assertEqual('desk-other'); viewModel.syncDevices([ { deviceId: 'desk-active', deviceName: 'Active', online: true }, { deviceId: 'desk-other', deviceName: 'Other', online: true } ]); - expect(state.find('desk-active')?.expanded).assertFalse(); + expect(state.selectedDeviceId).assertEqual('desk-other'); viewModel.refreshActive(); - expect(state.find('desk-active')?.expanded).assertFalse(); + expect(state.selectedDeviceId).assertEqual('desk-other'); }); - it('toggles expand on the same directory entry instance', 0, async () => { + it('selects a directory row without replacing its entry instance', 0, async () => { const state = new DeviceDirectoryState(); const viewModel = new DeviceDirectoryViewModel(state, { credentials: () => undefined, @@ -287,12 +289,9 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-other', deviceName: 'Other', online: false } ]); const before = state.find('desk-other'); - await viewModel.toggleDevice('desk-other'); - expect(state.find('desk-other') === before).assertTrue(); - expect(state.find('desk-other')?.expanded).assertTrue(); - await viewModel.toggleDevice('desk-other'); + await viewModel.selectDevice('desk-other'); expect(state.find('desk-other') === before).assertTrue(); - expect(state.find('desk-other')?.expanded).assertFalse(); + expect(state.selectedDeviceId).assertEqual('desk-other'); }); it('marks an online device loading before its catalog returns', 0, async () => { @@ -309,8 +308,8 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-active', deviceName: 'Active', online: true }, { deviceId: 'desk-other', deviceName: 'Other', online: true } ]); - const pending = viewModel.toggleDevice('desk-other'); - expect(state.find('desk-other')?.expanded).assertTrue(); + const pending = viewModel.selectDevice('desk-other'); + expect(state.selectedDeviceId).assertEqual('desk-other'); expect(state.find('desk-other')?.status).assertEqual('loading'); expect(state.find('desk-other')?.workspaces.length).assertEqual(0); source.releaseWorkspaces(); @@ -335,7 +334,7 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-active', deviceName: 'Active', online: true }, { deviceId: 'desk-other', deviceName: 'Other', online: true } ]); - const pending = viewModel.toggleDevice('desk-other'); + const pending = viewModel.selectDevice('desk-other'); source.releaseWorkspaces(); await source.workspacesListed; expect(state.find('desk-other')?.status).assertEqual('loading'); @@ -363,13 +362,13 @@ export default function deviceDirectoryUnitTest() { { deviceId: 'desk-active', deviceName: 'Active', online: true }, { deviceId: 'desk-other', deviceName: 'Other', online: true } ]); - await viewModel.toggleDevice('desk-other'); + await viewModel.selectDevice('desk-other'); expect(state.find('desk-other')?.status).assertEqual('ready'); expect(store.lists.get('desk-other')?.sessions.length).assertEqual(1); expect(store.lastDeviceKey).assertEqual('desk-active'); }); - it('treats a hydrated catalog as cached and still fetches on expand', 0, async () => { + it('treats a hydrated catalog as cached and still fetches on selection', 0, async () => { const store = new FakeRemoteSessionListStore(); await store.saveWorkspaces('desk-other', [workspace('/cached', 'Cached')]); await store.saveObservedList('desk-other', [sessionOn('desk-other', 'cached', 'Cached')], false); @@ -390,7 +389,7 @@ export default function deviceDirectoryUnitTest() { expect(state.find('desk-other')?.status).assertEqual('cached'); expect(state.find('desk-other')?.workspaces[0].path).assertEqual('/cached'); expect(source.listedDeviceIds.length).assertEqual(0); - await viewModel.toggleDevice('desk-other'); + await viewModel.selectDevice('desk-other'); expect(source.listedDeviceIds.length).assertEqual(1); expect(source.listedDeviceIds[0]).assertEqual('desk-other'); expect(state.find('desk-other')?.status).assertEqual('ready'); @@ -423,8 +422,8 @@ export default function deviceDirectoryUnitTest() { expect(state.find('desk-a')?.status).assertEqual('ready'); expect(state.find('desk-a')?.sessions[0].id).assertEqual('a-1'); expect(state.find('desk-a')?.workspaces[0].path).assertEqual('/a'); - await viewModel.toggleDevice('desk-a'); - await viewModel.toggleDevice('desk-a'); + await viewModel.selectDevice('desk-a'); + expect(state.selectedDeviceId).assertEqual('desk-a'); expect(source.listedDeviceIds.length).assertEqual(0); expect(state.find('desk-a')?.sessions[0].id).assertEqual('a-1'); });