diff --git a/scripts/check-harmonyos-architecture.mjs b/scripts/check-harmonyos-architecture.mjs index 46eed36b8..a6be6621b 100644 --- a/scripts/check-harmonyos-architecture.mjs +++ b/scripts/check-harmonyos-architecture.mjs @@ -44,6 +44,7 @@ const allPages = filesUnder(pagesRoot); const services = filesUnder(path.join(etsRoot, 'services')); const components = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}components${path.sep}`)); const viewmodels = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}viewmodel${path.sep}`)); +const stateFiles = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}state${path.sep}`)); const serviceToPages = services .filter((file) => imports(file).some((spec) => spec === 'pages' || spec.startsWith('pages/'))) @@ -54,6 +55,9 @@ const componentToViewmodel = components const viewmodelToComponents = viewmodels .filter((file) => imports(file).some((spec) => spec === 'pages/components' || spec.startsWith('pages/components/'))) .map(relative); +const stateToComponents = stateFiles + .filter((file) => imports(file).some((spec) => spec === 'pages/components' || spec.startsWith('pages/components/'))) + .map(relative); const v1Components = allPages .filter((file) => /^\s*@Component\s*$/m.test(fs.readFileSync(file, 'utf8'))) .map(relative); @@ -98,10 +102,16 @@ const requiredPresentationFiles = [ 'components/AppRootOverlaySurfaces.ets', 'components/ChatMessageChrome.ets', 'components/ConnectManualPairingOverlay.ets', + 'components/ConversationHeader.ets', 'components/ConversationRouteSurface.ets', 'components/ToolInteractionPanels.ets', 'components/WideConversationHost.ets', - 'components/remote/RemoteSurfaceHost.ets' + 'components/remote/RemoteSurfaceHost.ets', + 'policy/ConversationHeaderPolicy.ets', + 'viewmodel/ConversationRuntime.ets', + 'viewmodel/RemoteTranscriptController.ets', + 'viewmodel/RemoteCreateFlowController.ets', + 'viewmodel/VisibleConversationController.ets' ]; const missingPresentationFiles = requiredPresentationFiles .filter((file) => !fs.existsSync(path.join(pagesRoot, file))); @@ -268,6 +278,7 @@ const expected = { serviceToPages: [], componentToViewmodel: [], viewmodelToComponents: [], + stateToComponents: [], v1Components: [], positionalActionConstructors: [], duplicatedConversationTraceFields: [], @@ -292,6 +303,7 @@ const actual = { serviceToPages, componentToViewmodel, viewmodelToComponents, + stateToComponents, v1Components, positionalActionConstructors, duplicatedConversationTraceFields, @@ -324,6 +336,12 @@ if (appRootPresentationLines > 500) { failed = true; console.error(`AppRootPresentation line budget exceeded: expected <=500, actual=${appRootPresentationLines}`); } +const conversationControllerFile = path.join(pagesRoot, 'viewmodel/ConversationController.ets'); +const conversationControllerLines = fs.readFileSync(conversationControllerFile, 'utf8').split(/\r?\n/).length - 1; +if (conversationControllerLines > 400) { + failed = true; + console.error(`ConversationController line budget exceeded: expected <=400, actual=${conversationControllerLines}`); +} for (const [file, budget] of componentLineBudgets) { const source = fs.readFileSync(path.join(pagesRoot, file), 'utf8'); const lineCount = source.split(/\r?\n/).length - 1; diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index 15e9ccc1a..39103495f 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -13,22 +13,26 @@ the module. Keep the official responsibilities explicit: intents/events rather than calling services directly. - ViewModels bridge services and views by owning feature state, projecting data, and handling intents. ViewModels must not import components. +- Shared conversation presentation DTOs (`ChatSurface`, `ChatComposerCapabilities`, + `ConversationUiModels`) live in `pages/state/`. State must not import + `pages/components`. The following constraints are enforced incrementally by `pnpm run harmony:architecture` (the runtime behavior checks remain in `entry/src/test/ArchitectureUnit.test.ets`): 1. `services/**` must not import `../pages/`. -2. `pages/components/**` must not import `pages/viewmodel/`; imports of +2. `pages/state/**` must not import `pages/components/`. +3. `pages/components/**` must not import `pages/viewmodel/`; imports of `pages/state/` and `pages/policy/` are allowed for observable state and pure policies. -3. The page dependency graph must remain acyclic; ViewModels must not depend on +4. The page dependency graph must remain acyclic; ViewModels must not depend on components. -4. Actions and Hooks use typed interfaces with object literals. Do not add +5. Actions and Hooks use typed interfaces with object literals. Do not add position-dependent callback constructors. -5. New components use `@ComponentV2`; do not add V1 `@Component`, `@State`, +6. New components use `@ComponentV2`; do not add V1 `@Component`, `@State`, `@Prop`, `@Link`, or `@Watch` declarations. `@BuilderParam` remains supported. -6. General Chat and Remote Chat shared observable fields belong to +7. General Chat and Remote Chat shared observable fields belong to `pages/state/ConversationCoreState.ets`. Page-specific state objects compose that core and must not redeclare the shared `@Trace` fields. 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 a2296504c..3ee7e6102 100644 --- a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md +++ b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md @@ -10,6 +10,7 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 ### 已实现 +- 展开态只要存在可用纵向折痕(双折叠一道、三折叠两道),且当前视口放得下 master + detail,就进入 master-detail;合盖仍强制单屏。无折痕时只有平板走宽屏,普通手机即使超宽也保持单屏。 - 宽屏本地与远程左栏在相同位置复用固定的“本地 / Remote”分段选择器,选中态由当前路由推导。 - 来源选择器使用独立来源切换动作,不复用单屏 `enterCodeEntry()`;具体会话行只选择当前来源内的会话。 - 宽屏来源切换使用 `clear(false)` 与 `pushPath(..., false)` 无转场替换路由,不播放页面进入或退出动画。 @@ -17,6 +18,7 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 - 本地与远程来源切换使用路由合同计算目标,不累积跨来源返回路径。 - 切回本地时停止手机侧远程轮询,但不清空远程活动会话或断开桌面。 - 通过 `display.getCurrentFoldCreaseRegion()` 读取折痕;第一道有效纵向折痕用于 master/detail 分界,无有效折痕时回退到 `344`。 +- 双折叠展开打开文件预览时,会话/预览分界对齐那一道折痕,而不是按视口对半切。 - 两道折痕或超宽宽度被识别为 extra-wide,但继续使用同一个 master-detail,不创建第三栏。 - 第二道折痕会把 detail 划分为无折痕候选内容带;会话视图选择最宽的候选带,同宽时优先右侧,避免 Composer、菜单和消息内操作热区跨越折痕。 - extra-wide detail 外层仍覆盖中间和右侧两屏;上述内容带只是 presentation 几何约束,不增加第三个业务 pane。 @@ -76,7 +78,7 @@ HarmonyOS 手机在展开双屏、完整展开三屏或其他宽屏形态下, - 不改变远程连接、配对、鉴权或断线重连协议。 - 不因为切换会话来源而停止桌面端正在执行的任务。 - 第一阶段不调整 `RemoteCreate` 的页面结构。 -- 不改变当前宽屏阈值和折叠状态判定规则。 +- 宽度阈值(720 / 1080)和合盖强制单屏保持不变;展开后是否进入宽屏由纵向折痕与设备类型共同决定。 - 不因为三屏增加一个独立的第三栏、第二套会话列表或新的业务路由模式。 ## 当前逻辑 @@ -86,13 +88,14 @@ HarmonyOS 手机在展开双屏、完整展开三屏或其他宽屏形态下, `AppRootPresentation.isWideLayout()` 当前遵循以下逻辑: ```text -设备处于折叠状态 -> 单屏 -设备非折叠,且 media query 命中宽屏 -> 宽屏 -设备非折叠,且实际宽度达到阈值 -> 宽屏 -其他 -> 单屏 +设备处于折叠状态 -> 单屏 +设备非折叠,宽视口,有可见纵向折痕,且放得下两栏 -> 宽屏(双折/三折展开) +设备非折叠,宽视口,折叠状态为展开/半折,即使折痕缺失 -> 宽屏(双折展开兜底) +设备非折叠,宽视口,无折痕,且设备类型为平板 -> 宽屏 +其他 -> 单屏 ``` -折叠状态优先于宽度。因此即使报告的可用宽度较大,只要设备处于折叠状态,也必须继续使用单屏布局。 +折叠状态优先于宽度和折痕。因此即使报告的可用宽度较大,只要设备处于折叠状态,也必须继续使用单屏布局。双折叠展开的一道折痕与三折叠的两道折痕使用同一套 master-detail,不新增第三会话栏。 ### 路由分流 diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets index 7085e03a0..987c97abd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets @@ -23,6 +23,7 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['chatHome.prompt', '今天想做什么?'], ['chatHome.placeholder', '问问 BitFun'], + ['chatHome.localHint', '这是手机上的对话。要读写电脑上的项目,请切换到远程。'], ['chatHome.research', '查找资料'], ['chatHome.researchPrompt', '帮我查找资料并整理重点:'], ['chatHome.image', '分析图片'], @@ -35,12 +36,9 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['chatHome.organizePrompt', '请帮我梳理这个问题的重点和思路:'], ['chatHome.plan', '制定行动计划'], ['chatHome.planPrompt', '请帮我制定一份清晰、可执行的行动计划:'], - ['chatHome.codeHintTitle', '这个任务可能需要 Code 能力'], - ['chatHome.codeHintBody', '连接本地工作区后,BitFun 才能读取项目文件、运行命令并修改代码。'], - ['chatHome.enterCode', '进入 Code'], - ['chatHome.generalAdvice', '仅给出通用建议'], - ['chatHome.generalPending', '普通聊天服务即将接入。需要处理本地项目时,请从侧栏进入 Code。'], - ['chatHome.generalAdvicePending', '普通代码建议服务即将接入。需要读取项目或运行命令时,请进入 Code。'], + ['chatHome.codeHintTitle', '这个任务可能需要远程能力'], + ['chatHome.codeHintBody', '连接桌面工作区后,BitFun 才能读取项目文件、运行命令并修改代码。'], + ['chatHome.enterCode', '进入远程'], ['generalChat.ready', 'BitFun 已就绪'], ['generalChat.generating', 'BitFun 正在回复'], @@ -49,11 +47,10 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['generalChat.stopped', '已停止生成。'], ['generalChat.interrupted', '回复已中断,已保留收到的内容。'], ['generalChat.replyInterrupted', '回复中断'], - ['generalChat.fileDownloadMock', '普通聊天暂不支持下载桌面端文件,请进入 Code 后处理本地文件。'], - ['generalChat.filePreviewUnavailable', '普通聊天暂不支持预览桌面端文件,请进入 Code 后打开。'], + ['generalChat.fileDownloadMock', '普通聊天暂不支持下载桌面端文件,请进入远程后处理本地文件。'], + ['generalChat.filePreviewUnavailable', '普通聊天暂不支持预览桌面端文件,请进入远程后打开。'], ['generalChat.localRestoreFailed', '普通对话历史暂时无法恢复,你仍可新建对话。'], ['generalChat.modelNotConfigured', '请先在设置中配置普通对话模型。'], - ['generalChat.imageNotSupported', '当前模型通道暂不支持图片,请先发送文字消息。'], ['generalChat.authenticationFailed', 'API Key 无效或无权访问该模型。'], ['generalChat.rateLimited', '模型服务请求过于频繁,请稍后重试。'], ['generalChat.serviceUnavailable', '模型服务暂时不可用,请稍后重试。'], @@ -102,12 +99,13 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['sidebar.projects', '项目'], ['sidebar.tasks', '已计划'], ['sidebar.tools', '应用'], - ['sidebar.code', 'Remote'], + ['sidebar.code', '远程'], ['sidebar.more', '更多'], ['sidebar.recent', '最近'], ['sidebar.newChat', '聊天'], ['sidebar.generalChat', '普通对话'], ['sidebar.local', '本地'], + ['sidebar.pinned', '置顶'], ['sidebar.archived', '已归档'], ['sidebar.archive', '归档'], ['sidebar.unarchive', '取消归档'], @@ -146,18 +144,18 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['viewSettings.status', '运行状态'], ['common.unknown', '未知'], - ['code.title', 'BitFun Code'], + ['code.title', 'BitFun 远程'], ['code.heroTitle', '本地开发助手'], ['code.heroBody', '连接桌面端后,BitFun 可以访问你的代码仓库、运行命令和修改文件。'], ['code.connectWorkspace', '连接本地工作区'], ['code.connectDesc', '扫码或粘贴连接链接'], ['code.noWorkspacePreview', '连接桌面端后会显示最近打开的代码仓库。'], - ['code.newTask', '新建 Code 任务'], + ['code.newTask', '新建远程任务'], ['code.switchWorkspace', '切换工作区'], ['code.disconnect', '断开连接'], - ['code.recentSessions', '最近 Code 会话'], - ['code.emptySessionTitle', '暂无 Code 会话'], - ['code.emptySessionText', '连接本地工作区后,可以新建 Code 任务处理项目问题。'], + ['code.recentSessions', '最近远程会话'], + ['code.emptySessionTitle', '暂无远程会话'], + ['code.emptySessionText', '连接本地工作区后,可以新建远程任务处理项目问题。'], ['remote.title', '远程'], ['remote.chats', '聊天'], @@ -275,7 +273,7 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['connect.scanPairCode', '扫描二维码以配对'], ['connect.scanPairCodeAction', '扫描二维码连接'], ['connect.accountDevicesTitle', '选择桌面设备'], - ['connect.accountDevicesSubtitle', 'Remote'], + ['connect.accountDevicesSubtitle', '远程'], ['connect.accountDevicesBody', '选择一台在线桌面继续工作。'], ['connect.availableDevices', '账号设备'], ['connect.deviceLastUsed', '上次连接'], @@ -370,7 +368,7 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['home.emptyAssistantTitle', '暂无助理会话'], ['home.emptyAssistantText', '桌面端开放创建后可在这里开始。'], ['home.emptyTitle', '暂无会话'], - ['home.emptyText', '用 Code 处理编码任务,或用 Cowork 处理日常办公。'], + ['home.emptyText', '用远程处理编码任务,或用普通对话处理日常问题。'], ['sheet.createTitle', '创建 {0} 会话'], ['sheet.workspace', '工作区'], @@ -382,9 +380,18 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['sheet.creating', '创建中...'], ['sheet.start', '开始'], - ['chat.remoteSession', 'Remote Session'], + ['chat.remoteSession', '远程会话'], ['chat.sessionTitle', '会话标题'], + ['chat.sessionSection', '会话'], + ['chat.pin', '置顶'], + ['chat.unpin', '取消置顶'], + ['chat.uploadedFiles', '已上传的文件'], ['chat.stop', '停止'], + ['chat.send', '发送'], + ['chat.voiceInput', '语音输入'], + ['chat.stopListening', '停止听写'], + ['chat.addImage', '添加图片'], + ['chat.removeImage', '移除图片'], ['chat.selectModel', '选择模型'], ['chat.loadOlder', '加载更早消息'], ['chat.inputPlaceholder', '向 BitFun 提问'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index cef4f8673..3a99c59a0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -374,6 +374,7 @@ export interface ChatMessageItemResponse { export interface ImageAttachment { name: string; data_url: string; + mime_type?: string; } export interface SelectedImageAttachment extends ImageAttachment { 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 8b7cea8c3..1ee798cff 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 @@ -1,5 +1,6 @@ import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { DetectedUrlAction } from '../../services/ConnectScanDecisionPolicy'; import { ConversationIntent } from './ConversationIntent'; import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; @@ -88,6 +89,7 @@ export interface SettingsPresentationActions { readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; readonly cloudLogout: () => Promise; readonly cloudListDevices: () => Promise; + readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; readonly getPermissionMode: () => Promise; readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; readonly testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; @@ -100,7 +102,7 @@ export interface ConnectPresentationActions { readonly clearPairing: () => void; readonly urlChanged: (url: string) => void; readonly userChanged: (user: string) => void; - readonly detected: (url: string) => boolean; + readonly detected: (url: string) => string; readonly inputVisible: (visible: boolean) => void; readonly paste: () => void; readonly scan: () => void; @@ -136,13 +138,14 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions { onSettings: { close: () => {}, addConnection: () => {}, disconnect: () => {}, reconnect: () => {}, openAccount: () => {}, cloudLogin: async () => '', cloudLogout: async () => {}, - cloudListDevices: async () => [], getPermissionMode: async () => 'ask', + cloudListDevices: async () => [], cloudSelectDevice: async () => {}, + getPermissionMode: async () => 'ask', setPermissionMode: async (mode: RemotePermissionMode) => mode, testGeneral: async () => '', saveGeneral: async () => '' }, onConnect: { back: () => {}, connect: () => {}, clearPairing: () => {}, urlChanged: () => {}, userChanged: () => {}, - detected: () => false, inputVisible: () => {}, paste: () => {}, scan: () => {}, + detected: () => DetectedUrlAction.INVALID, inputVisible: () => {}, paste: () => {}, scan: () => {}, cloudListDevices: async () => [], cloudSelectDevice: async () => {} }, onFilePreview: { close: () => {}, refresh: () => {}, download: () => {}, openLink: () => {} }, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets index 76dc0a66e..b0cf70479 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets @@ -1,4 +1,4 @@ -import { ConversationUiQuestionAnswer } from '../components/ConversationUiModels'; +import { ConversationUiQuestionAnswer } from '../state/ConversationUiModels'; import { FilePreviewRequest } from '../../model/FilePreviewTarget'; export enum ConversationIntentType { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index a07455141..9ce3a59e6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -1,7 +1,7 @@ import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteModels'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; import { ConversationIntent, ConversationIntentType } from './ConversationIntent'; -import { toRemoteQuestionAnswer } from '../components/ConversationUiModels'; +import { toRemoteQuestionAnswer } from '../state/ConversationUiModels'; import { FilePreviewRequest } from '../../model/FilePreviewTarget'; export interface ConversationIntentDispatcherHooks { 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 018255e63..18c71f525 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 @@ -122,6 +122,7 @@ export struct AppSettingsSurface { cloudLogin: this.actions.onSettings.cloudLogin, cloudLogout: this.actions.onSettings.cloudLogout, cloudListDevices: this.actions.onSettings.cloudListDevices, + cloudSelectDevice: this.actions.onSettings.cloudSelectDevice, getPermissionMode: this.actions.onSettings.getPermissionMode, setPermissionMode: this.actions.onSettings.setPermissionMode, openAccountOnAppear: this.shellState.settingsMode === 'account', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index 394d50871..08340952f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -320,7 +320,8 @@ export struct AppRootPresentation { this.wideLayoutMatched, this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED, this.deviceType, - verticalCreases + verticalCreases, + this.isExpandedFoldable() ); const geometry = ConversationLayoutPolicy.resolveWideGeometry( this.viewportWidth, @@ -338,13 +339,22 @@ export struct AppRootPresentation { } } + private isExpandedFoldable(): boolean { + return this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED || + this.foldStatus === display.FoldStatus.FOLD_STATUS_HALF_FOLDED; + } + private currentVerticalCreases(): ConversationLayoutCrease[] { if (this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED) { return []; } try { const region = display.getCurrentFoldCreaseRegion(); - return region.creaseRects + const creaseRects = region.creaseRects; + if (!creaseRects) { + return []; + } + return creaseRects .filter((rect: display.Rect): boolean => rect.height > rect.width) .map((rect: display.Rect): ConversationLayoutCrease => { return new ConversationLayoutCrease( 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 6e02ff01b..69f8d2b16 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 @@ -111,7 +111,7 @@ export struct AppSidebar { LocalSessionContent() { Column() { if (this.visiblePinnedSessions().length > 0) { - Text('置顶') + Text(RemoteI18n.t('sidebar.pinned')) .fontSize(14).fontWeight(FontWeight.Medium).fontColor(MUTED) .width('100%').margin({ top: 16, bottom: 6 }) ForEach(this.visiblePinnedSessions(), (session: RemoteSession) => { 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 c1a6917fd..627acca61 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,6 +1,7 @@ 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'; @ComponentV2 export struct BitFunAccountLoginPage { @@ -104,11 +105,11 @@ export struct BitFunAccountLoginPage { .scrollBar(BarState.Off) Button() { - SymbolGlyph($r('sys.symbol.chevron_left')) - .fontSize(23) - .fontColor([INK]) - .width(26) - .height(26) + TemplateIcon({ + src: $r('app.media.remote_ref_back'), + iconWidth: 15, + iconHeight: 23 + }) } .width(44) .height(44) @@ -116,6 +117,7 @@ export struct BitFunAccountLoginPage { .type(ButtonType.Circle) .backgroundColor('#00000000') .position({ x: 28, y: 22 }) + .accessibilityText(RemoteI18n.t('common.back')) .onClick(() => { this.onBack(); }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index 66628abaa..2f2a0cea0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -1,4 +1,4 @@ -import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; +import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from '../state/ConversationUiModels'; import { INK, LINE, MUTED, SOFT } from './Theme'; import { MessageFileCards, MessageImageGallery, MessageMarkdown } from './ChatMessageContent'; import { ChatMessageRetryAction, ChatTypingDots, ChatUserMessageBubble } from './ChatMessageChrome'; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets index c485c0add..98a3ee75b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets @@ -1,5 +1,5 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ConversationUiMessage } from './ConversationUiModels'; +import { ConversationUiMessage } from '../state/ConversationUiModels'; import { MessageImageGallery } from './ChatMessageContent'; import { ACCENT, INK, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets index 10df9e331..d21b3f692 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets @@ -1,4 +1,4 @@ -import { ConversationUiImage } from './ConversationUiModels'; +import { ConversationUiImage } from '../state/ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { FileTargetResolver } from '../../services/FileTargetResolver'; import { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets index aa21d5e3f..5eb5d96a6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets @@ -39,6 +39,7 @@ export struct ChatStatusBar { .textAlign(TextAlign.Center) .backgroundColor(SOFT) .borderRadius(17) + .accessibilityText(RemoteI18n.t('chat.stop')) .onClick(() => { this.onStop(); }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index e2dbc9f92..06f325649 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -1,7 +1,7 @@ -import { ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, toConversationUiMessage } from './ConversationUiModels'; +import { ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, toConversationUiMessage } from '../state/ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; -import { ChatSurface } from './ChatSurface'; +import { ChatSurface } from '../state/ChatSurface'; import { CARD, INK, LINE, MUTED, RED } from './Theme'; import { ChatMessageBubble } from './ChatMessageBubble'; import { RemoteLogger } from '../../services/RemoteLogger'; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 5e32670d5..39220d68d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -1,12 +1,11 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatComposerPolicy, ComposerPrimaryAction } from '../../services/ChatComposerPolicy'; -import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; -import { ChatSurface } from './ChatSurface'; +import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from '../state/ChatComposerCapabilities'; import { ConversationUiModel, ConversationUiModelCatalog, ConversationUiSelectedImage -} from './ConversationUiModels'; +} from '../state/ConversationUiModels'; import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; @@ -302,6 +301,7 @@ export struct ComposerBar { } .width(COMPOSER_ACTION_SIZE) .height(COMPOSER_ACTION_SIZE) + .accessibilityText(RemoteI18n.t('chat.addImage')) .onClick(() => { if (this.isVoiceListening) { return; @@ -390,6 +390,8 @@ export struct ComposerBar { .type(ButtonType.Circle) .backgroundColor(this.actionBackgroundColor()) .borderRadius(20) + .accessibilityText(RemoteI18n.t(ChatComposerPolicy.primaryActionAccessibilityKey( + this.primaryAction(), this.isVoiceListening))) // Driven off the same decision that drew the glyph, so the button can never // do something other than what it is showing. .onClick(() => { @@ -487,14 +489,15 @@ export struct ComposerBar { .objectFit(ImageFit.Cover) .borderRadius(14) Text('×') - .width(22) - .height(22) - .fontSize(14) + .width(32) + .height(32) + .fontSize(16) .fontColor(CARD) .textAlign(TextAlign.Center) .backgroundColor('#AA222222') - .borderRadius(11) - .margin({ top: 4, right: 4 }) + .borderRadius(16) + .margin({ top: 0, right: 0 }) + .accessibilityText(RemoteI18n.t('chat.removeImage')) .onClick(() => { this.onRemoveImage(image.id); }) @@ -525,8 +528,10 @@ export struct ComposerBar { } private shouldShowAddButton(): boolean { - return this.capabilities.showAddButton && - (this.capabilities.supportsAttachments || this.capabilities.surface === ChatSurface.General); + return ChatComposerPolicy.shouldShowAddButton( + this.capabilities.showAddButton, + this.capabilities.supportsAttachments + ); } // Attachments live inside the card, so the card has to grow to hold them. @@ -556,8 +561,7 @@ export struct ComposerBar { } private shouldShowModelControl(): boolean { - return (this.capabilities.surface === ChatSurface.Remote || this.capabilities.surface === ChatSurface.General) && - this.enabledModels().length > 0; + return this.enabledModels().length > 0; } private enabledModels(): ConversationUiModel[] { 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 e1e87a723..6f04ef650 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 @@ -1,6 +1,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'; @ComponentV2 export struct ConnectAccountDevicePage { @@ -17,7 +18,6 @@ export struct ConnectAccountDevicePage { @Local accountDevicesBusy: boolean = false; @Local accountDevicesError: string = ''; @Local switchingDeviceId: string = ''; - @Local otherConnectionMethodsExpanded: boolean = false; aboutToAppear(): void { this.refreshAccountDevices(); @@ -50,7 +50,7 @@ export struct ConnectAccountDevicePage { Text(RemoteI18n.t('connect.accountDevicesBody')) .fontSize(14).lineHeight(21).fontColor(MUTED).width('100%') this.AccountDeviceList() - this.OtherConnectionMethods() + this.ScanPairCodeAction() } .width('100%') .constraintSize({ minHeight: '100%' }) @@ -110,36 +110,18 @@ export struct ConnectAccountDevicePage { } @Builder - private OtherConnectionMethods() { - Column() { - Row({ space: 12 }) { - Text(RemoteI18n.t('connect.otherConnectionMethods')) - .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK) - Blank() - SymbolGlyph(this.otherConnectionMethodsExpanded ? - $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) - .fontSize(13).fontColor([MUTED]) - } - .width('100%').height(58).padding({ left: 16, right: 16 }) - .onClick(() => { - this.otherConnectionMethodsExpanded = !this.otherConnectionMethodsExpanded; - }) - - if (this.otherConnectionMethodsExpanded) { - Divider().color(LINE).margin({ left: 16, right: 16 }) - Row({ space: 12 }) { - SymbolGlyph($r('sys.symbol.link')) - .fontSize(20).fontColor([MUTED]).width(22).height(22).opacity(0.66) - Text(RemoteI18n.t('connect.scanPairCodeAction')) - .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK).layoutWeight(1) - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) - } - .width('100%').height(58).padding({ left: 16, right: 16 }) - .onClick(() => this.onOpenScanner()) - } + private ScanPairCodeAction() { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.link')) + .fontSize(20).fontColor([MUTED]).width(22).height(22).opacity(0.66) + Text(RemoteI18n.t('connect.scanPairCodeAction')) + .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK).layoutWeight(1) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) } - .width('100%').backgroundColor(CARD).borderRadius(8).border({ width: 1, color: LINE }) + .width('100%').height(58).padding({ left: 16, right: 16 }) + .backgroundColor(CARD).borderRadius(8).border({ width: 1, color: LINE }) + .onClick(() => this.onOpenScanner()) } @Builder @@ -170,7 +152,15 @@ export struct ConnectAccountDevicePage { } .layoutWeight(1) .alignItems(HorizontalAlign.Start) - if (device.online) { + if (this.canSelectAccountDevice(device) && AccountDeviceSelectionPolicy.shouldShowConnectAction( + device.online, device.deviceId, this.deviceId, this.controlTargetDeviceId, this.connectionState + )) { + Text(this.switchingDeviceId === device.deviceId ? + RemoteI18n.t('remote.settings.deviceConnecting') : RemoteI18n.t('connect.connect')) + .fontSize(14).fontColor(INK) + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(SOFT).borderRadius(14) + } else if (device.online) { SymbolGlyph($r('sys.symbol.chevron_right')) .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) } @@ -204,9 +194,6 @@ export struct ConnectAccountDevicePage { RemoteI18n.t('remote.settings.deviceLoadFailed'); } finally { this.accountDevicesBusy = false; - if (!this.hasOnlineDesktopDevice()) { - this.otherConnectionMethodsExpanded = true; - } } } @@ -216,15 +203,9 @@ export struct ConnectAccountDevicePage { } private canSelectAccountDevice(device: CloudAccountDevice): boolean { - return device.online && device.deviceId !== this.deviceId && this.switchingDeviceId.length === 0; - } - - private hasOnlineDesktopDevice(): boolean { - const devices = this.desktopDevices(); - for (let index = 0; index < devices.length; index += 1) { - if (devices[index].online) return true; - } - return false; + return AccountDeviceSelectionPolicy.canSelectOnline( + device.online, device.deviceId, this.deviceId, this.switchingDeviceId + ); } private accountDeviceStatus(device: CloudAccountDevice): string { 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 986cc21b8..bcfe1a13e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets @@ -4,9 +4,14 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ConnectAccountDevicePage } from './ConnectAccountDevicePage'; import { ConnectManualPairingOverlay } from './ConnectManualPairingOverlay'; -import { CONNECT_INTENT_AUTO } from '../state/AppShellState'; -import { ConnectOpenIntentPolicy } from '../policy/ConnectOpenIntentPolicy'; -import { ACCENT, CARD, CONNECT_HERO_ACCENT, CONNECT_HERO_BG, CONNECT_HERO_SECONDARY, +import { CONNECT_INTENT_AUTO, CONNECT_INTENT_SCAN } from '../state/AppShellState'; +import { + ConnectSheetLandingPolicy, + ConnectSheetStatusPolicy, + ConnectSheetStep +} from '../policy/ConnectSheetLandingPolicy'; +import { DetectedUrlAction } from '../../services/ConnectScanDecisionPolicy'; +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'; const CAMERA_PERMISSION: Permissions = 'ohos.permission.CAMERA'; @@ -35,7 +40,7 @@ export struct ConnectView { @Event onConnect: (password?: string) => void = (_password?: string) => {}; @Event onRemoteUrlChange: (value: string) => void = (_value: string) => {}; @Event onUserIdChange: (value: string) => void = (_value: string) => {}; - @Event onRemoteUrlDetected: (value: string) => boolean = (_value: string) => false; + @Event onRemoteUrlDetected: (value: string) => string = (_value: string) => DetectedUrlAction.INVALID; @Event onRemoteUrlInputVisibleChange: (visible: boolean) => void = (_visible: boolean) => {}; @Event cloudListDevices: () => Promise = async (): Promise => []; @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = @@ -47,12 +52,15 @@ export struct ConnectView { @Local cameraPermissionReady: boolean = false; @Local requestingCameraPermission: boolean = false; + /** + * SCAN intent always opens the camera. Every other automatic open lands on + * the account device list when signed in, and on the pair-code explainer + * when not — never on the camera by default. + */ aboutToAppear(): void { - this.pairingStep = ConnectOpenIntentPolicy.initialStep( + this.pairingStep = ConnectSheetLandingPolicy.initialStep( this.openIntent, - this.isAccountAuthenticated(), - this.remoteUrl, - this.pairingStep + this.isAccountAuthenticated() ); } @@ -264,10 +272,10 @@ export struct ConnectView { .borderRadius(24) .position({ x: 28, y: 18 }) .onClick(() => { - if (ConnectOpenIntentPolicy.backStaysInSheet( - this.openIntent, this.currentStep(), this.remoteUrl, this.showManualPairing)) { + if (this.openIntent !== CONNECT_INTENT_SCAN && + this.currentStep() === ConnectSheetStep.Scan && this.remoteUrl.trim().length === 0 && !this.showManualPairing) { this.stopInlineScan(); - this.pairingStep = this.isAccountAuthenticated() ? 'account' : 'intro'; + this.pairingStep = this.isAccountAuthenticated() ? ConnectSheetStep.Account : ConnectSheetStep.Intro; return; } this.stopInlineScan(); @@ -468,6 +476,10 @@ export struct ConnectView { } if (this.remoteUrl.trim().length > 0) { if (this.requiresAccountAuth && this.accountPassword.length === 0) { + if (this.isAccountAuthenticated()) { + this.pairingStep = ConnectSheetStep.Account; + return; + } this.showManualPairing = true; this.onRemoteUrlInputVisibleChange(true); return; @@ -479,7 +491,7 @@ export struct ConnectView { this.onRemoteUrlInputVisibleChange(true); this.stopInlineScan(); this.showManualPairing = true; - this.pairingStep = 'scan'; + this.pairingStep = ConnectSheetStep.Scan; } private canConnect(): boolean { @@ -499,19 +511,7 @@ export struct ConnectView { } private currentStep(): string { - if (this.pairingStep === 'account' && this.isAccountAuthenticated()) { - return 'account'; - } - if (this.pairingStep === 'scan' || - this.remoteUrl.trim().length > 0 || - this.isBusy || - this.isConnected || - this.connectionState === 'parsing' || - this.connectionState === 'pairing' || - this.connectionState === 'reconnecting') { - return 'scan'; - } - return 'intro'; + return ConnectSheetLandingPolicy.visibleStep(this.pairingStep, this.isAccountAuthenticated()); } private isAccountAuthenticated(): boolean { @@ -543,7 +543,7 @@ export struct ConnectView { private async startInlineScan(): Promise { if (this.scannerStarted || this.scanCompleted || - this.currentStep() !== 'scan' || + this.currentStep() !== ConnectSheetStep.Scan || this.showManualPairing) { return; } @@ -593,15 +593,26 @@ export struct ConnectView { } this.scanCompleted = true; this.stopInlineScan(); - const shouldPrompt = this.onRemoteUrlDetected(text); - if (shouldPrompt) { + const action = this.onRemoteUrlDetected(text); + if (action === DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD) { this.showManualPairing = true; this.onRemoteUrlInputVisibleChange(true); - this.pairingStep = 'scan'; + this.pairingStep = ConnectSheetStep.Scan; return; } - this.ensureUserId(); - this.onConnect(); + if (action === DetectedUrlAction.SHOW_CLOUD_DEVICES || + action === DetectedUrlAction.USE_CLOUD_DEVICE) { + this.pairingStep = ConnectSheetStep.Account; + return; + } + if (action === DetectedUrlAction.PAIR_NOW) { + this.ensureUserId(); + this.onConnect(); + return; + } + this.inlineScanError = this.statusText || RemoteI18n.t('connect.hintInvalidLink'); + this.scanCompleted = false; + this.resumeInlineScan(); }); } catch (_err) { this.scannerStarted = false; @@ -621,7 +632,7 @@ export struct ConnectView { } private resumeInlineScan(): void { - if (this.remoteUrl.trim().length > 0 || this.pairingStep !== 'scan') { + if (this.remoteUrl.trim().length > 0 || this.pairingStep !== ConnectSheetStep.Scan) { return; } this.scanCompleted = false; @@ -660,39 +671,23 @@ export struct ConnectView { this.scanStartRetryCount = 0; this.inlineScanError = ''; this.cameraPermissionReady = false; - this.pairingStep = 'scan'; + this.pairingStep = ConnectSheetStep.Scan; } private isConnectError(): boolean { - if (this.connectionState === 'failed') { - return true; - } - if (this.isBusy || this.isConnected) { - return false; - } - const text = this.statusText.toLowerCase(); - return text.indexOf('http') >= 0 || - text.indexOf('error') >= 0 || - text.indexOf('failed') >= 0 || - text.indexOf(RemoteI18n.t('common.failed')) >= 0 || - this.statusText === RemoteI18n.t('errors.remoteUrlMissingParams') || - this.statusText === RemoteI18n.t('errors.remoteUrlRequired') || - text.indexOf('rejected') >= 0; + return ConnectSheetStatusPolicy.isConnectError( + this.connectionState, + this.isBusy, + this.isConnected, + this.statusText, + RemoteI18n.t('common.failed'), + RemoteI18n.t('errors.remoteUrlMissingParams'), + RemoteI18n.t('errors.remoteUrlRequired') + ); } private failureHint(): string { - if (this.connectionFailureKind === 'expired_room') { - return RemoteI18n.t('connect.hintExpiredRoom'); - } - if (this.connectionFailureKind === 'network') { - return RemoteI18n.t('connect.hintNetwork'); - } - if (this.connectionFailureKind === 'protected_user') { - return RemoteI18n.t('connect.hintProtectedUser'); - } - if (this.connectionFailureKind === 'invalid_link') { - return RemoteI18n.t('connect.hintInvalidLink'); - } - return ''; + const key = ConnectSheetStatusPolicy.failureHintKey(this.connectionFailureKind); + return key.length > 0 ? RemoteI18n.t(key) : ''; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets similarity index 62% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets index 40c69d054..b3cba9367 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets @@ -1,28 +1,24 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ConversationUiSession } from './ConversationUiModels'; import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION_TEXT, SOFT } from './Theme'; import { CompactMenuButton } from './CompactMenuButton'; import { TemplateIcon } from './TemplateIcon'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 -export struct RemoteChatHeader { - @Param activeSession: ConversationUiSession = { - sessionId: '', - title: '', - workspacePath: '', - agentType: 'code' - }; - @Param workspaceBranch: string = ''; - @Param desktopName: string = ''; - @Param showBackButton: boolean = true; - @Param showSidebarButton: boolean = false; +export struct ConversationHeader { + @Param title: string = ''; + @Param subtitle: string = ''; + @Param titleFallback: string = ''; + @Param allowRename: boolean = false; + @Param showActions: boolean = false; + @Param showSidebarButton: boolean = true; + @Param showBackButton: boolean = false; @Param showSidebarRestoreButton: boolean = false; @Param showActionsMenu: boolean = false; @BuilderParam actionsMenu: () => void = this.EmptyBuilder; - @Event onBack: () => void = () => {}; @Event onOpenSidebar: () => void = () => {}; @Event onRestoreSidebar: () => void = () => {}; + @Event onBack: () => void = () => {}; @Event onOpenActions: () => void = () => {}; @Event onActionsMenuStateChange: (visible: boolean) => void = (_visible: boolean) => {}; @Event onRenameSession: (title: string) => void = (_title: string) => {}; @@ -41,42 +37,49 @@ export struct RemoteChatHeader { } @Builder - HeaderRow() { + private HeaderRow() { Row({ space: 8 }) { this.LeadingControl() - Column({ space: 3 }) { - Text(this.activeSession.title || RemoteI18n.t('chat.remoteSession')) - .fontSize(18) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .textAlign(TextAlign.Center) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .onClick(() => { - this.renameTitle = this.activeSession.title || ''; - this.showTitleEditor = true; - }) - Row({ space: 6 }) { - Text(this.headerContextTitle()) - .fontSize(14) - .fontColor(MUTED) - .textAlign(TextAlign.Center) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - .justifyContent(FlexAlign.Center) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Center) - this.ActionsControl() + this.TitleBlock() + this.TrailingControl() } .width('100%') + .height(this.hasSubtitle() ? 76 : 64) .alignItems(VerticalAlign.Center) - .padding({ left: 18, right: 18, top: 14, bottom: 16 }) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) .backgroundColor(PAGE_BG) } + @Builder + private TitleBlock() { + Column({ space: 3 }) { + Text(this.resolvedTitle()) + .fontSize(this.hasSubtitle() ? 18 : 17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.Center) + .onClick(() => { + if (!this.allowRename) { + return; + } + this.renameTitle = this.title; + this.showTitleEditor = true; + }) + if (this.hasSubtitle()) { + Text(this.subtitle) + .fontSize(14) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.Center) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + @Builder private LeadingControl() { if (this.showSidebarRestoreButton) { @@ -116,43 +119,47 @@ export struct RemoteChatHeader { } @Builder - private ActionsControl() { - Stack({ alignContent: Alignment.Center }) { + private TrailingControl() { + if (this.showActions) { + Stack({ alignContent: Alignment.Center }) { TemplateIcon({ src: $r('app.media.remote_ref_more'), iconWidth: 23, iconHeight: 7 }) - } - .width(44) - .height(44) - .backgroundColor(CARD) - .borderRadius(22) - .border({ width: 1, color: LINE }) - .shadow({ radius: 10, color: LINE, offsetY: 3 }) - .accessibilityText(RemoteI18n.t('sidebar.more')) - .bindPopup(this.showActionsMenu, { - builder: () => { - this.actionsMenu(); - }, - placement: Placement.BottomRight, - popupColor: '#00000000', - enableArrow: false, - autoCancel: true, - mask: false, - targetSpace: 8, - onStateChange: (event) => { - this.onActionsMenuStateChange(event.isVisible); } - }) - .onClick(() => { - this.showTitleEditor = false; - this.onOpenActions(); - }) + .width(44) + .height(44) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(22) + .shadow({ radius: 10, color: LINE, offsetY: 3 }) + .accessibilityText(RemoteI18n.t('sidebar.more')) + .bindPopup(this.showActionsMenu, { + builder: () => { + this.actionsMenu(); + }, + placement: Placement.BottomRight, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 8, + onStateChange: (event) => { + this.onActionsMenuStateChange(event.isVisible); + } + }) + .onClick(() => { + this.showTitleEditor = false; + this.onOpenActions(); + }) + } else { + Blank().width(44).height(44) + } } @Builder - TitleEditor() { + private TitleEditor() { Row({ space: 8 }) { TextInput({ placeholder: RemoteI18n.t('chat.sessionTitle'), text: this.renameTitle }) .layoutWeight(1) @@ -193,20 +200,22 @@ export struct RemoteChatHeader { }) } .width('100%') - .padding({ left: 18, right: 18, top: 10, bottom: 8 }) + .padding({ left: 16, right: 16, top: 10, bottom: 8 }) .backgroundColor(PAGE_BG) } - private headerContextTitle(): string { - if (this.desktopName.length > 0) { - return this.desktopName; + private resolvedTitle(): string { + if (this.title.length > 0) { + return this.title; } - const brand = 'BitFun'; - return this.workspaceBranch.length > 0 ? `${brand} · ${this.workspaceBranch}` : brand; + return this.titleFallback; + } + + private hasSubtitle(): boolean { + return this.subtitle.length > 0; } @Builder private EmptyBuilder() { } - } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets index 6a8256879..e94ff9e9c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets @@ -10,7 +10,7 @@ import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; import { RemotePageState } from '../state/RemotePageState'; import { ComposerPresentation } from './ComposerBar'; import { ConversationViewHost } from './ConversationViewHost'; -import { toConversationUiModelCatalog } from './ConversationUiModels'; +import { toConversationUiModelCatalog } from '../state/ConversationUiModels'; import { RemoteCreateSessionView } from './RemoteCreateSessionView'; import { RemoteSurfaceHost, @@ -71,7 +71,8 @@ export struct ConversationRouteSurface { this.route, this.remotePageState, this.generalPageState, - this.actions.generalStatus() + this.actions.generalStatus(), + this.remoteCreateState ), activeFilePreviewPath: this.route === AppRoute.RemoteChat && this.filePreviewState.visible ? this.filePreviewState.target.remotePath : '', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets index 1c66b5a89..562eb4f16 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets @@ -9,8 +9,8 @@ export struct ConversationSourceSwitcher { build() { Row({ space: 2 }) { - this.SourceOption(ConversationSource.General, RemoteI18n.t('sidebar.local')) - this.SourceOption(ConversationSource.Remote, RemoteI18n.t('sidebar.code')) + this.SourceOption(ConversationSource.General, RemoteI18n.t('sidebar.local'), 'conversation-source-local') + this.SourceOption(ConversationSource.Remote, RemoteI18n.t('sidebar.code'), 'conversation-source-remote') } .width('100%') .height(40) @@ -21,8 +21,9 @@ export struct ConversationSourceSwitcher { } @Builder - private SourceOption(source: ConversationSource, label: string) { + private SourceOption(source: ConversationSource, label: string, optionId: string) { Text(label) + .id(optionId) .layoutWeight(1) .height(32) .fontSize(13) @@ -31,6 +32,7 @@ export struct ConversationSourceSwitcher { .textAlign(TextAlign.Center) .backgroundColor(this.activeSource === source ? CARD : '#00000000') .borderRadius(6) + .accessibilityText(label) .onClick(() => { if (this.activeSource !== source) { this.onSelectSource(source); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index a551a782c..51f4dd3e0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -3,8 +3,8 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; import { RemoteLogger } from '../../services/RemoteLogger'; -import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; -import { ChatSurface } from './ChatSurface'; +import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from '../state/ChatComposerCapabilities'; +import { ChatSurface } from '../state/ChatSurface'; import { ChatStatusBar } from './ChatStatusBar'; import { ChatTimeline } from './ChatTimeline'; import { ConversationLoadingState } from './ConversationLoadingState'; @@ -14,11 +14,11 @@ import { ConversationUiQuestionAnswer, ConversationUiSelectedImage, ConversationUiSession -} from './ConversationUiModels'; +} from '../state/ConversationUiModels'; import { ComposerBar, ComposerPresentation } from './ComposerBar'; -import { GeneralChatHeader } from './GeneralChatHeader'; -import { RemoteChatHeader } from './RemoteChatHeader'; -import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; +import { ConversationHeader } from './ConversationHeader'; +import { ConversationHeaderPolicy, ConversationHeaderPresentation } from '../policy/ConversationHeaderPolicy'; +import { FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; @ComponentV2 export struct ConversationView { @@ -41,10 +41,6 @@ export struct ConversationView { @Param hasMoreMessages: boolean = false; @Param timelineItems: ChatTimelineItem[] = []; @Param timelineRevision: number = 0; - @Param showSuggestionsWhenEmpty: boolean = false; - @Param supportsSearch: boolean = false; - @Param supportsImages: boolean = false; - @Param supportsFiles: boolean = false; @Param modelCatalog: ConversationUiModelCatalog = { version: 0, models: [], @@ -63,7 +59,9 @@ export struct ConversationView { @Param showBackButton: boolean = true; @Param showSidebarRestoreButton: boolean = false; @Param composerPresentation: ComposerPresentation = ComposerPresentation.Compact; + @Param composerInputId: string = 'conversation-composer-input'; @Param contentHorizontalOffset: number = 0; + @BuilderParam aboveComposer: () => void = this.EmptyBuilder; @Event onOpenSidebar: () => void = () => {}; @Event onRestoreSidebar: () => void = () => {}; @Event onBack: () => void = () => {}; @@ -91,6 +89,7 @@ export struct ConversationView { @Event onSend: () => void = () => {}; @Event onVoiceInput: () => void = () => {}; @Event onChatInputChange: (value: string) => void = (_value: string) => {}; + @Event onBackgroundTap: () => void = () => {}; @Local showHeaderActions: boolean = false; private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; @@ -118,11 +117,13 @@ export struct ConversationView { maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 800 : 0 }) .layoutWeight(1) - } else if (this.shouldShowSuggestions()) { - Blank().layoutWeight(1) - if (!this.isVoiceListening) { - this.PromptArea() - } + } else if (this.surface === ChatSurface.Create) { + Blank() + .layoutWeight(1) + .width('100%') + .onClick(() => { + this.onBackgroundTap(); + }) } else if (this.shouldCenterInlineStatus()) { this.CenteredInlineStatus() } else { @@ -149,65 +150,38 @@ export struct ConversationView { @Builder Header() { - if (this.surface === ChatSurface.General) { - GeneralChatHeader({ - title: this.activeSession.title, - showActions: ConversationViewContract.hasRealTimelineItem(this.timelineItems), - showSidebarButton: this.showSidebarButton, - showBackButton: this.showBackButton, - showSidebarRestoreButton: this.showSidebarRestoreButton, - showActionsMenu: this.showHeaderActions, - actionsMenu: () => { - this.HeaderActionsPopover(); - }, - onOpenSidebar: () => { - this.onOpenSidebar(); - }, - onRestoreSidebar: () => { - this.onRestoreSidebar(); - }, - onBack: () => { - this.onBack(); - }, - onOpenActions: () => { - this.showHeaderActions = !this.showHeaderActions; - }, - onActionsMenuStateChange: (visible: boolean) => { - this.showHeaderActions = visible; - } - }) - } else { - RemoteChatHeader({ - activeSession: this.activeSession, - workspaceBranch: this.workspaceBranch, - desktopName: this.desktopName, - showBackButton: this.showBackButton, - showSidebarButton: this.showSidebarButton, - showSidebarRestoreButton: this.showSidebarRestoreButton, - showActionsMenu: this.showHeaderActions, - actionsMenu: () => { - this.HeaderActionsPopover(); - }, - onBack: () => { - this.onBack(); - }, - onOpenSidebar: () => { - this.onOpenSidebar(); - }, - onRestoreSidebar: () => { - this.onRestoreSidebar(); - }, - onOpenActions: () => { - this.showHeaderActions = !this.showHeaderActions; - }, - onActionsMenuStateChange: (visible: boolean) => { - this.showHeaderActions = visible; - }, - onRenameSession: (title: string) => { - this.onRenameSession(title); - } - }) - } + ConversationHeader({ + title: this.headerTitle(), + subtitle: this.headerSubtitle(), + titleFallback: this.headerFallback(), + allowRename: this.headerAllowRename(), + showActions: this.headerShowActions(), + showSidebarButton: this.showSidebarButton, + showBackButton: this.showBackButton, + showSidebarRestoreButton: this.showSidebarRestoreButton, + showActionsMenu: this.showHeaderActions, + actionsMenu: () => { + this.HeaderActionsPopover(); + }, + onOpenSidebar: () => { + this.onOpenSidebar(); + }, + onRestoreSidebar: () => { + this.onRestoreSidebar(); + }, + onBack: () => { + this.onBack(); + }, + onOpenActions: () => { + this.showHeaderActions = !this.showHeaderActions; + }, + onActionsMenuStateChange: (visible: boolean) => { + this.showHeaderActions = visible; + }, + onRenameSession: (title: string) => { + this.onRenameSession(title); + } + }) } @Builder @@ -285,45 +259,14 @@ export struct ConversationView { .padding({ left: 32, right: 32, bottom: 48 }) } - @Builder - PromptArea() { - Column({ space: 15 }) { - if (this.supportsSearch) { - this.SuggestionRow('globe', RemoteI18n.t('chatHome.research'), RemoteI18n.t('chatHome.researchPrompt')) - } - if (this.supportsImages) { - this.SuggestionRow('image', RemoteI18n.t('chatHome.image'), RemoteI18n.t('chatHome.imagePrompt')) - } - if (this.supportsFiles) { - this.SuggestionRow('file', RemoteI18n.t('chatHome.file'), RemoteI18n.t('chatHome.filePrompt')) - } - } - .width('100%') - .padding({ left: 24, right: 36, bottom: 18 }) - } - - @Builder - SuggestionRow(icon: string, label: string, prompt: string) { - Row({ space: 14 }) { - this.SuggestionIcon(icon) - Text(label) - .fontSize(17) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - .layoutWeight(1) - } - .width('100%') - .height(38) - .onClick(() => { - this.onChatInputChange(prompt); - }) - } - @Builder Composer() { - ComposerBar({ + Column() { + this.aboveComposer() + ComposerBar({ presentation: this.composerPresentation, capabilities: this.composerCapabilities, + inputId: this.composerInputId, chatInput: this.chatInput, selectedImages: this.selectedImages, isBusy: this.isBusy, @@ -353,7 +296,13 @@ export struct ConversationView { onChatInputChange: (value: string) => { this.onChatInputChange(value); } - }) + }) + } + .width('100%') + } + + @Builder + private EmptyBuilder() { } @Builder @@ -374,18 +323,22 @@ export struct ConversationView { @Builder HeaderActionsContent() { - Text('会话') + Text(RemoteI18n.t('chat.sessionSection')) .fontSize(13).fontWeight(FontWeight.Medium).fontColor(MUTED) .width('100%').height(28).padding({ left: 8 }) if (this.surface === ChatSurface.General) { this.RemoteStyleMenuItem('remote_actions_check', - this.isSessionPinned ? '取消置顶' : '置顶', () => this.onTogglePinSession(), this.isSessionPinned) + this.isSessionPinned ? RemoteI18n.t('chat.unpin') : RemoteI18n.t('chat.pin'), + () => this.onTogglePinSession(), this.isSessionPinned) } - this.RemoteStyleMenuItem('remote_actions_cloud', '已上传的文件', () => this.onShowUploadedFiles()) + this.RemoteStyleMenuItem('remote_actions_cloud', RemoteI18n.t('chat.uploadedFiles'), + () => this.onShowUploadedFiles()) if (this.surface === ChatSurface.General) { Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) - this.RemoteStyleMenuItem('remote_actions_folder', '归档', () => this.onArchiveSession()) - this.RemoteStyleMenuItem('remote_actions_settings', '删除', () => this.onDeleteSession()) + this.RemoteStyleMenuItem('remote_actions_folder', RemoteI18n.t('sidebar.archive'), + () => this.onArchiveSession()) + this.RemoteStyleMenuItem('remote_actions_settings', RemoteI18n.t('common.delete'), + () => this.onDeleteSession()) } else if (this.canStop) { Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) this.RemoteStyleMenuItem('remote_actions_settings', RemoteI18n.t('chat.stop'), () => this.onStop()) @@ -425,70 +378,9 @@ export struct ConversationView { } } - @Builder - SuggestionIcon(kind: string) { - Stack({ alignContent: Alignment.Center }) { - if (kind === 'write') { - Text('Aa') - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - } else if (kind === 'organize') { - Text('=') - .fontSize(23) - .fontColor(MUTED) - } else if (kind === 'plan') { - Text('✓') - .fontSize(18) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - } else if (kind === 'globe') { - SymbolGlyph($r('sys.symbol.website')) - .fontSize(21) - .fontColor([MUTED]) - } else if (kind === 'image') { - SymbolGlyph($r('sys.symbol.picture')) - .fontSize(21) - .fontColor([MUTED]) - } else { - this.FileGlyph() - } - } - .width(26) - .height(26) - } - - @Builder - FileGlyph() { - Stack() { - Text('') - .width(19) - .height(23) - .borderRadius(3) - .border({ width: 1.5, color: MUTED }) - .position({ x: 3, y: 2 }) - Text('') - .width(7) - .height(7) - .border({ width: { right: 1.5, bottom: 1.5 }, color: MUTED }) - .position({ x: 15, y: 3 }) - } - .width(26) - .height(26) - } - - private shouldShowSuggestions(): boolean { - return ConversationViewContract.shouldShowSuggestions( - this.showSuggestionsWhenEmpty, - this.isBusy, - this.timelineItems - ) && (this.supportsSearch || this.supportsImages || this.supportsFiles); - } - private shouldCenterInlineStatus(): boolean { return this.inlineStatusText.length > 0 && !this.isBusy && - !this.shouldShowSuggestions() && !ConversationViewContract.hasRealTimelineItem(this.timelineItems); } @@ -529,7 +421,39 @@ export struct ConversationView { } private shouldShowStatusBar(): boolean { - return !this.isLoadingConversation && this.surface === ChatSurface.Remote && this.connectionState !== 'connected'; + return this.surface === ChatSurface.Remote && + !this.isLoadingConversation && + this.connectionState !== 'connected'; + } + + private headerTitle(): string { + return this.headerPresentation().title; + } + + private headerSubtitle(): string { + return this.headerPresentation().subtitle; + } + + private headerFallback(): string { + return RemoteI18n.t(this.headerPresentation().fallbackKey); + } + + private headerAllowRename(): boolean { + return this.headerPresentation().allowRename; + } + + private headerShowActions(): boolean { + return this.headerPresentation().showActions; + } + + private headerPresentation(): ConversationHeaderPresentation { + return ConversationHeaderPolicy.present( + this.surface, + this.activeSession.title, + this.desktopName, + this.workspaceBranch, + ConversationViewContract.hasRealTimelineItem(this.timelineItems) + ); } private connectionColor(): ResourceColor { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets index f3e42cc60..c9d002522 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets @@ -1,5 +1,5 @@ import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; -import { ChatSurface } from './ChatSurface'; +import { ChatSurface } from '../state/ChatSurface'; export class ConversationViewContract { static surfaceForRoute(isRemoteChat: boolean): ChatSurface { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets index 045054b74..c19ef8aa7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets @@ -5,7 +5,7 @@ import { ConversationIntents, ConversationIntentType } from '../actions/ConversationIntent'; -import { ConversationUiQuestionAnswer } from './ConversationUiModels'; +import { ConversationUiQuestionAnswer } from '../state/ConversationUiModels'; import { ComposerPresentation } from './ComposerBar'; @ComponentV2 @@ -37,10 +37,6 @@ export struct ConversationViewHost { hasMoreMessages: this.viewState.hasMoreMessages, timelineItems: this.viewState.timelineItems, timelineRevision: this.viewState.timelineRevision, - showSuggestionsWhenEmpty: this.viewState.showSuggestionsWhenEmpty, - supportsSearch: this.viewState.supportsSearch, - supportsImages: this.viewState.supportsImages, - supportsFiles: this.viewState.supportsFiles, modelCatalog: this.viewState.modelCatalog, selectedModelId: this.viewState.selectedModelId, downloadingFilePath: this.viewState.downloadingFilePath, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets deleted file mode 100644 index a00d0ca78..000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets +++ /dev/null @@ -1,228 +0,0 @@ -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SOFT, SUBTLE } from './Theme'; - -@ComponentV2 -export struct CreateSessionSheet { - @Param createAgentType: string = 'code'; - @Param workspaceName: string = ''; - @Param workspaceBranch: string = ''; - @Param isBusy: boolean = false; - @Param sessionTitle: string = ''; - @Param instruction: string = ''; - @Event onSessionTitleChange: (value: string) => void = (_value: string) => {}; - @Event onInstructionChange: (value: string) => void = (_value: string) => {}; - @Event onClose: () => void = () => {}; - @Event onChooseWorkspace: () => void = () => {}; - @Event onStart: () => void = () => {}; - - build() { - Column() { - Blank() - Column({ space: 16 }) { - Text('') - .width(42) - .height(4) - .backgroundColor(SUBTLE) - .borderRadius(2) - - Row() { - Text(RemoteI18n.f('sheet.createTitle', this.displayAgent())) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Blank() - Text('×') - .fontSize(26) - .fontColor(INK) - .width(34) - .height(34) - .textAlign(TextAlign.Center) - .onClick(() => { - this.onClose(); - }) - } - .width('100%') - - Scroll() { - Column({ space: 18 }) { - Text(RemoteI18n.t('sheet.workspace')) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - .margin({ top: 4 }) - - Row({ space: 10 }) { - this.FolderGlyph() - Text(this.workspaceTitle()) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text('›') - .fontSize(18) - .fontColor(INK) - } - .width('100%') - .height(58) - .padding({ left: 14, right: 12 }) - .backgroundColor(SOFT) - .borderRadius(14) - .border({ width: 1, color: LINE }) - .onClick(() => { - this.onChooseWorkspace(); - }) - - this.InstructionField() - this.AdvancedOptions() - } - .width('100%') - .padding({ bottom: 8 }) - } - .layoutWeight(1) - .scrollBar(BarState.Off) - - Button(this.isBusy ? RemoteI18n.t('sheet.creating') : RemoteI18n.t('sheet.start')) - .width('100%') - .height(56) - .fontSize(17) - .fontWeight(FontWeight.Medium) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .borderRadius(14) - .enabled(!this.isBusy) - .onClick(() => { - this.onStart(); - }) - } - .width('100%') - .height('64%') - .padding({ left: 22, right: 22, top: 10, bottom: 26 }) - .backgroundColor(PAGE_BG) - .borderRadius({ topLeft: 20, topRight: 20 }) - } - .width('100%') - .height('100%') - .backgroundColor('#99000000') - } - - @Builder - TitleField() { - Column({ space: 10 }) { - Text(RemoteI18n.t('sheet.sessionTitleOptional')) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - TextInput({ placeholder: this.defaultSessionTitle(), text: this.sessionTitle }) - .height(52) - .fontSize(15) - .backgroundColor(SOFT) - .borderRadius(14) - .padding({ left: 14, right: 14 }) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .onChange((value: string) => { - this.onSessionTitleChange(value); - }) - } - .width('100%') - } - - @Builder - InstructionField() { - Column({ space: 10 }) { - Text(RemoteI18n.t('sheet.firstInstructionOptional')) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - TextArea({ placeholder: RemoteI18n.t('sheet.instructionPlaceholder'), text: this.instruction }) - .height(68) - .fontSize(15) - .backgroundColor(SOFT) - .borderRadius(14) - .padding(14) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .onChange((value: string) => { - this.onInstructionChange(value); - }) - } - .width('100%') - } - - @Builder - AdvancedOptions() { - Column({ space: 10 }) { - Text(RemoteI18n.t('sheet.advancedOptions')) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - Row() { - Text('模型') - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - Text('Auto') - .fontSize(14) - .fontColor(INK) - Text('⌄') - .fontSize(18) - .fontColor(INK) - .margin({ left: 10 }) - } - .width('100%') - .height(56) - .padding({ left: 14, right: 14 }) - .backgroundColor(SOFT) - .borderRadius(14) - .border({ width: 1, color: LINE }) - } - .width('100%') - } - - @Builder - FolderGlyph() { - Stack() { - Text('') - .width(26) - .height(18) - .borderRadius(4) - .border({ width: 2, color: INK }) - .position({ x: 2, y: 9 }) - Text('') - .width(12) - .height(7) - .borderRadius({ topLeft: 4, topRight: 4 }) - .border({ width: { top: 2, left: 2, right: 2 }, color: INK }) - .position({ x: 4, y: 5 }) - } - .width(30) - .height(30) - } - - private workspaceTitle(): string { - const base = this.workspaceName || 'BitFun'; - return this.workspaceBranch.length > 0 ? `${base} / ${this.workspaceBranch}` : base; - } - - private defaultSessionTitle(): string { - return `Remote ${this.displayAgent()} Session`; - } - - private displayAgent(): string { - const normalized = (this.createAgentType || '').toLowerCase(); - if (normalized === 'cowork') { - return 'Cowork'; - } - if (normalized === 'claw') { - return 'Claw'; - } - return 'Code'; - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets deleted file mode 100644 index 8deb0fb9c..000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets +++ /dev/null @@ -1,144 +0,0 @@ -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; -import { CompactMenuButton } from './CompactMenuButton'; -import { TemplateIcon } from './TemplateIcon'; -import { SidebarToggleButton } from './SidebarToggleButton'; - -@ComponentV2 -export struct GeneralChatHeader { - @Param title: string = ''; - /** Secondary context line. Empty keeps the single-line header. */ - @Param subtitle: string = ''; - @Param showActions: boolean = false; - @Param showSidebarButton: boolean = true; - @Param showBackButton: boolean = false; - @Param showSidebarRestoreButton: boolean = false; - @Param showActionsMenu: boolean = false; - @BuilderParam actionsMenu: () => void = this.EmptyBuilder; - @Event onOpenSidebar: () => void = () => {}; - @Event onRestoreSidebar: () => void = () => {}; - @Event onBack: () => void = () => {}; - @Event onOpenActions: () => void = () => {}; - @Event onActionsMenuStateChange: (visible: boolean) => void = (_visible: boolean) => {}; - - build() { - Row({ space: 8 }) { - this.LeadingControl() - this.TitleBlock() - this.TrailingControl() - } - .width('100%') - .height(this.hasSubtitle() ? 76 : 64) - .alignItems(VerticalAlign.Center) - .padding({ left: 16, right: 16, top: 8, bottom: 8 }) - .backgroundColor(PAGE_BG) - } - - /** Mirrors the conversation header: title above a muted context line. */ - @Builder - private TitleBlock() { - Column({ space: 3 }) { - Text(this.title || 'BitFun') - .fontSize(this.hasSubtitle() ? 18 : 17) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .textAlign(TextAlign.Center) - if (this.hasSubtitle()) { - Text(this.subtitle) - .fontSize(14) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .textAlign(TextAlign.Center) - } - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Center) - } - - private hasSubtitle(): boolean { - return this.subtitle.length > 0; - } - - @Builder - private LeadingControl() { - if (this.showSidebarRestoreButton) { - SidebarToggleButton({ - restore: true, - controlSize: 48, - onToggle: this.onRestoreSidebar - }) - } else if (this.showBackButton) { - Stack({ alignContent: Alignment.Center }) { - TemplateIcon({ - src: $r('app.media.remote_ref_back'), - iconWidth: 15, - iconHeight: 23 - }) - } - .width(44) - .height(44) - .backgroundColor(CARD) - .border({ width: 1, color: LINE }) - .borderRadius(22) - .shadow({ radius: 10, color: LINE, offsetY: 3 }) - .accessibilityText(RemoteI18n.t('common.back')) - .onClick(() => { - this.onBack(); - }) - } else if (this.showSidebarButton) { - CompactMenuButton({ - onOpen: () => { - this.onOpenSidebar(); - } - }) - } else { - Blank().width(44).height(44) - } - } - - @Builder - private TrailingControl() { - if (this.showActions) { - Stack({ alignContent: Alignment.Center }) { - TemplateIcon({ - src: $r('app.media.remote_ref_more'), - iconWidth: 23, - iconHeight: 7 - }) - } - .width(44) - .height(44) - .backgroundColor(CARD) - .border({ width: 1, color: LINE }) - .borderRadius(22) - .shadow({ radius: 10, color: LINE, offsetY: 3 }) - .accessibilityText(RemoteI18n.t('sidebar.more')) - .bindPopup(this.showActionsMenu, { - builder: () => { - this.actionsMenu(); - }, - placement: Placement.BottomRight, - popupColor: '#00000000', - enableArrow: false, - autoCancel: true, - mask: false, - targetSpace: 8, - onStateChange: (event) => { - this.onActionsMenuStateChange(event.isVisible); - } - }) - .onClick(() => { - this.onOpenActions(); - }) - } else { - Blank().width(44).height(44) - } - } - - @Builder - private EmptyBuilder() { - } -} 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 c3cda2916..4a4beebf4 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 @@ -4,6 +4,7 @@ import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { RemotePermissionMode } from '../../model/RemoteModels'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; import { BitFunAccountLoginPage } from './BitFunAccountLoginPage'; +import { AccountDeviceSelectionPolicy } from '../policy/AccountDeviceSelectionPolicy'; @ComponentV2 export struct RemoteControlSettingsSheet { @@ -25,6 +26,8 @@ export struct RemoteControlSettingsSheet { @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; @Event cloudLogout: () => Promise = async (): Promise => {}; @Event cloudListDevices: () => Promise = async (): Promise => []; + @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = + async (_device: CloudAccountDevice): Promise => {}; @Event getPermissionMode: () => Promise = async (): Promise => 'ask'; @Event setPermissionMode: (mode: RemotePermissionMode) => Promise = async (mode: RemotePermissionMode): Promise => mode; @@ -35,6 +38,7 @@ export struct RemoteControlSettingsSheet { @Local accountDevices: CloudAccountDevice[] = []; @Local accountDevicesBusy: boolean = false; @Local accountDevicesError: string = ''; + @Local switchingDeviceId: string = ''; @Local permissionMode: RemotePermissionMode = 'ask'; @Local permissionModeBusy: boolean = false; @Local permissionModeLoaded: boolean = false; @@ -621,6 +625,10 @@ export struct RemoteControlSettingsSheet { }, (device: CloudAccountDevice): string => `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) } + if (this.accountDevicesError.length > 0 && this.desktopDevices().length > 0) { + Text(this.accountDevicesError) + .fontSize(13).lineHeight(18).fontColor(RED).width('100%') + } } .width('100%').padding({ left: 18, right: 18, top: 16, bottom: 16 }) .backgroundColor(CARD).borderRadius(24).margin({ bottom: 24 }) @@ -638,9 +646,19 @@ export struct RemoteControlSettingsSheet { Text(this.deviceStatus(device)) .fontSize(13).fontColor(device.online ? GREEN : MUTED) }.layoutWeight(1).alignItems(HorizontalAlign.Start) + if (this.shouldShowDeviceConnectAction(device)) { + Text(this.switchingDeviceId === device.deviceId ? + RemoteI18n.t('remote.settings.deviceConnecting') : RemoteI18n.t('connect.connect')) + .fontSize(14).fontColor(INK) + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(SOFT).borderRadius(14) + } } .width('100%').height(54).alignItems(VerticalAlign.Center) - .opacity(device.online ? 1 : 0.68) + .opacity(this.canActivateAccountDevice(device) ? 1 : 0.68) + .onClick(() => { + void this.connectAccountDevice(device); + }) } private async refreshAccountDevices(allowPendingLogin: boolean = false): Promise { @@ -661,6 +679,49 @@ export struct RemoteControlSettingsSheet { device.deviceId !== this.deviceId && device.deviceName !== 'HarmonyOS Phone'); } + private canActivateAccountDevice(device: CloudAccountDevice): boolean { + return AccountDeviceSelectionPolicy.canActivate( + device.online, + device.deviceId, + this.deviceId, + this.controlTargetDeviceId, + this.connectionState, + this.switchingDeviceId + ); + } + + private shouldShowDeviceConnectAction(device: CloudAccountDevice): boolean { + return AccountDeviceSelectionPolicy.shouldShowConnectAction( + device.online, + device.deviceId, + this.deviceId, + this.controlTargetDeviceId, + this.connectionState + ); + } + + private async connectAccountDevice(device: CloudAccountDevice): Promise { + if (!this.canActivateAccountDevice(device)) { + return; + } + this.switchingDeviceId = device.deviceId; + this.accountDevicesError = ''; + try { + if (AccountDeviceSelectionPolicy.canSelectOnline( + device.online, device.deviceId, this.deviceId, '' + )) { + await this.cloudSelectDevice(device); + } else { + this.onReconnect(); + } + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceSwitchFailed'); + } finally { + this.switchingDeviceId = ''; + } + } + private deviceStatus(device: CloudAccountDevice): string { if (device.deviceId === this.deviceId) return RemoteI18n.t('remote.settings.deviceCurrent'); const presence = device.online ? RemoteI18n.t('remote.settings.deviceOnline') : diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets index 130cfd836..2f501cdc2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets @@ -1,14 +1,13 @@ -import { KeyboardAvoidMode } from '@kit.ArkUI'; import { RecentWorkspaceEntry } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; -import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, RED, SOFT, SUBTLE } from './Theme'; -import { ComposerBar, ComposerPresentation } from './ComposerBar'; -import { ConversationUiModelCatalog } from './ConversationUiModels'; -import { REMOTE_CREATE_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; -import { SidebarToggleButton } from './SidebarToggleButton'; -import { TemplateIcon } from './TemplateIcon'; +import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; +import { ComposerPresentation } from './ComposerBar'; +import { ConversationView } from './ConversationView'; +import { ConversationUiModelCatalog } from '../state/ConversationUiModels'; +import { REMOTE_CREATE_COMPOSER_CAPABILITIES } from '../state/ChatComposerCapabilities'; +import { ChatSurface } from '../state/ChatSurface'; @ComponentV2 export struct RemoteCreateSessionView { @@ -33,138 +32,48 @@ export struct RemoteCreateSessionView { @Event onVoiceInput: () => void = () => {}; @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; @Local showSelectorSheet: boolean = false; - private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; aboutToAppear(): void { - this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); - this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); setTimeout(() => this.keepComposerFocused(), 180); } - aboutToDisappear(): void { - this.getUIContext().setKeyboardAvoidMode(this.previousKeyboardAvoidMode); - } - build() { Column() { - if (this.presentation === ComposerPresentation.Floating) { - this.Header() - Blank() - .layoutWeight(1) - .onClick(() => { - this.state.closeMenu(); - this.keepComposerFocused(); - }) - } else { - this.CompactNavigationSpace() - this.ContextControls() - this.CompactComposerBar() - } - if (this.presentation === ComposerPresentation.Floating) { - this.TaskComposer() - } - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - .bindSheet($$this.showSelectorSheet, this.SelectorSheet(), this.selectorSheetOptions()) - } - - @Builder - CompactComposerBar() { - ComposerBar({ - presentation: ComposerPresentation.Create, - capabilities: REMOTE_CREATE_COMPOSER_CAPABILITIES, - inputId: 'remote-create-composer', - chatInput: this.state.draft, - isBusy: this.state.isSubmitting, - connectionState: 'connected', - isVoiceListening: this.isVoiceListening, - modelCatalog: this.modelCatalog, - selectedModelId: this.selectedModelId, - onSend: () => this.onSend(), - onChatInputChange: (value: string) => this.onDraftChange(value), - onVoiceInput: () => this.onVoiceInput(), - onSelectModel: (modelId: string) => this.onSelectModel(modelId) - }) - } - - @Builder - CompactNavigationSpace() { - Column() { - Row() { - Button() { - TemplateIcon({ - src: $r('app.media.remote_ref_back'), - iconWidth: 15, - iconHeight: 23 - }) - } - .width(44) - .height(44) - .padding(0) - .type(ButtonType.Circle) - .backgroundColor(CARD) - .border({ width: 1, color: LINE }) - .borderRadius(22) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) - .accessibilityText(RemoteI18n.t('common.back')) - .onClick(() => this.onBack()) - } - .width('100%') - .height(78) - .padding({ left: 18, top: 14 }) - .alignItems(VerticalAlign.Top) - Column() - .width('100%') - .layoutWeight(1) - .onClick(() => { + ConversationView({ + surface: ChatSurface.Create, + composerCapabilities: REMOTE_CREATE_COMPOSER_CAPABILITIES, + composerPresentation: this.presentation === ComposerPresentation.Floating ? + ComposerPresentation.Floating : ComposerPresentation.Create, + composerInputId: 'remote-create-composer', + chatInput: this.state.draft, + isBusy: this.state.isSubmitting, + inlineStatusText: this.state.errorText, + connectionState: 'connected', + isVoiceListening: this.isVoiceListening, + modelCatalog: this.modelCatalog, + selectedModelId: this.selectedModelId, + showBackButton: true, + showSidebarButton: false, + showSidebarRestoreButton: this.showSidebarRestoreButton, + aboveComposer: () => { + this.ContextControls(); + }, + onBack: () => this.onBack(), + onRestoreSidebar: () => this.onRestoreSidebar(), + onSend: () => this.onSend(), + onChatInputChange: (value: string) => this.onDraftChange(value), + onVoiceInput: () => this.onVoiceInput(), + onSelectModel: (modelId: string) => this.onSelectModel(modelId), + onBackgroundTap: () => { this.state.closeMenu(); this.keepComposerFocused(); - }) - } - .width('100%') - .layoutWeight(1) - } - - @Builder - Header() { - Row({ space: 8 }) { - if (this.showSidebarRestoreButton) { - SidebarToggleButton({ - restore: true, - controlSize: 48, - onToggle: this.onRestoreSidebar - }) - } else if (this.presentation !== ComposerPresentation.Floating) { - Button() { - SymbolGlyph($r('sys.symbol.chevron_left')) - .fontSize(22) - .fontColor([INK]) } - .width(48) - .height(48) - .padding(0) - .type(ButtonType.Circle) - .backgroundColor(CARD) - .border({ width: 1, color: LINE }) - .borderRadius(24) - .accessibilityText(RemoteI18n.t('common.back')) - .onClick(() => this.onBack()) - } else { - Blank().width(48).height(48) - } - Text(RemoteI18n.t('remote.create.title')) - .layoutWeight(1) - .fontSize(17) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .textAlign(TextAlign.Center) - Blank().width(48).height(48) + }) } .width('100%') - .height(64) - .padding({ left: 16, right: 16, top: 8, bottom: 8 }) + .height('100%') + .backgroundColor(PAGE_BG) + .bindSheet($$this.showSelectorSheet, this.SelectorSheet(), this.selectorSheetOptions()) } @Builder @@ -445,102 +354,6 @@ export struct RemoteCreateSessionView { .borderRadius(16) } - @Builder - TaskComposer() { - Column({ space: 6 }) { - if (this.presentation === ComposerPresentation.Floating) { - this.ContextControls() - Divider() - .color(LINE) - .margin({ left: 18, right: 18 }) - } - if (this.state.errorText.length > 0) { - Text(this.state.errorText) - .width('100%') - .padding({ left: 12, right: 12 }) - .fontSize(12) - .fontColor(RED) - .maxLines(2) - } - Row({ space: 8 }) { - TextArea({ placeholder: RemoteI18n.t('remote.create.placeholder'), text: this.state.draft }) - .id('remote-create-composer') - .layoutWeight(1) - .height(56) - .fontSize(16) - .fontColor(INK) - .placeholderColor(SUBTLE) - .backgroundColor('#00000000') - .padding({ left: 6, right: 4, top: 8, bottom: 8 }) - .defaultFocus(true) - .maxLines(3) - .onChange((value: string, previewText?: PreviewText) => { - if (previewText && previewText.value.length > 0) { - return; - } - this.onDraftChange(value); - }) - Button() { - SymbolGlyph($r('sys.symbol.arrow_up')) - .fontSize(23) - .fontColor([INK]) - .opacity(this.canSend() ? 1 : 0.36) - } - .width(42) - .height(42) - .padding(0) - .type(ButtonType.Circle) - .backgroundColor('#00000000') - .enabled(this.canSend()) - .onClick(() => this.onSend()) - } - .width('100%') - .height(this.presentation === ComposerPresentation.Floating ? 72 : 66) - .padding({ left: 12, right: 7, top: 5, bottom: 5 }) - .backgroundColor(this.presentation === ComposerPresentation.Floating ? '#00000000' : CARD) - .borderRadius(this.presentation === ComposerPresentation.Floating ? 0 : 25) - .border({ - width: this.presentation === ComposerPresentation.Floating ? 0 : 1, - color: this.presentation === ComposerPresentation.Floating ? '#00000000' : SOFT - }) - .shadow({ - radius: this.presentation === ComposerPresentation.Floating ? 0 : 22, - color: this.presentation === ComposerPresentation.Floating ? '#00000000' : '#18000000', - offsetY: this.presentation === ComposerPresentation.Floating ? 0 : 7 - }) - } - .width('100%') - .constraintSize({ maxWidth: this.presentation === ComposerPresentation.Floating ? 760 : 10000 }) - .alignSelf(ItemAlign.Center) - .padding({ - left: this.presentation === ComposerPresentation.Floating ? 0 : 16, - right: this.presentation === ComposerPresentation.Floating ? 0 : 16, - top: this.presentation === ComposerPresentation.Floating ? 4 : 0, - bottom: this.presentation === ComposerPresentation.Floating ? 4 : 14 - }) - .backgroundColor(this.presentation === ComposerPresentation.Floating ? CARD : '#00000000') - .borderRadius(this.presentation === ComposerPresentation.Floating ? 18 : 0) - .border({ - width: this.presentation === ComposerPresentation.Floating ? 1 : 0, - color: this.presentation === ComposerPresentation.Floating ? SOFT : '#00000000' - }) - .shadow({ - radius: this.presentation === ComposerPresentation.Floating ? 20 : 0, - color: this.presentation === ComposerPresentation.Floating ? '#18000000' : '#00000000', - offsetY: this.presentation === ComposerPresentation.Floating ? 6 : 0 - }) - .margin({ - left: this.presentation === ComposerPresentation.Floating ? 24 : 0, - right: this.presentation === ComposerPresentation.Floating ? 24 : 0, - bottom: this.presentation === ComposerPresentation.Floating ? 24 : 0 - }) - } - - private canSend(): boolean { - return this.state.draft.trim().length > 0 && this.state.selectedDeviceId.length > 0 && - !this.state.isSubmitting; - } - private keepComposerFocused(): void { setTimeout(() => focusControl.requestFocus('remote-create-composer'), 30); } 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 0a3cb71b9..45380c8b6 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 @@ -92,6 +92,7 @@ export struct SettingsSheet { .backgroundColor(CARD) .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) .margin({ top: 22, right: 18 }) + .accessibilityText(RemoteI18n.t('common.close')) .onClick(() => { this.onClose(); }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets index 42a9a75af..c1f35ba89 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets @@ -1,4 +1,6 @@ -import { MUTED } from './Theme'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ThinkingPresentationPolicy } from '../policy/ThinkingPresentationPolicy'; +import { INK, MUTED, SOFT } from './Theme'; @ComponentV2 export struct ThinkingBlock { @@ -9,51 +11,122 @@ export struct ThinkingBlock { @Param streamKey: string = ''; @Event onCopyText: (text: string) => void = (_text: string) => {}; @Local dotPhase: number = 0; + @Local expanded: boolean = false; private dotTimerId: number = 0; aboutToAppear(): void { - if (this.isRunning()) { - this.dotTimerId = setInterval(() => { - this.dotPhase = (this.dotPhase + 1) % 3; - }, 360); - } + this.expanded = ThinkingPresentationPolicy.defaultExpanded(this.isRunning(), this.keepExpandedWhenDone); + this.syncDotTimer(); } aboutToDisappear(): void { - if (this.dotTimerId !== 0) { - clearInterval(this.dotTimerId); - this.dotTimerId = 0; - } + this.stopDotTimer(); + } + + @Monitor('status') + onStatusChanged(): void { + this.expanded = ThinkingPresentationPolicy.defaultExpanded(this.isRunning(), this.keepExpandedWhenDone); + this.syncDotTimer(); } build() { - if (this.isRunning()) { - Row({ space: 6 }) { - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.dotOpacity(0)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.dotOpacity(1)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - Text('•') - .width(6) - .height(18) - .fontSize(16) + if (ThinkingPresentationPolicy.shouldRender(this.status, this.text)) { + Column({ space: 8 }) { + this.HeaderRow() + if (this.expanded && this.hasBody()) { + Text(this.text) + .fontSize(14) + .lineHeight(21) + .fontColor(MUTED) + .width('100%') + } + } + .width('100%') + .padding({ left: 2, right: 2, top: 2, bottom: this.expanded && this.hasBody() ? 8 : 2 }) + } + } + + @Builder + HeaderRow() { + Row({ space: 8 }) { + Row({ space: 8 }) { + if (this.isRunning()) { + this.RunningDots() + } else if (this.expanded) { + SymbolGlyph($r('sys.symbol.chevron_down')) + .fontSize(14) + .fontColor([MUTED]) + .width(16) + .height(16) + } else { + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + .width(16) + .height(16) + } + Text(this.isRunning() ? RemoteI18n.t('chat.thinkingInProgress') : RemoteI18n.t('chat.thinkingComplete')) + .fontSize(13) + .fontWeight(FontWeight.Medium) .fontColor(MUTED) - .opacity(this.dotOpacity(2)) - .animation({ duration: 180, curve: Curve.EaseInOut }) + .layoutWeight(1) + } + .layoutWeight(1) + .height(32) + .alignItems(VerticalAlign.Center) + .onClick(() => { + if (!this.isRunning() && this.hasBody()) { + this.expanded = !this.expanded; + } + }) + .accessibilityText(this.isRunning() ? + RemoteI18n.t('chat.thinkingInProgress') : + RemoteI18n.t('chat.thinkingComplete')) + if (this.hasBody() && !this.isRunning()) { + Text(RemoteI18n.t('common.copy')) + .fontSize(13) + .fontColor(INK) + .padding({ left: 8, right: 8, top: 6, bottom: 6 }) + .backgroundColor(SOFT) + .borderRadius(8) + .accessibilityText(RemoteI18n.t('common.copy')) + .onClick(() => { + this.onCopyText(this.text); + }) } - .height(24) - .padding({ left: 2 }) } + .width('100%') + .height(32) + .alignItems(VerticalAlign.Center) + } + + @Builder + RunningDots() { + Row({ space: 6 }) { + Text('•') + .width(6) + .height(18) + .fontSize(16) + .fontColor(MUTED) + .opacity(this.dotOpacity(0)) + .animation({ duration: 180, curve: Curve.EaseInOut }) + Text('•') + .width(6) + .height(18) + .fontSize(16) + .fontColor(MUTED) + .opacity(this.dotOpacity(1)) + .animation({ duration: 180, curve: Curve.EaseInOut }) + Text('•') + .width(6) + .height(18) + .fontSize(16) + .fontColor(MUTED) + .opacity(this.dotOpacity(2)) + .animation({ duration: 180, curve: Curve.EaseInOut }) + } + .width(22) + .height(18) } private dotOpacity(index: number): number { @@ -61,7 +134,29 @@ export struct ThinkingBlock { } private isRunning(): boolean { - const normalized = (this.status || '').toLowerCase(); - return normalized === 'active' || normalized === 'running'; + return ThinkingPresentationPolicy.isRunning(this.status); + } + + private hasBody(): boolean { + return this.text.trim().length > 0; + } + + private syncDotTimer(): void { + if (this.isRunning()) { + if (this.dotTimerId === 0) { + this.dotTimerId = setInterval(() => { + this.dotPhase = (this.dotPhase + 1) % 3; + }, 360); + } + return; + } + this.stopDotTimer(); + } + + private stopDotTimer(): void { + if (this.dotTimerId !== 0) { + clearInterval(this.dotTimerId); + this.dotTimerId = 0; + } } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets index 8b930354b..5427bad5a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets @@ -1,5 +1,5 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ConversationUiQuestionAnswer } from './ConversationUiModels'; +import { ConversationUiQuestionAnswer } from '../state/ConversationUiModels'; import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; interface QuestionOption { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index fa8f893ae..6bbe8f764 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -1,4 +1,4 @@ -import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; +import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from '../state/ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ToolFileReference, ToolFileReferenceResolver } from '../../services/ToolFileReferenceResolver'; import { CARD, FILE_LINK, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index 0df472ef2..e50afb131 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -8,7 +8,8 @@ import { emptyAppRootPresentationActions } from '../../actions/AppRootPresentationActions'; import { ConversationViewSettings } from '../ConversationViewSettings'; -import { GeneralChatHeader } from '../GeneralChatHeader'; +import { ConversationHeader } from '../ConversationHeader'; +import { RemoteCompactHomePolicy } from '../../policy/RemoteCompactHomePolicy'; import { RemoteSessionList } from '../RemoteSessionList'; import { RemoteSessionLoadingView } from '../RemoteSessionLoadingView'; import { SidebarToggleButton } from '../SidebarToggleButton'; @@ -60,7 +61,9 @@ export struct RemoteSurfaceHost { // down for a change that is only page state — the sidebar rebuild this used // to pay for. See AppShellViewModel.replaceRouteWithoutAnimation. aboutToAppear(): void { - RemoteLogger.info(`remote surface mounted mode=${this.mode}`); + RemoteLogger.info( + `remote surface mounted mode=${this.mode} connection=${this.remotePageState.connectionState} sessions=${this.remotePageState.visibleSessions().length}` + ); } build() { @@ -143,7 +146,7 @@ export struct RemoteSurfaceHost { .padding({ left: 12, right: 12 }) .backgroundColor(PRIMARY_ACTION) .borderRadius(14) - .onClick(() => this.actions.onRemoteHome.connectWorkspace()) + .onClick(() => this.actions.onRemoteHome.reconnect()) } } .width('100%') @@ -196,16 +199,17 @@ export struct RemoteSurfaceHost { @Builder private CompactHomeContent() { Column() { - GeneralChatHeader({ + ConversationHeader({ title: RemoteI18n.t('remote.title'), subtitle: this.compactHeaderContext(), + titleFallback: RemoteI18n.t('remote.title'), showSidebarButton: true, onOpenSidebar: this.onOpenSidebar }) - if (this.canShowSessionList()) { - this.CompactEmptyState() - } else { + if (RemoteCompactHomePolicy.shouldShowConnectHome(this.remotePageState.connectionState)) { this.DisconnectedState() + } else { + this.CompactEmptyState() } } .width('100%') @@ -233,7 +237,7 @@ export struct RemoteSurfaceHost { .width(148).height(46).fontSize(15).fontWeight(FontWeight.Medium) .fontColor(PRIMARY_ACTION_TEXT).backgroundColor(PRIMARY_ACTION) .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) - .onClick(() => this.actions.onRemoteHome.connectWorkspace()) + .onClick(() => this.actions.onRemoteHome.reconnect()) } else { Text(RemoteI18n.t('remote.startSession')) .width(148).height(46).fontSize(15).fontWeight(FontWeight.Medium) @@ -404,7 +408,10 @@ export struct RemoteSurfaceHost { * the connect state does not advertise a stale desktop. */ private compactHeaderContext(): string { - return this.canShowSessionList() ? this.remotePageState.desktopName : ''; + return RemoteCompactHomePolicy.headerSubtitle( + this.remotePageState.connectionState, + this.remotePageState.desktopName + ); } private desktopName(): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/AccountDeviceSelectionPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/AccountDeviceSelectionPolicy.ets new file mode 100644 index 000000000..2a85a48f8 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/AccountDeviceSelectionPolicy.ets @@ -0,0 +1,52 @@ +/** + * Shared selection rules for the account-device list on Connect and Settings. + * Online desktops can be selected. The last control target can still be tapped + * to reconnect after the live link drops, even if presence already reads offline. + */ +export class AccountDeviceSelectionPolicy { + static canSelectOnline( + online: boolean, + deviceId: string, + phoneDeviceId: string, + switchingDeviceId: string + ): boolean { + return online && deviceId !== phoneDeviceId && switchingDeviceId.length === 0; + } + + static shouldReconnectLastTarget( + deviceId: string, + controlTargetDeviceId: string, + connectionState: string + ): boolean { + return deviceId.length > 0 && + deviceId === controlTargetDeviceId && + connectionState !== 'connected'; + } + + static canActivate( + online: boolean, + deviceId: string, + phoneDeviceId: string, + controlTargetDeviceId: string, + connectionState: string, + switchingDeviceId: string + ): boolean { + if (switchingDeviceId.length > 0) { + return false; + } + return AccountDeviceSelectionPolicy.canSelectOnline(online, deviceId, phoneDeviceId, switchingDeviceId) || + AccountDeviceSelectionPolicy.shouldReconnectLastTarget(deviceId, controlTargetDeviceId, connectionState); + } + + static shouldShowConnectAction( + online: boolean, + deviceId: string, + phoneDeviceId: string, + controlTargetDeviceId: string, + connectionState: string + ): boolean { + return AccountDeviceSelectionPolicy.canActivate( + online, deviceId, phoneDeviceId, controlTargetDeviceId, connectionState, '' + ) && !(deviceId === controlTargetDeviceId && connectionState === 'connected'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConnectOpenIntentPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConnectOpenIntentPolicy.ets deleted file mode 100644 index 70973453d..000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConnectOpenIntentPolicy.ets +++ /dev/null @@ -1,45 +0,0 @@ -import { CONNECT_INTENT_SCAN } from '../state/AppShellState'; - -/** - * Where the connect sheet opens, and what Back means once it is there. - */ -export class ConnectOpenIntentPolicy { - /** - * A signed-in phone normally opens on its account's device list, because that - * is the connection it already has and the one most taps mean. But the entry - * labelled 「扫描二维码连接」 means the camera and nothing else — landing it on a - * device picker reads as the wrong screen rather than as a shortcut, and the - * scanner it promised is then two taps further in. - */ - static initialStep( - openIntent: string, - accountAuthenticated: boolean, - remoteUrl: string, - currentStep: string - ): string { - if (openIntent === CONNECT_INTENT_SCAN) { - return 'scan'; - } - if (accountAuthenticated) { - return 'account'; - } - return remoteUrl.trim().length === 0 ? 'scan' : currentStep; - } - - /** - * Back retraces the way in. A sheet opened straight onto the scanner has no - * step behind it, so it leaves rather than reveals a picker the user never - * passed through. - */ - static backStaysInSheet( - openIntent: string, - currentStep: string, - remoteUrl: string, - showManualPairing: boolean - ): boolean { - return openIntent !== CONNECT_INTENT_SCAN && - currentStep === 'scan' && - remoteUrl.trim().length === 0 && - !showManualPairing; - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConnectSheetLandingPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConnectSheetLandingPolicy.ets new file mode 100644 index 000000000..6d90249bd --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConnectSheetLandingPolicy.ets @@ -0,0 +1,80 @@ +import { CONNECT_INTENT_SCAN } from '../state/AppShellState'; + +export class ConnectSheetStep { + static readonly Intro: string = 'intro'; + static readonly Scan: string = 'scan'; + static readonly Account: string = 'account'; +} + +/** + * Where the connect sheet should open, and which step it may keep showing. + * + * The camera is a named action, not the default: an unsigned-in first open + * has to explain that the pair code comes from the desktop. A signed-in phone + * already has an account device list. Only an entry that promised the scanner + * may land on it. + */ +export class ConnectSheetLandingPolicy { + static initialStep(openIntent: string, accountAuthenticated: boolean): string { + if (openIntent === CONNECT_INTENT_SCAN) { + return ConnectSheetStep.Scan; + } + if (accountAuthenticated) { + return ConnectSheetStep.Account; + } + return ConnectSheetStep.Intro; + } + + static visibleStep(pairingStep: string, accountAuthenticated: boolean): string { + if (pairingStep === ConnectSheetStep.Account && accountAuthenticated) { + return ConnectSheetStep.Account; + } + if (pairingStep === ConnectSheetStep.Scan) { + return ConnectSheetStep.Scan; + } + return ConnectSheetStep.Intro; + } +} + +export class ConnectSheetStatusPolicy { + static isConnectError( + connectionState: string, + isBusy: boolean, + isConnected: boolean, + statusText: string, + failedLabel: string, + missingParams: string, + urlRequired: string + ): boolean { + if (connectionState === 'failed') { + return true; + } + if (isBusy || isConnected) { + return false; + } + const text = statusText.toLowerCase(); + return text.indexOf('http') >= 0 || + text.indexOf('error') >= 0 || + text.indexOf('failed') >= 0 || + text.indexOf(failedLabel.toLowerCase()) >= 0 || + statusText === missingParams || + statusText === urlRequired || + text.indexOf('rejected') >= 0; + } + + static failureHintKey(kind: string): string { + if (kind === 'expired_room') { + return 'connect.hintExpiredRoom'; + } + if (kind === 'network') { + return 'connect.hintNetwork'; + } + if (kind === 'protected_user') { + return 'connect.hintProtectedUser'; + } + if (kind === 'invalid_link') { + return 'connect.hintInvalidLink'; + } + return ''; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationHeaderPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationHeaderPolicy.ets new file mode 100644 index 000000000..4531abc17 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationHeaderPolicy.ets @@ -0,0 +1,68 @@ +import { ChatSurface } from '../state/ChatSurface'; + +export class ConversationHeaderPresentation { + title: string = ''; + subtitle: string = ''; + fallbackKey: string = 'app.title'; + allowRename: boolean = false; + showActions: boolean = false; + + constructor( + title: string, + subtitle: string, + fallbackKey: string, + allowRename: boolean, + showActions: boolean + ) { + this.title = title; + this.subtitle = subtitle; + this.fallbackKey = fallbackKey; + this.allowRename = allowRename; + this.showActions = showActions; + } +} + +/** + * One header contract for local chat, remote chat, create, and remote home. + * View supplies i18n for fallbackKey; this policy only decides structure. + */ +export class ConversationHeaderPolicy { + static readonly REMOTE_BRAND: string = 'BitFun'; + + static present( + surface: ChatSurface, + sessionTitle: string, + desktopName: string, + workspaceBranch: string, + hasTimeline: boolean + ): ConversationHeaderPresentation { + if (surface === ChatSurface.Create) { + return new ConversationHeaderPresentation('', '', 'remote.create.title', false, false); + } + if (surface === ChatSurface.Remote) { + return new ConversationHeaderPresentation( + sessionTitle, + ConversationHeaderPolicy.remoteSubtitle(desktopName, workspaceBranch), + 'chat.remoteSession', + true, + true + ); + } + return new ConversationHeaderPresentation( + sessionTitle, + '', + 'app.title', + false, + hasTimeline + ); + } + + static remoteSubtitle(desktopName: string, workspaceBranch: string): string { + if (desktopName.length > 0) { + return desktopName; + } + return workspaceBranch.length > 0 ? + `${ConversationHeaderPolicy.REMOTE_BRAND} · ${workspaceBranch}` : + ConversationHeaderPolicy.REMOTE_BRAND; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets index f6a011f6d..e01b5e890 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets @@ -59,7 +59,8 @@ export class ConversationLayoutPolicy { mediaQueryMatched: boolean, isFolded: boolean, deviceType: string, - creases: ConversationLayoutCrease[] + creases: ConversationLayoutCrease[], + isExpandedFoldable: boolean = false ): boolean { if (isFolded) { return false; @@ -69,7 +70,10 @@ export class ConversationLayoutPolicy { } const visibleCreases = ConversationLayoutPolicy.visibleCreases(viewportWidth, creases); if (visibleCreases.length > 0) { - return visibleCreases.length >= 2; + return ConversationLayoutPolicy.canFitMasterDetail(viewportWidth, visibleCreases); + } + if (isExpandedFoldable) { + return ConversationLayoutPolicy.canFitMasterDetail(viewportWidth, []); } return ConversationLayoutPolicy.isTabletDevice(deviceType); } @@ -79,10 +83,7 @@ export class ConversationLayoutPolicy { creases: ConversationLayoutCrease[] ): ConversationLayoutGeometry { const visibleCreases = ConversationLayoutPolicy.visibleCreases(viewportWidth, creases); - const firstCrease = visibleCreases.find((crease: ConversationLayoutCrease): boolean => { - return crease.left >= ConversationLayoutPolicy.MIN_MASTER_PANE_WIDTH && - crease.left + crease.width <= viewportWidth - ConversationLayoutPolicy.MIN_DETAIL_PANE_WIDTH; - }); + const firstCrease = ConversationLayoutPolicy.firstUsableCrease(viewportWidth, visibleCreases); const masterPaneWidth = firstCrease ? firstCrease.left : ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH; const masterDetailGap = firstCrease ? Math.max(0, firstCrease.width) : 0; @@ -135,6 +136,28 @@ export class ConversationLayoutPolicy { }, undefined); } + private static canFitMasterDetail( + viewportWidth: number, + visibleCreases: ConversationLayoutCrease[] + ): boolean { + const firstCrease = ConversationLayoutPolicy.firstUsableCrease(viewportWidth, visibleCreases); + const masterPaneWidth = firstCrease ? firstCrease.left : + ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH; + const masterDetailGap = firstCrease ? Math.max(0, firstCrease.width) : 0; + return masterPaneWidth >= ConversationLayoutPolicy.MIN_MASTER_PANE_WIDTH && + viewportWidth - masterPaneWidth - masterDetailGap >= ConversationLayoutPolicy.MIN_DETAIL_PANE_WIDTH; + } + + private static firstUsableCrease( + viewportWidth: number, + visibleCreases: ConversationLayoutCrease[] + ): ConversationLayoutCrease | undefined { + return visibleCreases.find((crease: ConversationLayoutCrease): boolean => { + return crease.left >= ConversationLayoutPolicy.MIN_MASTER_PANE_WIDTH && + crease.left + crease.width <= viewportWidth - ConversationLayoutPolicy.MIN_DETAIL_PANE_WIDTH; + }); + } + private static hasWideViewport(viewportWidth: number, mediaQueryMatched: boolean): boolean { return mediaQueryMatched || viewportWidth >= ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets index 9d60fafb3..d99c447ef 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets @@ -1,7 +1,7 @@ import { ConversationUiModel, ConversationUiModelCatalog -} from '../components/ConversationUiModels'; +} from '../state/ConversationUiModels'; export class ConversationModelPresentationPolicy { static enabledModels(catalog: ConversationUiModelCatalog): ConversationUiModel[] { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets index c7da144bd..1c48faac0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets @@ -76,6 +76,10 @@ export class FilePreviewPlacementPolicy { if (creaseLayout) { return creaseLayout; } + const creaseFocusLayout = FilePreviewPlacementPolicy.creaseAlignedFocusSplit(viewportWidth, creases); + if (creaseFocusLayout) { + return creaseFocusLayout; + } const flatLayout = FilePreviewPlacementPolicy.flatTriplePane( viewportWidth, creases, @@ -144,6 +148,31 @@ export class FilePreviewPlacementPolicy { ); } + private static creaseAlignedFocusSplit( + viewportWidth: number, + creases: ConversationLayoutCrease[] + ): FilePreviewLayout | undefined { + const visible = FilePreviewPlacementPolicy.visibleCreases(viewportWidth, creases); + if (visible.length !== 1) { + return undefined; + } + const crease = visible[0]; + const conversationWidth = crease.left; + const previewWidth = viewportWidth - crease.left - crease.width; + if (conversationWidth < FilePreviewPlacementPolicy.MIN_CONVERSATION_WIDTH || + previewWidth < FilePreviewPlacementPolicy.MIN_PREVIEW_WIDTH) { + return undefined; + } + return new FilePreviewLayout( + FilePreviewPlacement.WideFocusSplit, + 0, + 0, + conversationWidth, + crease.width, + previewWidth + ); + } + private static creaseAlignedTriplePane( viewportWidth: number, creases: ConversationLayoutCrease[] diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/RemoteCompactHomePolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/RemoteCompactHomePolicy.ets new file mode 100644 index 000000000..90512a27c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/RemoteCompactHomePolicy.ets @@ -0,0 +1,19 @@ +/** + * Compact Remote Home is conversation chrome, not a session list. + * + * The sidebar may keep a cached list after the desktop drops so the user can + * still see what the phone already knows. Compact home must not treat that + * cache as a live link: "pick a session" only belongs on a connected desktop. + */ +export class RemoteCompactHomePolicy { + static shouldShowConnectHome(connectionState: string): boolean { + return connectionState !== 'connected'; + } + + static headerSubtitle(connectionState: string, desktopName: string): string { + if (RemoteCompactHomePolicy.shouldShowConnectHome(connectionState)) { + return ''; + } + return desktopName; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/RemoteSurfaceEntryPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/RemoteSurfaceEntryPolicy.ets new file mode 100644 index 000000000..36c4cfc30 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/RemoteSurfaceEntryPolicy.ets @@ -0,0 +1,16 @@ +export class RemoteConnectEntryKind { + static readonly EnterSurface: string = 'enter_surface'; + static readonly OpenConnect: string = 'open_connect'; + static readonly OpenScan: string = 'open_scan'; +} + +/** + * Switching to Remote is a change of context. Opening the connect sheet is a + * command to pair or pick a desktop. Those two must not share an entry point. + */ +export class RemoteSurfaceEntryPolicy { + static shouldOpenConnectSheet(kind: string): boolean { + return kind === RemoteConnectEntryKind.OpenConnect || + kind === RemoteConnectEntryKind.OpenScan; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ThinkingPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ThinkingPresentationPolicy.ets new file mode 100644 index 000000000..bba7401fc --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ThinkingPresentationPolicy.ets @@ -0,0 +1,17 @@ +export class ThinkingPresentationPolicy { + static isRunning(status: string): boolean { + const normalized = (status || '').toLowerCase(); + return normalized === 'active' || normalized === 'running'; + } + + static shouldRender(status: string, text: string): boolean { + if (ThinkingPresentationPolicy.isRunning(status)) { + return true; + } + return text.trim().length > 0; + } + + static defaultExpanded(isRunning: boolean, keepExpandedWhenDone: boolean): boolean { + return isRunning || keepExpandedWhenDone; + } +} 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 500169c05..42f4b5649 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 @@ -216,19 +216,6 @@ export class AppRootRuntime extends AppRootRuntimeComposition { }); } - enterCodeEntry(): void { - if (this.settingsController.hasCloudAccountSession() && this.remotePageState.accountUserId.trim().length > 0) { - this.appShellState.setConnectSheetVisible(true); - return; - } - if (RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState))) { - this.appShellState.setConnectSheetVisible(false); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - return; - } - this.appShellState.setConnectSheetVisible(true); - } - async switchWideConversationSource(source: ConversationSource): Promise { if (AppRouteContract.conversationSource(this.appShellViewModel.currentRoute()) === source) { return; @@ -329,7 +316,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { openAddConnectionFromSettings(): void { this.appShellState.setSettingsVisible(false); setTimeout(() => { - this.appShellState.setConnectSheetVisible(true, CONNECT_INTENT_SCAN); + this.openConnectSheet(CONNECT_INTENT_SCAN); }, 220); } 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 8272811c5..7455ff4a6 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 @@ -41,6 +41,7 @@ import { ConversationController } from '../viewmodel/ConversationController'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteModelController } from '../../services/RemoteModelController'; import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; +import { DetectedUrlAction } from '../../services/ConnectScanDecisionPolicy'; import { RemoteSessionController } from '../../services/RemoteSessionController'; import { RemoteSessionListCache } from '../../services/RemoteSessionListCache'; import { RemoteSessionListRdbStore } from '../../services/RemoteSessionListRdbStore'; @@ -63,7 +64,11 @@ import { AppRouteContract, ConversationSource } from '../navigation/AppRouteContract'; -import { AppShellState } from '../state/AppShellState'; +import { AppShellState, CONNECT_INTENT_AUTO, CONNECT_INTENT_SCAN } from '../state/AppShellState'; +import { + RemoteConnectEntryKind, + RemoteSurfaceEntryPolicy +} from '../policy/RemoteSurfaceEntryPolicy'; import { AppShellViewModel } from '../viewmodel/AppShellViewModel'; import { RemoteActivityViewModel } from '../viewmodel/RemoteActivityViewModel'; import { @@ -112,7 +117,6 @@ export abstract class AppRootRuntimeComposition { abstract connect(autoReconnect?: boolean, accountPassword?: string): Promise; abstract currentActiveTurnId(): string; abstract disconnect(clearPairing: boolean): Promise; - abstract enterCodeEntry(): void; abstract enterCompactLayout(): void; abstract exitActiveChat(): void; abstract failRemoteConnection(err: Object): void; @@ -655,8 +659,8 @@ export abstract class AppRootRuntimeComposition { }, this.clearCachedRemoteData, (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), - (): void => this.appShellState.setConnectSheetVisible(false), - (): void => this.appShellState.setConnectSheetVisible(true) + (): void => this.dismissConnectSheetAfterSuccess(), + (): void => this.openConnectSheet() ); readonly settingsController: SettingsController = new SettingsController( @@ -685,7 +689,7 @@ export abstract class AppRootRuntimeComposition { resetKnownRemoteState: (): void => this.conversationController.resetKnownRemoteState(), clearCachedRemoteData: this.clearCachedRemoteData, closeSettings: (): void => this.appShellState.setSettingsVisible(false), - closeConnectSheet: (): void => this.appShellState.setConnectSheetVisible(false), + closeConnectSheet: (): void => this.dismissConnectSheetAfterSuccess(), navigateRemoteHome: (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), loadRecentWorkspaces: async (): Promise => { await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); @@ -793,8 +797,8 @@ export abstract class AppRootRuntimeComposition { onLayoutModeChanged: (wideLayout: boolean): void => this.appShellState.setWideLayout(wideLayout), onRemoteHome: { openSidebar: (): void => this.openAppSidebar(), - connectWorkspace: (): void => this.enterCodeEntry(), - addConnection: (): void => this.appShellState.setConnectSheetVisible(true), + connectWorkspace: (): void => this.openConnectSheet(), + addConnection: (): void => this.openConnectSheet(), openSettings: (): void => this.openRemoteControlSettings(), refresh: (): void => { this.remoteSessionViewModel.refreshSessions(); }, showWorkspaces: (): void => { this.remoteWorkspaceViewModel.toggleRecentWorkspaces(); }, @@ -866,6 +870,8 @@ export abstract class AppRootRuntimeComposition { this.settingsController.loginCloudAccount(relayUrl, username, password), cloudLogout: (): Promise => this.settingsController.logoutCloudAccount(), cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), + cloudSelectDevice: (device: CloudAccountDevice): Promise => + this.settingsController.selectCloudAccountDevice(device), getPermissionMode: (): Promise => this.settingsController.getRemotePermissionMode(), setPermissionMode: (mode: RemotePermissionMode): Promise => this.settingsController.setRemotePermissionMode(mode), @@ -877,18 +883,15 @@ export abstract class AppRootRuntimeComposition { onConnect: { back: (): void => this.appShellState.setConnectSheetVisible(false), connect: (password?: string): void => { - // Keep connection progress on the same RemoteHome surface as the connected state. - this.appShellState.setConnectSheetVisible(false); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); this.connect(false, password || ''); }, clearPairing: (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, urlChanged: (url: string): void => { this.remotePageState.setRemoteUrl(url); this.remoteConnectionController.projectRemoteUrl(url); }, userChanged: (user: string): void => this.remotePageState.setUserId(user), - detected: (url: string): boolean => this.remoteConnectionController.handleDetectedUrl(url), + detected: (url: string): string => this.handleDetectedRemoteUrl(url), inputVisible: (visible: boolean): void => this.remotePageState.setRemoteUrlInputVisible(visible), paste: (): void => { this.remoteConnectionController.paste(); }, - scan: (): void => { this.remoteConnectionController.scan(this.host.context()); }, + scan: (): void => { void this.scanRemotePairCode(); }, cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), cloudSelectDevice: (device: CloudAccountDevice): Promise => this.settingsController.selectCloudAccountDevice(device) @@ -903,5 +906,66 @@ export abstract class AppRootRuntimeComposition { }; readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; + enterCodeEntry(): void { + this.enterRemoteSurface(); + } + + enterRemoteSurface(): void { + this.appShellState.setConnectSheetVisible(false); + if (this.appShellState.wideLayout) { + void this.switchWideConversationSource(ConversationSource.Remote); + return; + } + void this.switchCompactConversationSource(ConversationSource.Remote); + } + openConnectSheet(intent: string = CONNECT_INTENT_AUTO): void { + const kind = intent === CONNECT_INTENT_SCAN ? + RemoteConnectEntryKind.OpenScan : RemoteConnectEntryKind.OpenConnect; + if (!RemoteSurfaceEntryPolicy.shouldOpenConnectSheet(kind)) { + this.enterRemoteSurface(); + return; + } + this.appShellState.setConnectSheetVisible(true, intent); + } + + /** + * Close the sheet only after a connect that the user is watching on it. + * Heartbeat reconnects and background restores must not steal the current + * route just because they also go through the same success path. + */ + dismissConnectSheetAfterSuccess(): void { + const opened = this.appShellState.showConnectSheet; + this.appShellState.setConnectSheetVisible(false); + if (opened) { + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + } + } + + handleDetectedRemoteUrl(url: string): string { + const result = this.remoteConnectionController.handleDetectedUrl( + url, + this.settingsController.hasCloudAccountSession(), + this.remotePageState.accountUsername + ); + if (result.action === DetectedUrlAction.USE_CLOUD_DEVICE) { + void this.settingsController.restoreCloudTarget( + result.cloudDeviceId, + this.remotePageState.desktopName, + true + ); + } + return result.action; + } + + async scanRemotePairCode(): Promise { + const text = await this.remoteConnectionController.scan(this.host.context()); + if (text.length === 0) { + return; + } + const action = this.handleDetectedRemoteUrl(text); + if (action === DetectedUrlAction.PAIR_NOW) { + void this.connect(false, ''); + } + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatComposerCapabilities.ets similarity index 91% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatComposerCapabilities.ets index b2f27d048..0da62826a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatComposerCapabilities.ets @@ -30,10 +30,10 @@ export class ChatComposerCapabilities { } export const GENERAL_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities = - new ChatComposerCapabilities(ChatSurface.General, false, false); + new ChatComposerCapabilities(ChatSurface.General, true, false); export const REMOTE_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities = new ChatComposerCapabilities(ChatSurface.Remote, true, true, true, true, true); export const REMOTE_CREATE_COMPOSER_CAPABILITIES: ChatComposerCapabilities = - new ChatComposerCapabilities(ChatSurface.Remote, false, true, false, true); + new ChatComposerCapabilities(ChatSurface.Create, false, true, false, true); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatSurface.ets similarity index 55% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatSurface.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatSurface.ets index 284d69a2a..4a3940af2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ChatSurface.ets @@ -1,4 +1,5 @@ export enum ChatSurface { General = 'general', - Remote = 'remote' + Remote = 'remote', + Create = 'create' } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationUiModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationUiModels.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets index fc137e964..2985d4245 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets @@ -6,9 +6,10 @@ import { RemoteUiState } from '../../services/RemoteUiState'; import { ChatComposerCapabilities, GENERAL_CHAT_COMPOSER_CAPABILITIES, - REMOTE_CHAT_COMPOSER_CAPABILITIES -} from '../components/ChatComposerCapabilities'; -import { ChatSurface } from '../components/ChatSurface'; + REMOTE_CHAT_COMPOSER_CAPABILITIES, + REMOTE_CREATE_COMPOSER_CAPABILITIES +} from './ChatComposerCapabilities'; +import { ChatSurface } from './ChatSurface'; import { ConversationUiModelCatalog, ConversationUiSelectedImage, @@ -16,10 +17,11 @@ import { toConversationUiModelCatalog, toConversationUiSelectedImage, toConversationUiSession -} from '../components/ConversationUiModels'; +} from './ConversationUiModels'; import { AppRoute } from '../navigation/AppRouteContract'; import { ConversationCoreState } from './ConversationCoreState'; import { GeneralChatPageState } from './GeneralChatPageState'; +import { RemoteCreateSessionState } from './RemoteCreateSessionState'; import { RemotePageState } from './RemotePageState'; /** Immutable presentation state consumed by ConversationView. */ @@ -38,10 +40,7 @@ export class ConversationViewState { hasMoreMessages: boolean = false; timelineItems: ChatTimelineItem[] = []; timelineRevision: number = 0; - showSuggestionsWhenEmpty: boolean = true; - supportsSearch: boolean = false; - supportsImages: boolean = false; - supportsFiles: boolean = false; + showSuggestionsWhenEmpty: boolean = false; modelCatalog: ConversationUiModelCatalog = toConversationUiModelCatalog(RemoteUiState.emptyModelCatalog()); selectedModelId: string = ''; isSessionPinned: boolean = false; @@ -56,14 +55,34 @@ export class ConversationViewState { route: AppRoute, remote: RemotePageState, general: GeneralChatPageState, - generalInlineStatus: string + generalInlineStatus: string, + create: RemoteCreateSessionState = new RemoteCreateSessionState() ): ConversationViewState { + if (route === AppRoute.RemoteCreate) { + return ConversationViewState.create(remote, create); + } if (route === AppRoute.RemoteChat) { return ConversationViewState.remote(remote); } return ConversationViewState.general(general, generalInlineStatus); } + private static create(remote: RemotePageState, create: RemoteCreateSessionState): ConversationViewState { + const state = new ConversationViewState(); + state.surface = ChatSurface.Create; + state.desktopName = remote.desktopName; + state.workspaceBranch = remote.workspaceBranch; + state.connectionState = 'connected'; + state.composerCapabilities = REMOTE_CREATE_COMPOSER_CAPABILITIES; + state.chatInput = create.draft; + state.isBusy = create.isSubmitting; + state.inlineStatusText = create.errorText; + state.isVoiceListening = create.isVoiceListening; + state.selectedModelId = create.selectedModelId; + state.modelCatalog = toConversationUiModelCatalog(remote.conversation.modelCatalog); + return state; + } + private static remote(remote: RemotePageState): ConversationViewState { const state = ConversationViewState.fromCore(remote.conversation); state.surface = ChatSurface.Remote; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/WatchProvisionState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/WatchProvisionState.ets index cb6fe2beb..d2ddfcfd0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/WatchProvisionState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/WatchProvisionState.ets @@ -1,4 +1,5 @@ import { WatchProvisionProtocol } from '../../services/WatchProvisionProtocol'; +import { WatchProvisionDisplay } from '../../services/WatchProvisionDisplay'; export enum WatchProvisionPhase { /** No watch is waiting; the card is not on screen. */ @@ -12,7 +13,7 @@ export enum WatchProvisionPhase { } @ObservedV2 -export class WatchProvisionState { +export class WatchProvisionState implements WatchProvisionDisplay { @Trace phase: WatchProvisionPhase = WatchProvisionPhase.Hidden; @Trace deviceName: string = ''; @Trace deviceIdLabel: string = ''; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets index 74b5ef25b..c877a88fc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -1,101 +1,43 @@ import { - RecentWorkspaceEntry, - RemoteImageContext, RemoteQuestionAnswerPayload, RemoteSession, - SessionSummary, - SelectedImageAttachment + SelectedImageAttachment, + SessionSummary } from '../../model/RemoteModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; -import { ChatTimelineState, ChatTimelineStore } from '../../services/ChatTimelineStore'; -import { ClipboardService } from '../../services/ClipboardService'; -import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; -import { ImagePickerService } from '../../services/ImagePickerService'; -import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; -import { GeneralChatConversationViewModel } from './GeneralChatConversationViewModel'; -import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; -import { - GeneralChatServiceState, - GeneralChatServiceStatus -} from '../../services/general-chat/GeneralChatServiceState'; -import { RemoteChatCache } from '../../services/RemoteChatCache'; -import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; -import { - RemoteChatPollingCursor, - RemoteChatPollingLifecycleController, - RemoteChatPollingSnapshot -} from '../../services/RemoteChatPollingLifecycleController'; -import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { RemoteLogger } from '../../services/RemoteLogger'; -import { RemoteModelController } from '../../services/RemoteModelController'; -import { RemoteToolActionController } from '../../services/RemoteToolActionController'; -import { RemoteUiState } from '../../services/RemoteUiState'; -import { RemoteSessionManager } from '../../services/RemoteSessionManager'; -import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; import { VoiceInputRouteSnapshot } from '../../services/VoiceInputLifecycleController'; import { AppRootRouteState } from '../navigation/AppRootRouteState'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; import { RemotePageState } from '../state/RemotePageState'; -import { ConversationViewModel } from './ConversationViewModel'; -import { AppShellViewModel } from './AppShellViewModel'; -import { FilePreviewController } from './FilePreviewController'; -import { RemoteConnectionController } from './RemoteConnectionController'; -import { RemoteSessionViewModel } from './RemoteSessionViewModel'; -import { SettingsController } from './SettingsController'; -const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; - -export interface ConversationControllerHooks { - readonly currentRoute: () => AppRoute; -} - -export interface RemoteConversationHooks { - readonly isConversationContext: (sessionId: string) => boolean; - readonly isFilePreviewVisible: () => boolean; - readonly stopVoiceInput: () => Promise; - readonly showToast: (message: string) => boolean; - readonly selectAssistantWorkspace: (path: string) => Promise; -} - -export interface RemoteConversationDependencies { - readonly timeline: ConversationViewModel; - readonly chat: RemoteChatCommandController; - readonly chatCache: RemoteChatCache; - readonly polling: RemoteChatPollingLifecycleController; - readonly models: RemoteModelController; - readonly files: RemoteFileDownloadController; - readonly tools: RemoteToolActionController; - readonly connection: RemoteConnectionController; - readonly imagePicker: ImagePickerService; - readonly clipboard: ClipboardService; - readonly sessions: RemoteSessionViewModel; - readonly sessionManager: RemoteSessionManager; - readonly workspace: RemoteWorkspaceCoordinator; - readonly settings: SettingsController; - readonly appShell: AppShellViewModel; - readonly filePreview: FilePreviewController; - readonly generalCommands: GeneralChatCommandController; - readonly generalConversation: GeneralChatConversationViewModel; - readonly generalDrafts: GeneralChatDraftLifecycleController; - readonly hooks: RemoteConversationHooks; -} - -/** Owns route-dependent composer and voice presentation state. */ +import { RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; +import { + ConversationControllerHooks, + RemoteConversationDependencies, + RemoteConversationHooks +} from './ConversationRuntime'; +import { RemoteCreateFlowController } from './RemoteCreateFlowController'; +import { RemoteTranscriptController } from './RemoteTranscriptController'; +import { VisibleConversationController } from './VisibleConversationController'; + +export { + ConversationControllerHooks, + RemoteConversationDependencies, + RemoteConversationHooks +} from './ConversationRuntime'; + +/** Owns route-dependent composer state and delegates feature work. */ export class ConversationController { private readonly general: GeneralChatPageState; private readonly remote: RemotePageState; private readonly remoteCreate: RemoteCreateSessionState; private readonly hooks: ConversationControllerHooks; private readonly remoteRuntime?: RemoteConversationDependencies; - private knownPollVersionValue: number = 0; - private knownModelCatalogVersion: number = 0; - private knownRemoteMessageCount: number = 0; - private isSyncingAfterTurn: boolean = false; - private isRebuildingRemoteTranscript: boolean = false; - private remoteCreateWorkspaceLoadVersion: number = 0; + private readonly transcript: RemoteTranscriptController; + private readonly createFlow: RemoteCreateFlowController; + private readonly visible: VisibleConversationController; constructor( general: GeneralChatPageState, @@ -109,6 +51,21 @@ export class ConversationController { this.remoteCreate = remoteCreate; this.hooks = hooks; this.remoteRuntime = remoteRuntime; + this.transcript = new RemoteTranscriptController(remote, remoteRuntime, (message: string) => { + this.showHomeToast(message); + }); + this.createFlow = new RemoteCreateFlowController(remote, remoteCreate, remoteRuntime); + this.visible = new VisibleConversationController( + general, + remote, + remoteRuntime, + this.transcript, + this.createFlow, + (route: AppRoute, value: string) => this.setChatInput(route, value), + (statusText: string) => this.setVisibleStatusText(statusText), + (route: AppRoute) => this.isGeneralComposerRoute(route), + (message: string) => this.showHomeToast(message) + ); } visibleChatInput(): string { @@ -192,902 +149,108 @@ export class ConversationController { return AppRouteContract.isGeneralComposerRoute(route); } - knownPollVersion(): number { - return this.knownPollVersionValue; - } - - resetKnownRemoteState(): void { - this.knownPollVersionValue = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - } - + knownPollVersion(): number { return this.transcript.knownPollVersion(); } + resetKnownRemoteState(): void { this.transcript.resetKnownRemoteState(); } updateKnownMessageCount(pollVersion: number, knownMessageCount: number): void { - this.knownRemoteMessageCount = knownMessageCount; - this.updateChatPollingCursor(pollVersion, knownMessageCount); + this.transcript.updateKnownMessageCount(pollVersion, knownMessageCount); } - updateKnownModelCatalogVersion(version: number): void { - this.knownModelCatalogVersion = version; - this.requireRemoteRuntime().polling.updateKnownModelCatalogVersion(version); - } - - async loadRemoteMessages(): Promise { - const runtime = this.requireRemoteRuntime(); - await runtime.chat.loadMessages( - this.remote.activeSession.sessionId || '', - runtime.hooks.isConversationContext - ); - } - - async selectRemoteModel(modelId: string): Promise { - const runtime = this.requireRemoteRuntime(); - await runtime.models.selectModel( - modelId, - this.remote.activeSession.sessionId || '', - this.remote.isBusy, - runtime.connection.ensureAvailable() - ); - } - - async loadOlderRemoteMessages(): Promise { - const runtime = this.requireRemoteRuntime(); - await runtime.chat.loadOlderMessages( - this.remote.activeSession.sessionId || '', - this.knownPollVersionValue, - this.remote.hasMoreMessages, - this.remote.isBusy - ); - } - - async sendRemoteMessage(): Promise { - const runtime = this.requireRemoteRuntime(); - if (this.remote.isVoiceListening) { - await runtime.hooks.stopVoiceInput(); - } - const rawText = this.remote.chatInput.trim(); - const images = this.remote.selectedImages.slice(); - const text = rawText.length > 0 ? rawText : - (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - const sessionId = this.remote.activeSession.sessionId || ''; - if ((!text && images.length === 0) || !sessionId || this.remote.isBusy || - !runtime.connection.ensureAvailable()) { - return; - } - // Sending into a turn that is still running is allowed, but the desktop - // queues it rather than interrupting, so say so once — otherwise the - // message just sits in the transcript with nothing appearing to happen. - const queuedBehindRunningTurn = this.hasRunningRemoteTurn(); - this.remote.clearComposer(); - const localMessage = RemoteUiState.localUserMessage(text, images); - runtime.timeline.appendOptimisticMessage(localMessage); - const pendingActiveId = runtime.timeline.setPendingActiveTurn(localMessage.id); - this.syncRemoteTimeline(); - RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId} behindRunningTurn=${queuedBehindRunningTurn ? '1' : '0'}`); - if (queuedBehindRunningTurn) { - this.showHomeToast(RemoteI18n.t('chat.queuedAfterRunningTurn')); - } - this.startRemotePolling(); - runtime.polling.nudge(); - const imageContexts: RemoteImageContext[] = images.length > 0 ? - runtime.imagePicker.toRemoteContexts(images) : []; - await runtime.chat.sendPreparedMessage( - sessionId, - text, - this.remote.activeSession.agentType, - rawText, - images, - imageContexts, - localMessage.id, - pendingActiveId, - this.remote.isBusy, - true - ); - } - - async stopRemoteTask(): Promise { - const runtime = this.requireRemoteRuntime(); - const sessionId = this.remote.activeSession.sessionId || ''; - if (!sessionId) { - return; - } - await runtime.chat.stopTask( - sessionId, - this.remote.activeTurnMessage.id, - this.remoteActiveTurnId(), - runtime.connection.ensureAvailable() - ); - } - - async renameRemoteSession(title: string): Promise { - const runtime = this.requireRemoteRuntime(); - const nextTitle = title.trim(); - if (!this.remote.activeSession.sessionId || nextTitle.length === 0 || - nextTitle === this.remote.activeSession.title || this.remote.isBusy) { - return; - } - await runtime.chat.renameActiveSession( - this.remote.activeSession, - nextTitle, - this.remote.isBusy, - runtime.connection.ensureAvailable() - ); - } - - async copyRemoteMessage(text: string): Promise { - if (text.trim().length === 0) { - return; - } - try { - await this.requireRemoteRuntime().clipboard.writeText(text); - this.remote.setStatusText(RemoteI18n.t('status.messageCopied')); - } catch (err) { - this.remote.setStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - async downloadRemoteFile(path: string): Promise { - const runtime = this.requireRemoteRuntime(); - await runtime.files.download( - path, - this.remote.activeSession.sessionId || '', - this.remote.isBusy, - runtime.connection.ensureAvailable() - ); - } - - retryRemoteMessage(text: string): void { - if (this.remote.isBusy || !this.requireRemoteRuntime().connection.ensureAvailable()) { - return; - } - this.remote.setChatInput(text); - this.sendRemoteMessage(); - } - + this.transcript.updateKnownModelCatalogVersion(version); + } + async loadRemoteMessages(): Promise { await this.transcript.loadRemoteMessages(); } + async selectRemoteModel(modelId: string): Promise { await this.transcript.selectRemoteModel(modelId); } + async loadOlderRemoteMessages(): Promise { await this.transcript.loadOlderRemoteMessages(); } + async sendRemoteMessage(): Promise { await this.transcript.sendRemoteMessage(); } + async stopRemoteTask(): Promise { await this.transcript.stopRemoteTask(); } + async renameRemoteSession(title: string): Promise { await this.transcript.renameRemoteSession(title); } + async copyRemoteMessage(text: string): Promise { await this.transcript.copyRemoteMessage(text); } + async downloadRemoteFile(path: string): Promise { await this.transcript.downloadRemoteFile(path); } + retryRemoteMessage(text: string): void { this.transcript.retryRemoteMessage(text); } async approveRemoteTool(toolId: string, updatedInput?: Object): Promise { - const runtime = this.requireRemoteRuntime(); - await runtime.tools.approve( - toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), updatedInput - ); - } - - async rejectRemoteTool(toolId: string): Promise { - const runtime = this.requireRemoteRuntime(); - await runtime.tools.reject( - toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable() - ); + await this.transcript.approveRemoteTool(toolId, updatedInput); } - - async cancelRemoteTool(toolId: string): Promise { - const runtime = this.requireRemoteRuntime(); - await runtime.tools.cancel( - toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable() - ); - } - + async rejectRemoteTool(toolId: string): Promise { await this.transcript.rejectRemoteTool(toolId); } + async cancelRemoteTool(toolId: string): Promise { await this.transcript.cancelRemoteTool(toolId); } async answerRemoteQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { - const runtime = this.requireRemoteRuntime(); - await runtime.tools.answer( - toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), answers - ); - } - - resetRemoteTimeline(sessionId: string): void { - const runtime = this.requireRemoteRuntime(); - runtime.timeline.reset(sessionId); - this.knownPollVersionValue = 0; - this.syncRemoteTimeline(); - } - - syncRemoteTimeline(): void { - const runtime = this.requireRemoteRuntime(); - const state: ChatTimelineState = runtime.timeline.snapshotState(); - this.remote.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - this.remote.hasMoreMessages, - runtime.timeline.viewState(this.remote.hasMoreMessages) - ); - this.remote.setModelCatalog(state.modelCatalog, state.selectedModelId); - } - - startRemotePolling(): void { - this.requireRemoteRuntime().polling.startActiveSession({ - sessionId: this.remote.activeSession.sessionId || '', - cursor: this.currentChatPollingCursor(), - activeTurn: this.remote.activeTurnMessage - }); + await this.transcript.answerRemoteQuestion(toolId, answers); } - + resetRemoteTimeline(sessionId: string): void { this.transcript.resetRemoteTimeline(sessionId); } + syncRemoteTimeline(): void { this.transcript.syncRemoteTimeline(); } + startRemotePolling(): void { this.transcript.startRemotePolling(); } applyRemoteSnapshot(snapshot: RemoteChatPollingSnapshot): void { - const runtime = this.requireRemoteRuntime(); - if (!runtime.hooks.isConversationContext(snapshot.sessionId)) { - return; - } - if (snapshot.historyRewritten) { - this.rebuildRemoteTranscript(snapshot.sessionId); - return; - } - runtime.timeline.applySnapshot(snapshot); - this.syncRemoteTimeline(); - if (snapshot.newMessages.length > 0) { - this.cacheRemoteTranscript(snapshot.sessionId); - } - this.knownPollVersionValue = snapshot.cursor.pollVersion; - this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; - this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; - if (snapshot.title.length > 0) { - this.remote.setActiveSession({ - sessionId: this.remote.activeSession.sessionId, - title: snapshot.title, - workspacePath: this.remote.activeSession.workspacePath, - agentType: this.remote.activeSession.agentType - }); - } - if (snapshot.modelCatalog) { - runtime.models.applyCatalog(snapshot.modelCatalog); - } - this.remote.setStatusText(this.hasRunningRemoteTurn() - ? RemoteI18n.t('status.desktopProcessing') - : RemoteI18n.t('status.messagesSynced')); - if (snapshot.shouldSyncAfterTurnEnded) { - this.syncAfterRemoteTurnEnded(); - } - } - - /** - * Writes the transcript now on screen back to disk. - * - * Fire-and-forget on purpose: the snapshot is already rendered, and a cache - * that cannot be written only costs the next open a fetch it used to pay for - * anyway. - */ - private cacheRemoteTranscript(sessionId: string): void { - const runtime = this.requireRemoteRuntime(); - const state: ChatTimelineState = runtime.timeline.snapshotState(); - runtime.chatCache.sync(sessionId, state.persistedMessages); - } - - /** - * Refetches a transcript the desktop no longer agrees with. - * - * Tails are handed out by index, so once the desktop reports fewer messages - * than this session had counted there is no offset left that means the same - * thing on both ends. Everything stored for the session goes, including the - * poll cursor, which `reloadMessages` resets by way of `onMessageCountKnown`. - */ - private rebuildRemoteTranscript(sessionId: string): void { - const runtime = this.requireRemoteRuntime(); - if (this.isRebuildingRemoteTranscript) { - return; - } - this.isRebuildingRemoteTranscript = true; - RemoteLogger.info(`remote transcript rewritten upstream session=${this.shortSessionId(sessionId)}`); - runtime.chatCache.forget(sessionId) - .then((): Promise => runtime.chat.reloadMessages(sessionId, runtime.hooks.isConversationContext)) - .then((): void => { - this.isRebuildingRemoteTranscript = false; - }) - .catch((): void => { - this.isRebuildingRemoteTranscript = false; - }); - } - - hasRunningRemoteTurn(): boolean { - return this.remote.activeTurnMessage.id.length > 0 && - (this.remote.activeTurnMessage.status || '').toLowerCase() === 'active'; - } - - remoteActiveTurnId(): string { - return ChatTimelineStore.cancelableTurnId(this.remote.activeTurnMessage); - } - - projectedRemoteTimelineItems(): ChatTimelineItem[] { - return this.requireRemoteRuntime().timeline.viewState(this.remote.hasMoreMessages); + this.transcript.applyRemoteSnapshot(snapshot); } + hasRunningRemoteTurn(): boolean { return this.transcript.hasRunningRemoteTurn(); } + remoteActiveTurnId(): string { return this.transcript.remoteActiveTurnId(); } + projectedRemoteTimelineItems(): ChatTimelineItem[] { return this.transcript.projectedRemoteTimelineItems(); } async createRemoteSession(agentType: string, inPlace: boolean = false): Promise { - const runtime = this.requireRemoteRuntime(); - runtime.filePreview.close(); - await runtime.sessions.createSession( - agentType, - '', - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - openRemoteCreateSession(): void { - const runtime = this.requireRemoteRuntime(); - if (!runtime.connection.ensureAvailable()) { - return; - } - const deviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; - const deviceName = this.remote.controlTargetDeviceName || this.remote.desktopName; - this.remoteCreate.prepare(deviceId, deviceName, this.remote.selectedModelId); - if (deviceId.length > 0) { - this.remoteCreate.setDevices([{ - deviceId, - deviceName: deviceName || deviceId, - online: true - }]); - } - this.remoteCreate.setWorkspaces(this.remote.recentWorkspaces); - runtime.appShell.pushRoute(AppRoute.RemoteCreate); - this.loadRemoteCreateChoices(); - this.loadRemoteCreateModelCatalog(); - } - - closeRemoteCreateSession(): void { - const runtime = this.requireRemoteRuntime(); - this.remoteCreateWorkspaceLoadVersion += 1; - runtime.hooks.stopVoiceInput(); - this.remoteCreate.closeMenu(); - runtime.appShell.popRoute(AppRoute.RemoteHome); - } - - async loadRemoteCreateChoices(): Promise { - await Promise.all([ - this.loadRemoteCreateDevices(), - this.loadRemoteCreateWorkspaces() - ]); - } - - async loadRemoteCreateModelCatalog(): Promise { - const runtime = this.requireRemoteRuntime(); - if (this.remote.modelCatalog.models.length > 0) { - return; - } - try { - const catalog = await runtime.sessionManager.getModelCatalog(); - const selectedModelId = RemoteUiState.selectedModelIdForCatalog(catalog, this.remote.selectedModelId); - this.remote.setModelCatalog(catalog, selectedModelId); - this.remoteCreate.setSelectedModelId(selectedModelId); - } catch (_err) { - // Model selection remains hidden when the remote does not expose a catalog. - } - } - - async loadRemoteCreateDevices(): Promise { - const runtime = this.requireRemoteRuntime(); - this.remoteCreate.isLoadingDevices = this.remoteCreate.devices.length === 0; - try { - const phoneDeviceId = runtime.connection.getDeviceId(); - const accountDevices = await runtime.settings.listCloudAccountDevices(); - const devices = accountDevices.filter((device: CloudAccountDevice): boolean => - device.online && device.deviceId !== phoneDeviceId - ); - const currentId = this.remoteCreate.selectedDeviceId; - if (currentId.length > 0 && - !devices.some((device: CloudAccountDevice): boolean => device.deviceId === currentId)) { - devices.unshift({ - deviceId: currentId, - deviceName: this.remoteCreate.selectedDeviceName || currentId, - online: true - }); - } - this.remoteCreate.setDevices(devices); - } catch (_err) { - const currentId = this.remoteCreate.selectedDeviceId; - if (currentId.length > 0) { - this.remoteCreate.setDevices([{ - deviceId: currentId, - deviceName: this.remoteCreate.selectedDeviceName || currentId, - online: true - }]); - } else { - this.remoteCreate.setDevices([]); - } - this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceLoadFailed'); - } - } - - async loadRemoteCreateWorkspaces(): Promise { - const runtime = this.requireRemoteRuntime(); - const loadVersion = ++this.remoteCreateWorkspaceLoadVersion; - const deviceId = this.remoteCreate.selectedDeviceId; - this.remoteCreate.isLoadingWorkspaces = this.remoteCreate.workspaces.length === 0; - try { - const workspaces = await runtime.workspace.recentWorkspaces(); - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreate.selectedDeviceId) { - return; - } - this.remoteCreate.setWorkspaces(workspaces); - } catch (_err) { - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreate.selectedDeviceId) { - return; - } - this.remoteCreate.setWorkspaces([]); - this.remoteCreate.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); - } - } - - toggleRemoteCreateDevices(): void { - this.remoteCreate.toggleMenu('devices'); - if (this.remoteCreate.openMenu === 'devices' && this.remoteCreate.devices.length === 0) { - this.loadRemoteCreateDevices(); - } - } - - toggleRemoteCreateWorkspaces(): void { - this.remoteCreate.toggleMenu('workspaces'); - if (this.remoteCreate.openMenu === 'workspaces' && this.remoteCreate.workspaces.length === 0) { - this.loadRemoteCreateWorkspaces(); - } - } - + await this.createFlow.createRemoteSession(agentType, inPlace); + } + openRemoteCreateSession(): void { this.createFlow.openRemoteCreateSession(); } + closeRemoteCreateSession(): void { this.createFlow.closeRemoteCreateSession(); } + async loadRemoteCreateChoices(): Promise { await this.createFlow.loadRemoteCreateChoices(); } + async loadRemoteCreateModelCatalog(): Promise { await this.createFlow.loadRemoteCreateModelCatalog(); } + async loadRemoteCreateDevices(): Promise { await this.createFlow.loadRemoteCreateDevices(); } + async loadRemoteCreateWorkspaces(): Promise { await this.createFlow.loadRemoteCreateWorkspaces(); } + toggleRemoteCreateDevices(): void { this.createFlow.toggleRemoteCreateDevices(); } + toggleRemoteCreateWorkspaces(): void { this.createFlow.toggleRemoteCreateWorkspaces(); } async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { - const runtime = this.requireRemoteRuntime(); - if (device.deviceId === this.remoteCreate.selectedDeviceId) { - this.remoteCreate.closeMenu(); - return; - } - const draft = this.remoteCreate.draft; - this.remoteCreate.closeMenu(); - this.remoteCreate.isLoadingWorkspaces = true; - try { - await runtime.settings.selectCloudAccountDevice(device, false); - this.remoteCreate.selectDevice(device); - this.remoteCreate.setDraft(draft); - await this.loadRemoteCreateWorkspaces(); - } catch (err) { - this.remoteCreate.isLoadingWorkspaces = false; - this.remoteCreate.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceSwitchFailed'); - } - } - - selectRemoteCreateWorkspace(path: string): void { - const workspace = this.remoteCreate.workspaces - .find((item: RecentWorkspaceEntry): boolean => item.path === path); - this.remoteCreate.selectWorkspace(workspace); + await this.createFlow.selectRemoteCreateDevice(device); } - - async submitRemoteCreateSession(): Promise { - const runtime = this.requireRemoteRuntime(); - const instruction = this.remoteCreate.draft.trim(); - if (instruction.length === 0 || this.remoteCreate.isSubmitting || !runtime.connection.ensureAvailable()) { - return; - } - const context = this.remoteCreate.submissionContext(); - const activeDeviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; - if (context.deviceId.length === 0 || context.deviceId !== activeDeviceId) { - this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceMismatch'); - return; - } - this.remoteCreate.isSubmitting = true; - this.remoteCreate.errorText = ''; - this.remoteCreate.closeMenu(); - try { - if (context.workspacePath.length > 0) { - await runtime.sessions.createSessionInWorkspace( - context.workspacePath, - this.remote.workspacePath, - instruction, - context.agentType, - undefined, - this.remoteCreate.selectedModelId - ); - } else { - await this.bindAssistantWorkspace(); - await runtime.sessions.createSession( - context.agentType, - instruction, - undefined, - this.remoteCreate.selectedModelId - ); - } - if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { - this.remoteCreate.errorText = this.remote.statusText || RemoteI18n.t('remote.create.submitFailed'); - } - } catch (err) { - this.remoteCreate.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.create.submitFailed'); - } finally { - this.remoteCreate.isSubmitting = false; - } - } - - /** - * The chat option creates a Claw session, and the desktop always binds those - * to its assistant workspace. Follow it there first, otherwise the app stays - * bound to the code workspace it was on and the new chat is listed, titled - * and file-scoped as if it had been created inside that workspace. - */ - private async bindAssistantWorkspace(): Promise { - const runtime = this.requireRemoteRuntime(); - if (this.remote.workspaceKind === 'assistant') { - return; - } - try { - const assistants = await runtime.workspace.assistants(); - if (assistants.length === 0) { - return; - } - await runtime.hooks.selectAssistantWorkspace(assistants[0].path); - } catch (err) { - RemoteLogger.warn(`assistant workspace bind failed: ${String(err)}`); - } - } - + selectRemoteCreateWorkspace(path: string): void { this.createFlow.selectRemoteCreateWorkspace(path); } + async submitRemoteCreateSession(): Promise { await this.createFlow.submitRemoteCreateSession(); } async createRemoteSessionInWorkspace( path: string, agentType: string = 'code', inPlace: boolean = false ): Promise { - const runtime = this.requireRemoteRuntime(); - runtime.filePreview.close(); - await runtime.sessions.createSessionInWorkspace( - path, - this.remote.workspacePath, - '', - agentType, - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); + await this.createFlow.createRemoteSessionInWorkspace(path, agentType, inPlace); } - async openRemoteSession(item: RemoteSession, inPlace: boolean = false): Promise { - const runtime = this.requireRemoteRuntime(); - runtime.filePreview.close(); - await runtime.sessions.openSession( - item, - this.remote.workspacePath, - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); + await this.createFlow.openRemoteSession(item, inPlace); } - applyRemoteActiveSession(session: SessionSummary): void { - const runtime = this.requireRemoteRuntime(); - const current = this.remote.activeSession; - if (runtime.hooks.isFilePreviewVisible() && - (current.sessionId !== session.sessionId || current.workspacePath !== session.workspacePath)) { - runtime.filePreview.close(); - } - this.remote.setActiveSession(session); + this.createFlow.applyRemoteActiveSession(session); } - async deleteRemoteSession(item: RemoteSession): Promise { - await this.requireRemoteRuntime().sessions.deleteSession(item, this.remote.workspacePath); - } - - openHomeSession(session: RemoteSession, inPlace: boolean = false): void { - this.requireRemoteRuntime().filePreview.close(); - if (session.agentType === 'chat') { - this.openGeneralSession(session); - return; - } - this.openRemoteSession(session, inPlace); - } - - async deleteHomeSession(session: RemoteSession): Promise { - if (session.agentType !== 'chat') { - await this.deleteRemoteSession(session); - return; - } - await this.requireRemoteRuntime().generalCommands.deleteSession(session, this.general.isBusy); + await this.createFlow.deleteRemoteSession(item); } - - activeGeneralChatAsRemoteSession(): RemoteSession { - const active = this.general.activeSession; - return { - id: active.sessionId, - title: active.title, - agentType: 'chat', - status: 'ready', - updatedAt: '', - createdAt: '', - messageCount: this.general.timelineItems.length, - workspacePath: active.workspacePath - }; + routeCreatedRemoteSession(sessionId: string): void { + this.createFlow.routeCreatedRemoteSession(sessionId); } - activeGeneralUploadedFileCount(): number { - let count = 0; - this.general.timelineItems.forEach((item: ChatTimelineItem) => { - if (item.message && item.message.images) { - count += item.message.images.length; - } - }); - return count; + openHomeSession(session: RemoteSession, inPlace: boolean = false): void { + this.visible.openHomeSession(session, inPlace); } - + async deleteHomeSession(session: RemoteSession): Promise { await this.visible.deleteHomeSession(session); } + activeGeneralChatAsRemoteSession(): RemoteSession { return this.visible.activeGeneralChatAsRemoteSession(); } + activeGeneralUploadedFileCount(): number { return this.visible.activeGeneralUploadedFileCount(); } async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { - await this.requireRemoteRuntime().generalCommands.archiveSession(session, archived, this.general.isBusy); - } - - async exportHomeSession(session: RemoteSession): Promise { - const runtime = this.requireRemoteRuntime(); - await runtime.generalCommands.exportSession( - session, - this.general.isBusy, - async (text: string): Promise => runtime.clipboard.writeText(text) - ); - } - - async openGeneralSession(item: RemoteSession): Promise { - const runtime = this.requireRemoteRuntime(); - if (this.general.isBusy) { - return; - } - runtime.polling.stop(); - runtime.generalConversation.stop(false); - await runtime.generalCommands.openSession( - item, - this.general.isBusy, - async (sessionId: string): Promise => runtime.generalDrafts.restore(sessionId), - (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) - ); - } - - async startGeneralChat(text: string): Promise { - const runtime = this.requireRemoteRuntime(); - const trimmed = text.trim(); - if (trimmed.length === 0 || this.general.isBusy) { - return; - } - runtime.polling.stop(); - runtime.generalConversation.stop(false); - runtime.generalDrafts.cancel(); - const created = await runtime.generalCommands.createSession( - trimmed, - this.general.isBusy, - async (): Promise => runtime.generalDrafts.clearHomeNow(), - (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) - ); - if (created) { - await runtime.generalConversation.sendMessage(); - } - } - - async sendVisibleMessage(): Promise { - const runtime = this.requireRemoteRuntime(); - if (runtime.appShell.isGeneralChatVisible()) { - if ((this.general.activeSession.sessionId || '').length === 0) { - this.startVisibleGeneralChat(); - return; - } - await runtime.generalConversation.sendMessage(); - return; - } - await this.sendRemoteMessage(); - } - - async stopVisibleTask(): Promise { - const runtime = this.requireRemoteRuntime(); - if (runtime.appShell.isGeneralChatVisible()) { - runtime.generalConversation.stop(true); - return; - } - await this.stopRemoteTask(); - } - - closeActiveChat(): void { - const runtime = this.requireRemoteRuntime(); - runtime.filePreview.close(); - runtime.hooks.stopVoiceInput(); - if (runtime.appShell.isRoute(AppRoute.GeneralChat)) { - runtime.generalDrafts.persistVisible(this.general.chatInput); - runtime.generalConversation.stop(true); - runtime.appShell.popRoute(AppRoute.ChatHome); - this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); - return; - } - runtime.polling.stop(); - this.remote.setConversationDismissed(true); - runtime.appShell.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - - async renameVisibleSession(title: string): Promise { - const runtime = this.requireRemoteRuntime(); - if (runtime.appShell.isGeneralChatVisible()) { - await runtime.generalCommands.renameActiveSession(this.general.activeSession, title); - return; - } - await this.renameRemoteSession(title); - } - - async retryVisibleMessage(text: string): Promise { - const runtime = this.requireRemoteRuntime(); - if (runtime.appShell.isGeneralChatVisible()) { - const prepared = await runtime.generalCommands.retryMessage( - this.general.activeSession.sessionId || '', text, this.general.isBusy - ); - if (prepared) { - await runtime.generalConversation.sendMessage(); - } - return; - } - this.retryRemoteMessage(text); - } - - downloadVisibleFile(path: string): void { - if (this.requireRemoteRuntime().appShell.isGeneralChatVisible()) { - this.general.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); - return; - } - this.downloadRemoteFile(path); - } - - async selectVisibleModel(modelId: string): Promise { - const runtime = this.requireRemoteRuntime(); - if (runtime.appShell.isGeneralChatVisible()) { - await runtime.settings.selectModel(modelId); - return; - } - await this.selectRemoteModel(modelId); - } - - startVisibleGeneralChat(): void { - const rawText = this.general.chatInput.trim(); - const text = rawText.length > 0 ? rawText : - (this.general.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - if (text.length === 0 || this.general.isBusy) { - return; - } - if (this.general.serviceState === GeneralChatServiceState.Unconfigured) { - const statusText = GeneralChatServiceStatus.userMessage(this.general.serviceState); - this.general.setStatus(statusText); - this.showHomeToast(statusText); - return; - } - this.startGeneralChat(text); - } - - generalChatHomeStatusText(): string { - if (this.general.serviceState === GeneralChatServiceState.Ready || - this.general.serviceState === GeneralChatServiceState.Sending || - this.general.serviceState === GeneralChatServiceState.Streaming) { - return ''; - } - return GeneralChatServiceStatus.userMessage(this.general.serviceState, this.general.statusText); - } - - prepareNewGeneralChat(): void { - const runtime = this.requireRemoteRuntime(); - runtime.hooks.stopVoiceInput(); - runtime.generalConversation.stop(true); - runtime.generalDrafts.clearHome(); - this.general.clearComposer(); - this.general.clearActiveSession(); - this.resetGeneralTimeline(''); - runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - + await this.visible.archiveHomeSession(session, archived); + } + async exportHomeSession(session: RemoteSession): Promise { await this.visible.exportHomeSession(session); } + async openGeneralSession(item: RemoteSession): Promise { await this.visible.openGeneralSession(item); } + async startGeneralChat(text: string): Promise { await this.visible.startGeneralChat(text); } + async sendVisibleMessage(): Promise { await this.visible.sendVisibleMessage(); } + async stopVisibleTask(): Promise { await this.visible.stopVisibleTask(); } + closeActiveChat(): void { this.visible.closeActiveChat(); } + async renameVisibleSession(title: string): Promise { await this.visible.renameVisibleSession(title); } + async retryVisibleMessage(text: string): Promise { await this.visible.retryVisibleMessage(text); } + downloadVisibleFile(path: string): void { this.visible.downloadVisibleFile(path); } + async selectVisibleModel(modelId: string): Promise { await this.visible.selectVisibleModel(modelId); } + startVisibleGeneralChat(): void { this.visible.startVisibleGeneralChat(); } + generalChatHomeStatusText(): string { return this.visible.generalChatHomeStatusText(); } + prepareNewGeneralChat(): void { this.visible.prepareNewGeneralChat(); } onVisibleChatInputChange(route: AppRoute, value: string): void { - this.setChatInput(route, value); - if (this.isGeneralComposerRoute(route)) { - this.requireRemoteRuntime().generalDrafts.scheduleVisible(value); - } - } - - visibleGeneralChatDraftId(): string { - return this.requireRemoteRuntime().appShell.isGeneralChatVisible() ? - this.general.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID : ''; + this.visible.onVisibleChatInputChange(route, value); } - + visibleGeneralChatDraftId(): string { return this.visible.visibleGeneralChatDraftId(); } async restoreGeneralChatDraft(draftId: string): Promise { - this.general.setChatInput(await this.requireRemoteRuntime().generalDrafts.restore(draftId)); - } - - latestUserMessageText(): string { - if (this.requireRemoteRuntime().appShell.isGeneralChatVisible()) { - return this.general.latestUserMessageText(); - } - const candidates = this.remote.persistedMessages.concat(this.remote.optimisticMessages); - for (let index = candidates.length - 1; index >= 0; index--) { - if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { - return candidates[index].text; - } - } - return ''; - } - - resetGeneralTimeline(sessionId: string): void { - this.requireRemoteRuntime().timeline.reset(sessionId); - this.syncGeneralTimeline(); - } - - syncGeneralTimeline(): void { - const runtime = this.requireRemoteRuntime(); - const state: ChatTimelineState = runtime.timeline.snapshotState(); - const projectedItems = runtime.timeline.viewState(false); - this.general.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - false, - projectedItems - ); - const itemSummary = projectedItems.map((item: ChatTimelineItem) => { - const message = item.message; - return `${item.type}:${item.id}:${message ? message.status : ''}:${message ? message.text.length : 0}`; - }).join(','); - RemoteLogger.info(`general chat projection revision=${this.general.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); - } - - showHomeToast(message: string): void { - const runtime = this.requireRemoteRuntime(); - if (!runtime.hooks.showToast(message)) { - this.setVisibleStatusText(message); - } - } - - private currentChatPollingCursor(): RemoteChatPollingCursor { - return { - pollVersion: this.knownPollVersionValue, - knownMessageCount: this.knownRemoteMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }; - } - - private updateChatPollingCursor(pollVersion: number, knownMessageCount: number): void { - this.knownPollVersionValue = pollVersion; - this.knownRemoteMessageCount = knownMessageCount; - this.requireRemoteRuntime().polling.updateCursor({ - pollVersion, - knownMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }); - } - - private async syncAfterRemoteTurnEnded(): Promise { - if (this.isSyncingAfterTurn) { - return; - } - this.isSyncingAfterTurn = true; - try { - // Deliberately not the cached path: this exists to pick up whatever the - // desktop settled on after the turn finished, which is exactly what the - // cache does not know yet. - const runtime = this.requireRemoteRuntime(); - await runtime.chat.reloadMessages( - this.remote.activeSession.sessionId || '', - runtime.hooks.isConversationContext - ); - } finally { - this.isSyncingAfterTurn = false; - } - } - - private shortSessionId(sessionId: string): string { - return sessionId.length <= 8 ? sessionId : - sessionId.slice(0, 4) + '...' + sessionId.slice(sessionId.length - 4); - } - - routeCreatedRemoteSession(sessionId: string): void { - const runtime = this.requireRemoteRuntime(); - runtime.filePreview.close(); - this.remote.setConversationDismissed(false); - if (runtime.appShell.isRoute(AppRoute.RemoteChat)) { - // Already on the screen this session belongs on. Swapping the path entry - // would rebuild the destination — and with it the sidebar — for a change - // that only page state describes. See replaceRouteWithoutAnimation. - return; - } - if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { - runtime.appShell.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); - return; - } - runtime.appShell.pushRoute(AppRoute.RemoteChat, sessionId); - } - - private routeRemoteSessionInPlace(sessionId: string): void { - const runtime = this.requireRemoteRuntime(); - runtime.filePreview.close(); - this.remote.setConversationDismissed(false); - const target = AppRouteContract.remoteSessionDestination(sessionId); - runtime.appShell.replaceRouteWithoutAnimation(target.name, target.routeParam().sessionId); - } - - private requireRemoteRuntime(): RemoteConversationDependencies { - if (!this.remoteRuntime) { - throw new Error('Remote conversation dependencies are not configured.'); - } - return this.remoteRuntime; + await this.visible.restoreGeneralChatDraft(draftId); } + latestUserMessageText(): string { return this.visible.latestUserMessageText(); } + resetGeneralTimeline(sessionId: string): void { this.visible.resetGeneralTimeline(sessionId); } + syncGeneralTimeline(): void { this.visible.syncGeneralTimeline(); } + showHomeToast(message: string): void { this.visible.showHomeToast(message); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets new file mode 100644 index 000000000..50766953c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets @@ -0,0 +1,99 @@ +import { ImagePickerService } from '../../services/ImagePickerService'; +import { ClipboardService } from '../../services/ClipboardService'; +import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; +import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; +import { RemoteChatCache } from '../../services/RemoteChatCache'; +import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { RemoteChatPollingLifecycleController } from '../../services/RemoteChatPollingLifecycleController'; +import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemoteToolActionController } from '../../services/RemoteToolActionController'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; +import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; +import { ConversationViewModel } from './ConversationViewModel'; +import { AppShellViewModel } from './AppShellViewModel'; +import { FilePreviewController } from './FilePreviewController'; +import { RemoteConnectionController } from './RemoteConnectionController'; +import { RemoteSessionViewModel } from './RemoteSessionViewModel'; +import { SettingsController } from './SettingsController'; +import { GeneralChatConversationViewModel } from './GeneralChatConversationViewModel'; + +export const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; + +export interface ConversationControllerHooks { + readonly currentRoute: () => AppRoute; +} + +export interface RemoteConversationHooks { + readonly isConversationContext: (sessionId: string) => boolean; + readonly isFilePreviewVisible: () => boolean; + readonly stopVoiceInput: () => Promise; + readonly showToast: (message: string) => boolean; + readonly selectAssistantWorkspace: (path: string) => Promise; +} + +export interface RemoteConversationDependencies { + readonly timeline: ConversationViewModel; + readonly chat: RemoteChatCommandController; + readonly chatCache: RemoteChatCache; + readonly polling: RemoteChatPollingLifecycleController; + readonly models: RemoteModelController; + readonly files: RemoteFileDownloadController; + readonly tools: RemoteToolActionController; + readonly connection: RemoteConnectionController; + readonly imagePicker: ImagePickerService; + readonly clipboard: ClipboardService; + readonly sessions: RemoteSessionViewModel; + readonly sessionManager: RemoteSessionManager; + readonly workspace: RemoteWorkspaceCoordinator; + readonly settings: SettingsController; + readonly appShell: AppShellViewModel; + readonly filePreview: FilePreviewController; + readonly generalCommands: GeneralChatCommandController; + readonly generalConversation: GeneralChatConversationViewModel; + readonly generalDrafts: GeneralChatDraftLifecycleController; + readonly hooks: RemoteConversationHooks; +} + +export function requireRemoteRuntime( + remoteRuntime?: RemoteConversationDependencies +): RemoteConversationDependencies { + if (!remoteRuntime) { + throw new Error('Remote conversation dependencies are not configured.'); + } + return remoteRuntime; +} + +export function shortSessionId(sessionId: string): string { + return sessionId.length <= 8 ? sessionId : + sessionId.slice(0, 4) + '...' + sessionId.slice(sessionId.length - 4); +} + +export function routeCreatedRemoteSession( + sessionId: string, + remoteRuntime: RemoteConversationDependencies, + setDismissed: (dismissed: boolean) => void +): void { + remoteRuntime.filePreview.close(); + setDismissed(false); + if (remoteRuntime.appShell.isRoute(AppRoute.RemoteChat)) { + return; + } + if (remoteRuntime.appShell.isRoute(AppRoute.RemoteCreate)) { + remoteRuntime.appShell.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); + return; + } + remoteRuntime.appShell.pushRoute(AppRoute.RemoteChat, sessionId); +} + +export function routeRemoteSessionInPlace( + sessionId: string, + remoteRuntime: RemoteConversationDependencies, + setDismissed: (dismissed: boolean) => void +): void { + remoteRuntime.filePreview.close(); + setDismissed(false); + const target = AppRouteContract.remoteSessionDestination(sessionId); + remoteRuntime.appShell.replaceRouteWithoutAnimation(target.name, target.routeParam().sessionId); +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets index d1e4ebd7c..bdf88a496 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets @@ -11,6 +11,11 @@ import { MobileIdentitySnapshot, MobileIdentityStore } from '../../services/Mobi import { RemoteDescriptorParser } from '../../services/RemoteDescriptorParser'; import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; import { RemoteModelController } from '../../services/RemoteModelController'; +import { + ConnectScanDecisionPolicy, + DetectedRemoteUrlResult, + DetectedUrlAction +} from '../../services/ConnectScanDecisionPolicy'; import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; import { RemoteSessionController } from '../../services/RemoteSessionController'; import { RemoteUiState } from '../../services/RemoteUiState'; @@ -275,39 +280,59 @@ export class RemoteConnectionController { } } - async scan(context: Context): Promise { + async scan(context: Context): Promise { try { this.pageState.setStatusText(RemoteI18n.t('status.openScanner')); const text = (await this.scanner.scanRemoteUrl(context)).trim(); if (text.length === 0) { this.pageState.setStatusText(RemoteI18n.t('status.scanEmpty')); - return; + return ''; } - this.handleDetectedUrl(text); + return text; } catch (err) { this.pageState.setRemoteUrlInputVisible(true); this.pageState.setStatusText(ConnectionErrorPolicy.errorText(err)); + return ''; } } - handleDetectedUrl(remoteUrl: string): boolean { + /** + * Apply a scanned pair URL and say what should happen next. + * + * This method must not start a connect and must not close the sheet: the + * caller owns both, so a camera callback cannot pair twice and cannot jump + * off the progress the sheet is showing. + */ + handleDetectedUrl( + remoteUrl: string, + hasCloudAccountSession: boolean = false, + cloudUsername: string = '' + ): DetectedRemoteUrlResult { try { this.applyRemoteUrl(remoteUrl); const descriptor = RemoteDescriptorParser.parse(remoteUrl); this.pageState.setStatusText(RemoteI18n.t('status.scannedUrl')); - if (this.pairing.shouldPromptForAccount(descriptor)) { + const action = ConnectScanDecisionPolicy.decide( + descriptor.accountAuth, + hasCloudAccountSession, + cloudUsername, + descriptor.accountUsername, + RemoteUiState.desktopIdFromRemoteUrl(remoteUrl) + ); + if (action === DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD) { this.pageState.setStatusText(RemoteI18n.t('connect.enterAccountToPair')); this.openConnectSheet(); - return true; + return new DetectedRemoteUrlResult(action); } - this.closeConnectSheet(); - this.replaceRoute(AppRoute.RemoteHome); - void this.connect(); - return false; + return new DetectedRemoteUrlResult( + action, + action === DetectedUrlAction.USE_CLOUD_DEVICE ? + RemoteUiState.desktopIdFromRemoteUrl(remoteUrl) : '' + ); } catch (err) { this.pageState.setRemoteUrlInputVisible(true); this.pageState.setStatusText(ConnectionErrorPolicy.errorText(err)); - return true; + return new DetectedRemoteUrlResult(DetectedUrlAction.INVALID); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteCreateFlowController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteCreateFlowController.ets new file mode 100644 index 000000000..58fc0143d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteCreateFlowController.ets @@ -0,0 +1,316 @@ +import { RecentWorkspaceEntry, RemoteSession, SessionSummary } from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { + RemoteConversationDependencies, + requireRemoteRuntime, + routeCreatedRemoteSession, + routeRemoteSessionInPlace +} from './ConversationRuntime'; + +export class RemoteCreateFlowController { + private readonly remote: RemotePageState; + private readonly remoteCreate: RemoteCreateSessionState; + private readonly remoteRuntime?: RemoteConversationDependencies; + private remoteCreateWorkspaceLoadVersion: number = 0; + + constructor( + remote: RemotePageState, + remoteCreate: RemoteCreateSessionState, + remoteRuntime: RemoteConversationDependencies | undefined + ) { + this.remote = remote; + this.remoteCreate = remoteCreate; + this.remoteRuntime = remoteRuntime; + } + + private navigateCreated(sessionId: string): void { + routeCreatedRemoteSession(sessionId, requireRemoteRuntime(this.remoteRuntime), (dismissed: boolean) => { + this.remote.setConversationDismissed(dismissed); + }); + } + + private navigateInPlace(sessionId: string): void { + routeRemoteSessionInPlace(sessionId, requireRemoteRuntime(this.remoteRuntime), (dismissed: boolean) => { + this.remote.setConversationDismissed(dismissed); + }); + } + + async createRemoteSession(agentType: string, inPlace: boolean = false): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + runtime.filePreview.close(); + await runtime.sessions.createSession( + agentType, + '', + inPlace ? (sessionId: string): void => this.navigateInPlace(sessionId) : + (sessionId: string): void => this.navigateCreated(sessionId) + ); + } + + openRemoteCreateSession(): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (!runtime.connection.ensureAvailable()) { + return; + } + const deviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; + const deviceName = this.remote.controlTargetDeviceName || this.remote.desktopName; + this.remoteCreate.prepare(deviceId, deviceName, this.remote.selectedModelId); + if (deviceId.length > 0) { + this.remoteCreate.setDevices([{ + deviceId, + deviceName: deviceName || deviceId, + online: true + }]); + } + this.remoteCreate.setWorkspaces(this.remote.recentWorkspaces); + runtime.appShell.pushRoute(AppRoute.RemoteCreate); + this.loadRemoteCreateChoices(); + this.loadRemoteCreateModelCatalog(); + } + + closeRemoteCreateSession(): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + this.remoteCreateWorkspaceLoadVersion += 1; + runtime.hooks.stopVoiceInput(); + this.remoteCreate.closeMenu(); + runtime.appShell.popRoute(AppRoute.RemoteHome); + } + + async loadRemoteCreateChoices(): Promise { + await Promise.all([ + this.loadRemoteCreateDevices(), + this.loadRemoteCreateWorkspaces() + ]); + } + + async loadRemoteCreateModelCatalog(): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (this.remote.modelCatalog.models.length > 0) { + return; + } + try { + const catalog = await runtime.sessionManager.getModelCatalog(); + const selectedModelId = RemoteUiState.selectedModelIdForCatalog(catalog, this.remote.selectedModelId); + this.remote.setModelCatalog(catalog, selectedModelId); + this.remoteCreate.setSelectedModelId(selectedModelId); + } catch (_err) { + // Model selection remains hidden when the remote does not expose a catalog. + } + } + + async loadRemoteCreateDevices(): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + this.remoteCreate.isLoadingDevices = this.remoteCreate.devices.length === 0; + try { + const phoneDeviceId = runtime.connection.getDeviceId(); + const accountDevices = await runtime.settings.listCloudAccountDevices(); + const devices = accountDevices.filter((device: CloudAccountDevice): boolean => + device.online && device.deviceId !== phoneDeviceId + ); + const currentId = this.remoteCreate.selectedDeviceId; + if (currentId.length > 0 && + !devices.some((device: CloudAccountDevice): boolean => device.deviceId === currentId)) { + devices.unshift({ + deviceId: currentId, + deviceName: this.remoteCreate.selectedDeviceName || currentId, + online: true + }); + } + this.remoteCreate.setDevices(devices); + } catch (_err) { + const currentId = this.remoteCreate.selectedDeviceId; + if (currentId.length > 0) { + this.remoteCreate.setDevices([{ + deviceId: currentId, + deviceName: this.remoteCreate.selectedDeviceName || currentId, + online: true + }]); + } else { + this.remoteCreate.setDevices([]); + } + this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceLoadFailed'); + } + } + + async loadRemoteCreateWorkspaces(): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + const loadVersion = ++this.remoteCreateWorkspaceLoadVersion; + const deviceId = this.remoteCreate.selectedDeviceId; + this.remoteCreate.isLoadingWorkspaces = this.remoteCreate.workspaces.length === 0; + try { + const workspaces = await runtime.workspace.recentWorkspaces(); + if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || + deviceId !== this.remoteCreate.selectedDeviceId) { + return; + } + this.remoteCreate.setWorkspaces(workspaces); + } catch (_err) { + if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || + deviceId !== this.remoteCreate.selectedDeviceId) { + return; + } + this.remoteCreate.setWorkspaces([]); + this.remoteCreate.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); + } + } + + toggleRemoteCreateDevices(): void { + this.remoteCreate.toggleMenu('devices'); + if (this.remoteCreate.openMenu === 'devices' && this.remoteCreate.devices.length === 0) { + this.loadRemoteCreateDevices(); + } + } + + toggleRemoteCreateWorkspaces(): void { + this.remoteCreate.toggleMenu('workspaces'); + if (this.remoteCreate.openMenu === 'workspaces' && this.remoteCreate.workspaces.length === 0) { + this.loadRemoteCreateWorkspaces(); + } + } + + async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (device.deviceId === this.remoteCreate.selectedDeviceId) { + this.remoteCreate.closeMenu(); + return; + } + const draft = this.remoteCreate.draft; + this.remoteCreate.closeMenu(); + this.remoteCreate.isLoadingWorkspaces = true; + try { + await runtime.settings.selectCloudAccountDevice(device, false); + this.remoteCreate.selectDevice(device); + this.remoteCreate.setDraft(draft); + await this.loadRemoteCreateWorkspaces(); + } catch (err) { + this.remoteCreate.isLoadingWorkspaces = false; + this.remoteCreate.errorText = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceSwitchFailed'); + } + } + + selectRemoteCreateWorkspace(path: string): void { + const workspace = this.remoteCreate.workspaces + .find((item: RecentWorkspaceEntry): boolean => item.path === path); + this.remoteCreate.selectWorkspace(workspace); + } + + async submitRemoteCreateSession(): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + const instruction = this.remoteCreate.draft.trim(); + if (instruction.length === 0 || this.remoteCreate.isSubmitting || !runtime.connection.ensureAvailable()) { + return; + } + const context = this.remoteCreate.submissionContext(); + const activeDeviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; + if (context.deviceId.length === 0 || context.deviceId !== activeDeviceId) { + this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceMismatch'); + return; + } + this.remoteCreate.isSubmitting = true; + this.remoteCreate.errorText = ''; + this.remoteCreate.closeMenu(); + try { + if (context.workspacePath.length > 0) { + await runtime.sessions.createSessionInWorkspace( + context.workspacePath, + this.remote.workspacePath, + instruction, + context.agentType, + undefined, + this.remoteCreate.selectedModelId + ); + } else { + await this.bindAssistantWorkspace(); + await runtime.sessions.createSession( + context.agentType, + instruction, + undefined, + this.remoteCreate.selectedModelId + ); + } + if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { + this.remoteCreate.errorText = this.remote.statusText || RemoteI18n.t('remote.create.submitFailed'); + } + } catch (err) { + this.remoteCreate.errorText = err instanceof Error ? err.message : + RemoteI18n.t('remote.create.submitFailed'); + } finally { + this.remoteCreate.isSubmitting = false; + } + } + + /** + * The chat option creates a Claw session, and the desktop always binds those + * to its assistant workspace. Follow it there first, otherwise the app stays + * bound to the code workspace it was on and the new chat is listed, titled + * and file-scoped as if it had been created inside that workspace. + */ + private async bindAssistantWorkspace(): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (this.remote.workspaceKind === 'assistant') { + return; + } + try { + const assistants = await runtime.workspace.assistants(); + if (assistants.length === 0) { + return; + } + await runtime.hooks.selectAssistantWorkspace(assistants[0].path); + } catch (err) { + RemoteLogger.warn(`assistant workspace bind failed: ${String(err)}`); + } + } + + async createRemoteSessionInWorkspace( + path: string, + agentType: string = 'code', + inPlace: boolean = false + ): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + runtime.filePreview.close(); + await runtime.sessions.createSessionInWorkspace( + path, + this.remote.workspacePath, + '', + agentType, + inPlace ? (sessionId: string): void => this.navigateInPlace(sessionId) : + (sessionId: string): void => this.navigateCreated(sessionId) + ); + } + + async openRemoteSession(item: RemoteSession, inPlace: boolean = false): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + runtime.filePreview.close(); + await runtime.sessions.openSession( + item, + this.remote.workspacePath, + inPlace ? (sessionId: string): void => this.navigateInPlace(sessionId) : + (sessionId: string): void => this.navigateCreated(sessionId) + ); + } + + applyRemoteActiveSession(session: SessionSummary): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + const current = this.remote.activeSession; + if (runtime.hooks.isFilePreviewVisible() && + (current.sessionId !== session.sessionId || current.workspacePath !== session.workspacePath)) { + runtime.filePreview.close(); + } + this.remote.setActiveSession(session); + } + + async deleteRemoteSession(item: RemoteSession): Promise { + await requireRemoteRuntime(this.remoteRuntime).sessions.deleteSession(item, this.remote.workspacePath); + } + + routeCreatedRemoteSession(sessionId: string): void { + this.navigateCreated(sessionId); + } +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets new file mode 100644 index 000000000..f5082ddb4 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets @@ -0,0 +1,371 @@ +import { + RemoteImageContext, + RemoteQuestionAnswerPayload +} from '../../model/RemoteModels'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; +import { ChatTimelineState, ChatTimelineStore } from '../../services/ChatTimelineStore'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { RemoteChatPollingCursor, RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { RemotePageState } from '../state/RemotePageState'; +import { + RemoteConversationDependencies, + requireRemoteRuntime, + shortSessionId +} from './ConversationRuntime'; + +export class RemoteTranscriptController { + private readonly remote: RemotePageState; + private readonly remoteRuntime?: RemoteConversationDependencies; + private readonly notify: (message: string) => void; + private knownPollVersionValue: number = 0; + private knownModelCatalogVersion: number = 0; + private knownRemoteMessageCount: number = 0; + private isSyncingAfterTurn: boolean = false; + private isRebuildingRemoteTranscript: boolean = false; + + constructor( + remote: RemotePageState, + remoteRuntime: RemoteConversationDependencies | undefined, + notify: (message: string) => void + ) { + this.remote = remote; + this.remoteRuntime = remoteRuntime; + this.notify = notify; + } + + knownPollVersion(): number { + return this.knownPollVersionValue; + } + + resetKnownRemoteState(): void { + this.knownPollVersionValue = 0; + this.knownModelCatalogVersion = 0; + this.knownRemoteMessageCount = 0; + } + + updateKnownMessageCount(pollVersion: number, knownMessageCount: number): void { + this.knownRemoteMessageCount = knownMessageCount; + this.updateChatPollingCursor(pollVersion, knownMessageCount); + } + + updateKnownModelCatalogVersion(version: number): void { + this.knownModelCatalogVersion = version; + requireRemoteRuntime(this.remoteRuntime).polling.updateKnownModelCatalogVersion(version); + } + + async loadRemoteMessages(): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + await runtime.chat.loadMessages( + this.remote.activeSession.sessionId || '', + runtime.hooks.isConversationContext + ); + } + + async selectRemoteModel(modelId: string): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + await runtime.models.selectModel( + modelId, + this.remote.activeSession.sessionId || '', + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + async loadOlderRemoteMessages(): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + await runtime.chat.loadOlderMessages( + this.remote.activeSession.sessionId || '', + this.knownPollVersionValue, + this.remote.hasMoreMessages, + this.remote.isBusy + ); + } + + async sendRemoteMessage(): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (this.remote.isVoiceListening) { + await runtime.hooks.stopVoiceInput(); + } + const rawText = this.remote.chatInput.trim(); + const images = this.remote.selectedImages.slice(); + const text = rawText.length > 0 ? rawText : + (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); + const sessionId = this.remote.activeSession.sessionId || ''; + if ((!text && images.length === 0) || !sessionId || this.remote.isBusy || + !runtime.connection.ensureAvailable()) { + return; + } + // Sending into a turn that is still running is allowed, but the desktop + // queues it rather than interrupting, so say so once — otherwise the + // message just sits in the transcript with nothing appearing to happen. + const queuedBehindRunningTurn = this.hasRunningRemoteTurn(); + this.remote.clearComposer(); + const localMessage = RemoteUiState.localUserMessage(text, images); + runtime.timeline.appendOptimisticMessage(localMessage); + const pendingActiveId = runtime.timeline.setPendingActiveTurn(localMessage.id); + this.syncRemoteTimeline(); + RemoteLogger.info(`chat send queued session=${shortSessionId(sessionId)} pending=${pendingActiveId} behindRunningTurn=${queuedBehindRunningTurn ? '1' : '0'}`); + if (queuedBehindRunningTurn) { + this.notify(RemoteI18n.t('chat.queuedAfterRunningTurn')); + } + this.startRemotePolling(); + runtime.polling.nudge(); + const imageContexts: RemoteImageContext[] = images.length > 0 ? + runtime.imagePicker.toRemoteContexts(images) : []; + await runtime.chat.sendPreparedMessage( + sessionId, + text, + this.remote.activeSession.agentType, + rawText, + images, + imageContexts, + localMessage.id, + pendingActiveId, + this.remote.isBusy, + true + ); + } + + async stopRemoteTask(): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + const sessionId = this.remote.activeSession.sessionId || ''; + if (!sessionId) { + return; + } + await runtime.chat.stopTask( + sessionId, + this.remote.activeTurnMessage.id, + this.remoteActiveTurnId(), + runtime.connection.ensureAvailable() + ); + } + + async renameRemoteSession(title: string): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + const nextTitle = title.trim(); + if (!this.remote.activeSession.sessionId || nextTitle.length === 0 || + nextTitle === this.remote.activeSession.title || this.remote.isBusy) { + return; + } + await runtime.chat.renameActiveSession( + this.remote.activeSession, + nextTitle, + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + async copyRemoteMessage(text: string): Promise { + if (text.trim().length === 0) { + return; + } + try { + await requireRemoteRuntime(this.remoteRuntime).clipboard.writeText(text); + this.remote.setStatusText(RemoteI18n.t('status.messageCopied')); + } catch (err) { + this.remote.setStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + async downloadRemoteFile(path: string): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + await runtime.files.download( + path, + this.remote.activeSession.sessionId || '', + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + retryRemoteMessage(text: string): void { + if (this.remote.isBusy || !requireRemoteRuntime(this.remoteRuntime).connection.ensureAvailable()) { + return; + } + this.remote.setChatInput(text); + this.sendRemoteMessage(); + } + + async approveRemoteTool(toolId: string, updatedInput?: Object): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + await runtime.tools.approve( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), updatedInput + ); + } + + async rejectRemoteTool(toolId: string): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + await runtime.tools.reject( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable() + ); + } + + async cancelRemoteTool(toolId: string): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + await runtime.tools.cancel( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable() + ); + } + + async answerRemoteQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + await runtime.tools.answer( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), answers + ); + } + + resetRemoteTimeline(sessionId: string): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + runtime.timeline.reset(sessionId); + this.knownPollVersionValue = 0; + this.syncRemoteTimeline(); + } + + syncRemoteTimeline(): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + const state: ChatTimelineState = runtime.timeline.snapshotState(); + this.remote.setTimelineProjection( + state.persistedMessages, + state.optimisticMessages, + state.activeTurn || RemoteUiState.emptyActiveTurn(), + this.remote.hasMoreMessages, + runtime.timeline.viewState(this.remote.hasMoreMessages) + ); + this.remote.setModelCatalog(state.modelCatalog, state.selectedModelId); + } + + startRemotePolling(): void { + requireRemoteRuntime(this.remoteRuntime).polling.startActiveSession({ + sessionId: this.remote.activeSession.sessionId || '', + cursor: this.currentChatPollingCursor(), + activeTurn: this.remote.activeTurnMessage + }); + } + + applyRemoteSnapshot(snapshot: RemoteChatPollingSnapshot): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (!runtime.hooks.isConversationContext(snapshot.sessionId)) { + return; + } + if (snapshot.historyRewritten) { + this.rebuildRemoteTranscript(snapshot.sessionId); + return; + } + runtime.timeline.applySnapshot(snapshot); + this.syncRemoteTimeline(); + if (snapshot.newMessages.length > 0) { + this.cacheRemoteTranscript(snapshot.sessionId); + } + this.knownPollVersionValue = snapshot.cursor.pollVersion; + this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; + this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; + if (snapshot.title.length > 0) { + this.remote.setActiveSession({ + sessionId: this.remote.activeSession.sessionId, + title: snapshot.title, + workspacePath: this.remote.activeSession.workspacePath, + agentType: this.remote.activeSession.agentType + }); + } + if (snapshot.modelCatalog) { + runtime.models.applyCatalog(snapshot.modelCatalog); + } + this.remote.setStatusText(this.hasRunningRemoteTurn() + ? RemoteI18n.t('status.desktopProcessing') + : RemoteI18n.t('status.messagesSynced')); + if (snapshot.shouldSyncAfterTurnEnded) { + this.syncAfterRemoteTurnEnded(); + } + } + + /** + * Writes the transcript now on screen back to disk. + * + * Fire-and-forget on purpose: the snapshot is already rendered, and a cache + * that cannot be written only costs the next open a fetch it used to pay for + * anyway. + */ + private cacheRemoteTranscript(sessionId: string): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + const state: ChatTimelineState = runtime.timeline.snapshotState(); + runtime.chatCache.sync(sessionId, state.persistedMessages); + } + + /** + * Refetches a transcript the desktop no longer agrees with. + * + * Tails are handed out by index, so once the desktop reports fewer messages + * than this session had counted there is no offset left that means the same + * thing on both ends. Everything stored for the session goes, including the + * poll cursor, which `reloadMessages` resets by way of `onMessageCountKnown`. + */ + private rebuildRemoteTranscript(sessionId: string): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (this.isRebuildingRemoteTranscript) { + return; + } + this.isRebuildingRemoteTranscript = true; + RemoteLogger.info(`remote transcript rewritten upstream session=${shortSessionId(sessionId)}`); + runtime.chatCache.forget(sessionId) + .then((): Promise => runtime.chat.reloadMessages(sessionId, runtime.hooks.isConversationContext)) + .then((): void => { + this.isRebuildingRemoteTranscript = false; + }) + .catch((): void => { + this.isRebuildingRemoteTranscript = false; + }); + } + + hasRunningRemoteTurn(): boolean { + return this.remote.activeTurnMessage.id.length > 0 && + (this.remote.activeTurnMessage.status || '').toLowerCase() === 'active'; + } + + remoteActiveTurnId(): string { + return ChatTimelineStore.cancelableTurnId(this.remote.activeTurnMessage); + } + + projectedRemoteTimelineItems(): ChatTimelineItem[] { + return requireRemoteRuntime(this.remoteRuntime).timeline.viewState(this.remote.hasMoreMessages); + } + + private currentChatPollingCursor(): RemoteChatPollingCursor { + return { + pollVersion: this.knownPollVersionValue, + knownMessageCount: this.knownRemoteMessageCount, + knownModelCatalogVersion: this.knownModelCatalogVersion + }; + } + + private updateChatPollingCursor(pollVersion: number, knownMessageCount: number): void { + this.knownPollVersionValue = pollVersion; + this.knownRemoteMessageCount = knownMessageCount; + requireRemoteRuntime(this.remoteRuntime).polling.updateCursor({ + pollVersion, + knownMessageCount, + knownModelCatalogVersion: this.knownModelCatalogVersion + }); + } + + private async syncAfterRemoteTurnEnded(): Promise { + if (this.isSyncingAfterTurn) { + return; + } + this.isSyncingAfterTurn = true; + try { + // Deliberately not the cached path: this exists to pick up whatever the + // desktop settled on after the turn finished, which is exactly what the + // cache does not know yet. + const runtime = requireRemoteRuntime(this.remoteRuntime); + await runtime.chat.reloadMessages( + this.remote.activeSession.sessionId || '', + runtime.hooks.isConversationContext + ); + } finally { + this.isSyncingAfterTurn = false; + } + } + +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/VisibleConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/VisibleConversationController.ets new file mode 100644 index 000000000..3b49a5154 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/VisibleConversationController.ets @@ -0,0 +1,316 @@ +import { RemoteSession } from '../../model/RemoteModels'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; +import { ChatTimelineState } from '../../services/ChatTimelineStore'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { + GeneralChatServiceState, + GeneralChatServiceStatus +} from '../../services/general-chat/GeneralChatServiceState'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { + GENERAL_CHAT_HOME_DRAFT_ID, + RemoteConversationDependencies, + requireRemoteRuntime +} from './ConversationRuntime'; +import { RemoteCreateFlowController } from './RemoteCreateFlowController'; +import { RemoteTranscriptController } from './RemoteTranscriptController'; + +export class VisibleConversationController { + private readonly general: GeneralChatPageState; + private readonly remote: RemotePageState; + private readonly remoteRuntime?: RemoteConversationDependencies; + private readonly transcript: RemoteTranscriptController; + private readonly createFlow: RemoteCreateFlowController; + private readonly setChatInputRef: (route: AppRoute, value: string) => void; + private readonly setVisibleStatusTextRef: (statusText: string) => void; + private readonly isGeneralComposerRouteRef: (route: AppRoute) => boolean; + private readonly notify: (message: string) => void; + + constructor( + general: GeneralChatPageState, + remote: RemotePageState, + remoteRuntime: RemoteConversationDependencies | undefined, + transcript: RemoteTranscriptController, + createFlow: RemoteCreateFlowController, + setChatInputRef: (route: AppRoute, value: string) => void, + setVisibleStatusTextRef: (statusText: string) => void, + isGeneralComposerRouteRef: (route: AppRoute) => boolean, + notify: (message: string) => void + ) { + this.general = general; + this.remote = remote; + this.remoteRuntime = remoteRuntime; + this.transcript = transcript; + this.createFlow = createFlow; + this.setChatInputRef = setChatInputRef; + this.setVisibleStatusTextRef = setVisibleStatusTextRef; + this.isGeneralComposerRouteRef = isGeneralComposerRouteRef; + this.notify = notify; + } + + openHomeSession(session: RemoteSession, inPlace: boolean = false): void { + requireRemoteRuntime(this.remoteRuntime).filePreview.close(); + if (session.agentType === 'chat') { + this.openGeneralSession(session); + return; + } + this.createFlow.openRemoteSession(session, inPlace); + } + + async deleteHomeSession(session: RemoteSession): Promise { + if (session.agentType !== 'chat') { + await this.createFlow.deleteRemoteSession(session); + return; + } + await requireRemoteRuntime(this.remoteRuntime).generalCommands.deleteSession(session, this.general.isBusy); + } + + activeGeneralChatAsRemoteSession(): RemoteSession { + const active = this.general.activeSession; + return { + id: active.sessionId, + title: active.title, + agentType: 'chat', + status: 'ready', + updatedAt: '', + createdAt: '', + messageCount: this.general.timelineItems.length, + workspacePath: active.workspacePath + }; + } + + activeGeneralUploadedFileCount(): number { + let count = 0; + this.general.timelineItems.forEach((item: ChatTimelineItem) => { + if (item.message && item.message.images) { + count += item.message.images.length; + } + }); + return count; + } + + async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { + await requireRemoteRuntime(this.remoteRuntime).generalCommands.archiveSession(session, archived, this.general.isBusy); + } + + async exportHomeSession(session: RemoteSession): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + await runtime.generalCommands.exportSession( + session, + this.general.isBusy, + async (text: string): Promise => runtime.clipboard.writeText(text) + ); + } + + async openGeneralSession(item: RemoteSession): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (this.general.isBusy) { + return; + } + runtime.polling.stop(); + runtime.generalConversation.stop(false); + await runtime.generalCommands.openSession( + item, + this.general.isBusy, + async (sessionId: string): Promise => runtime.generalDrafts.restore(sessionId), + (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) + ); + } + + async startGeneralChat(text: string): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + const trimmed = text.trim(); + if (trimmed.length === 0 || this.general.isBusy) { + return; + } + runtime.polling.stop(); + runtime.generalConversation.stop(false); + runtime.generalDrafts.cancel(); + const created = await runtime.generalCommands.createSession( + trimmed, + this.general.isBusy, + async (): Promise => runtime.generalDrafts.clearHomeNow(), + (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) + ); + if (created) { + await runtime.generalConversation.sendMessage(); + } + } + + async sendVisibleMessage(): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (runtime.appShell.isGeneralChatVisible()) { + if ((this.general.activeSession.sessionId || '').length === 0) { + this.startVisibleGeneralChat(); + return; + } + await runtime.generalConversation.sendMessage(); + return; + } + await this.transcript.sendRemoteMessage(); + } + + async stopVisibleTask(): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (runtime.appShell.isGeneralChatVisible()) { + runtime.generalConversation.stop(true); + return; + } + await this.transcript.stopRemoteTask(); + } + + closeActiveChat(): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + runtime.filePreview.close(); + runtime.hooks.stopVoiceInput(); + if (runtime.appShell.isRoute(AppRoute.GeneralChat)) { + runtime.generalDrafts.persistVisible(this.general.chatInput); + runtime.generalConversation.stop(true); + runtime.appShell.popRoute(AppRoute.ChatHome); + this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); + return; + } + runtime.polling.stop(); + this.remote.setConversationDismissed(true); + runtime.appShell.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + } + + async renameVisibleSession(title: string): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (runtime.appShell.isGeneralChatVisible()) { + await runtime.generalCommands.renameActiveSession(this.general.activeSession, title); + return; + } + await this.transcript.renameRemoteSession(title); + } + + async retryVisibleMessage(text: string): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (runtime.appShell.isGeneralChatVisible()) { + const prepared = await runtime.generalCommands.retryMessage( + this.general.activeSession.sessionId || '', text, this.general.isBusy + ); + if (prepared) { + await runtime.generalConversation.sendMessage(); + } + return; + } + this.transcript.retryRemoteMessage(text); + } + + downloadVisibleFile(path: string): void { + if (requireRemoteRuntime(this.remoteRuntime).appShell.isGeneralChatVisible()) { + this.general.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); + return; + } + this.transcript.downloadRemoteFile(path); + } + + async selectVisibleModel(modelId: string): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (runtime.appShell.isGeneralChatVisible()) { + await runtime.settings.selectModel(modelId); + return; + } + await this.transcript.selectRemoteModel(modelId); + } + + startVisibleGeneralChat(): void { + const rawText = this.general.chatInput.trim(); + const text = rawText.length > 0 ? rawText : + (this.general.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); + if (text.length === 0 || this.general.isBusy) { + return; + } + if (this.general.serviceState === GeneralChatServiceState.Unconfigured) { + const statusText = GeneralChatServiceStatus.userMessage(this.general.serviceState); + this.general.setStatus(statusText); + this.notify(statusText); + return; + } + this.startGeneralChat(text); + } + + generalChatHomeStatusText(): string { + if (this.general.serviceState === GeneralChatServiceState.Ready || + this.general.serviceState === GeneralChatServiceState.Sending || + this.general.serviceState === GeneralChatServiceState.Streaming) { + return ''; + } + return GeneralChatServiceStatus.userMessage(this.general.serviceState, this.general.statusText); + } + + prepareNewGeneralChat(): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + runtime.hooks.stopVoiceInput(); + runtime.generalConversation.stop(true); + runtime.generalDrafts.clearHome(); + this.general.clearComposer(); + this.general.clearActiveSession(); + this.resetGeneralTimeline(''); + runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome); + } + + onVisibleChatInputChange(route: AppRoute, value: string): void { + this.setChatInputRef(route, value); + if (this.isGeneralComposerRouteRef(route)) { + requireRemoteRuntime(this.remoteRuntime).generalDrafts.scheduleVisible(value); + } + } + + visibleGeneralChatDraftId(): string { + return requireRemoteRuntime(this.remoteRuntime).appShell.isGeneralChatVisible() ? + this.general.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID : ''; + } + + async restoreGeneralChatDraft(draftId: string): Promise { + this.general.setChatInput(await requireRemoteRuntime(this.remoteRuntime).generalDrafts.restore(draftId)); + } + + latestUserMessageText(): string { + if (requireRemoteRuntime(this.remoteRuntime).appShell.isGeneralChatVisible()) { + return this.general.latestUserMessageText(); + } + const candidates = this.remote.persistedMessages.concat(this.remote.optimisticMessages); + for (let index = candidates.length - 1; index >= 0; index--) { + if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { + return candidates[index].text; + } + } + return ''; + } + + resetGeneralTimeline(sessionId: string): void { + requireRemoteRuntime(this.remoteRuntime).timeline.reset(sessionId); + this.syncGeneralTimeline(); + } + + syncGeneralTimeline(): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + const state: ChatTimelineState = runtime.timeline.snapshotState(); + const projectedItems = runtime.timeline.viewState(false); + this.general.setTimelineProjection( + state.persistedMessages, + state.optimisticMessages, + state.activeTurn || RemoteUiState.emptyActiveTurn(), + false, + projectedItems + ); + const itemSummary = projectedItems.map((item: ChatTimelineItem) => { + const message = item.message; + return `${item.type}:${item.id}:${message ? message.status : ''}:${message ? message.text.length : 0}`; + }).join(','); + RemoteLogger.info(`general chat projection revision=${this.general.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); + } + + showHomeToast(message: string): void { + const runtime = requireRemoteRuntime(this.remoteRuntime); + if (!runtime.hooks.showToast(message)) { + this.setVisibleStatusTextRef(message); + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets index eb533f896..cb88aa0e0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets @@ -82,4 +82,24 @@ export class ChatComposerPolicy { ): boolean { return inputFocused || modelSelectorOpen || text.indexOf('\n') >= 0; } + + static shouldShowAddButton(showAddButton: boolean, supportsAttachments: boolean): boolean { + return showAddButton && supportsAttachments; + } + + static primaryActionAccessibilityKey( + action: ComposerPrimaryAction, + isVoiceListening: boolean + ): string { + if (action === ComposerPrimaryAction.Stop) { + return isVoiceListening ? 'chat.stopListening' : 'chat.stop'; + } + if (action === ComposerPrimaryAction.Voice || action === ComposerPrimaryAction.VoiceBlocked) { + return 'chat.voiceInput'; + } + if (action === ComposerPrimaryAction.Send || action === ComposerPrimaryAction.SendBlocked) { + return 'chat.send'; + } + return ''; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectScanDecisionPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectScanDecisionPolicy.ets new file mode 100644 index 000000000..0f1f57182 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectScanDecisionPolicy.ets @@ -0,0 +1,54 @@ +export class DetectedUrlAction { + static readonly INVALID: string = 'invalid'; + static readonly PAIR_NOW: string = 'pair_now'; + static readonly PROMPT_ACCOUNT_PASSWORD: string = 'prompt_account_password'; + static readonly USE_CLOUD_DEVICE: string = 'use_cloud_device'; + static readonly SHOW_CLOUD_DEVICES: string = 'show_cloud_devices'; +} + +export class DetectedRemoteUrlResult { + readonly action: string; + readonly cloudDeviceId: string; + + constructor(action: string, cloudDeviceId: string = '') { + this.action = action; + this.cloudDeviceId = cloudDeviceId; + } +} + +/** + * What a scanned pair URL should do next. Applying the URL and starting a + * connect are separate steps: the detector never connects by itself. + * + * A BitFun cloud session on this phone is already the account proof. An + * account-auth QR for that same user must not ask for the password again; + * it continues through the same-account device path instead of room pairing. + */ +export class ConnectScanDecisionPolicy { + static decide( + accountAuth: boolean, + hasCloudAccountSession: boolean, + cloudUsername: string, + qrUsername: string, + desktopId: string + ): string { + if (!accountAuth) { + return DetectedUrlAction.PAIR_NOW; + } + if (hasCloudAccountSession && + ConnectScanDecisionPolicy.usernamesCompatible(cloudUsername, qrUsername)) { + return desktopId.trim().length > 0 ? + DetectedUrlAction.USE_CLOUD_DEVICE : + DetectedUrlAction.SHOW_CLOUD_DEVICES; + } + return DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD; + } + + static usernamesCompatible(cloudUsername: string, qrUsername: string): boolean { + const qr = qrUsername.trim(); + if (qr.length === 0) { + return true; + } + return cloudUsername.trim() === qr; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemotePairingPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemotePairingPolicy.ets index 005cf6f71..3d9aa415f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemotePairingPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemotePairingPolicy.ets @@ -1,5 +1,9 @@ import { RemoteI18n } from '../i18n/RemoteI18n'; import { RemoteDescriptor } from '../model/RemoteModels'; +import { + ConnectScanDecisionPolicy, + DetectedUrlAction +} from './ConnectScanDecisionPolicy'; import { PairIdentity } from './RelayHttpClient'; import { RemoteDescriptorParser } from './RemoteDescriptorParser'; @@ -72,8 +76,18 @@ export class RemotePairingPolicy { }; } - shouldPromptForAccount(descriptor: RemoteDescriptor): boolean { - return descriptor.accountAuth; + shouldPromptForAccount( + descriptor: RemoteDescriptor, + hasCloudAccountSession: boolean = false, + cloudUsername: string = '' + ): boolean { + return ConnectScanDecisionPolicy.decide( + descriptor.accountAuth, + hasCloudAccountSession, + cloudUsername, + descriptor.accountUsername, + '' + ) === DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD; } private isDefaultUserId(userId: string, deviceId: string): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets index 4a15a8f04..b7faada54 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets @@ -8,7 +8,7 @@ import { WatchProvisionProtocol, WatchProvisionRequest } from './WatchProvisionProtocol'; -import { WatchProvisionState } from '../pages/state/WatchProvisionState'; +import { WatchProvisionDisplay } from './WatchProvisionDisplay'; const DATASYNC_PERMISSION: Permissions = 'ohos.permission.DISTRIBUTED_DATASYNC'; @@ -55,7 +55,7 @@ export interface WatchProvisionPort { * implicit and never remembered. */ export class WatchProvisionController { - private readonly state: WatchProvisionState; + private readonly state: WatchProvisionDisplay; private readonly port: WatchProvisionPort; private store?: WatchHandoffStore; private starting: boolean = false; @@ -69,7 +69,7 @@ export class WatchProvisionController { */ private readonly answered: Set = new Set(); - constructor(state: WatchProvisionState, port: WatchProvisionPort) { + constructor(state: WatchProvisionDisplay, port: WatchProvisionPort) { this.state = state; this.port = port; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionDisplay.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionDisplay.ets new file mode 100644 index 000000000..e3db25bea --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionDisplay.ets @@ -0,0 +1,8 @@ +/** Observable card the page owns. Model must not import pages/state. */ +export interface WatchProvisionDisplay { + ask(deviceName: string, deviceId: string): void; + working(): void; + done(message: string): void; + fail(message: string): void; + hide(): void; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/MockGeneralChatAdapter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/MockGeneralChatAdapter.ets index 90acc96ae..9ce117702 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/MockGeneralChatAdapter.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/MockGeneralChatAdapter.ets @@ -101,11 +101,11 @@ export class MockGeneralChatAdapter implements GeneralChatPort { } if (MockGeneralChatAdapter.looksLikeCodeTask(normalized)) { return [ - '这个问题可能需要 Code 能力来读取本地项目、运行命令或查看日志。', + '这个问题可能需要远程能力来读取本地项目、运行命令或查看日志。', '', - '如果只是要通用建议,我可以先给排查路径;如果要看本地仓库、改代码或运行命令,就应该从侧栏进入 Code。', + '如果只是要通用建议,我可以先给排查路径;如果要看本地仓库、改代码或运行命令,就应该从侧栏进入远程。', '', - '当前是 mock 回复。接入 BitFun harness 后,普通聊天会给出真实模型建议,Code 模块会继续负责本地 workspace 和工具执行。' + '当前是 mock 回复。接入 BitFun harness 后,普通聊天会给出真实模型建议,远程模块会继续负责本地 workspace 和工具执行。' ].join('\n'); } if (normalized.indexOf('图片') >= 0 || normalized.toLowerCase().indexOf('image') >= 0) { @@ -135,7 +135,7 @@ export class MockGeneralChatAdapter implements GeneralChatPort { return [ `我收到了:${normalized}`, '', - '在手机端,BitFun 不应该只是普通聊天框,而是一个通用助手入口:可以问答、搜索资料、分析图片、处理文件、写作改写,也可以在需要本地项目能力时引导进入 Code。', + '在手机端,BitFun 不应该只是普通聊天框,而是一个通用助手入口:可以问答、搜索资料、分析图片、处理文件、写作改写,也可以在需要本地项目能力时引导进入远程。', '', '当前回复是 mock,用来验证移动端交互和流式 UI。下一步把这个 backend 替换为 BitFun harness 的普通聊天接口后,就会返回真实模型结果。' ].join('\n'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets index c5faf7efa..2b1465abb 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets @@ -19,15 +19,33 @@ export const GENERAL_CHAT_SYSTEM_PROMPT: string = [ 'It is not a blockchain, gaming, NFT, cryptocurrency, or Web3 product. Never invent those product claims.', 'Treat those product exclusions as internal factual constraints and do not mention them unless the user asks something directly related.', 'Help with general questions, writing, summarization, planning, research preparation, and everyday work.', + 'The user may attach images. When images are present, look at them and answer from what you can see.', 'This mobile chat currently has no local workspace, shell, desktop file, web browsing, or external tool access.', 'Never claim to have used unavailable tools or accessed information you were not given.', 'When a task requires reading or changing a local repository, running commands, or accessing desktop files, explain that the user should use BitFun Remote/Code.', 'Answer in the user\'s language. Be concise, concrete, and honest about uncertainty.' ].join('\n'); +export interface ModelProviderImageSource { + type: string; + media_type?: string; + data?: string; +} + +export interface ModelProviderImageUrl { + url: string; +} + +export interface ModelProviderContentPart { + type: string; + text?: string; + source?: ModelProviderImageSource; + image_url?: ModelProviderImageUrl; +} + export interface ModelProviderMessage { role: string; - content: string; + content: string | ModelProviderContentPart[]; } interface AnthropicRequestBody { @@ -261,9 +279,6 @@ export class ModelProviderGeneralChatAdapter implements GeneralChatPort { history: ChatMessage[] = [], observer?: GeneralChatStreamObserver ): Promise { - if (images.length > 0) { - throw new Error(RemoteI18n.t('generalChat.imageNotSupported')); - } const config = await this.configStore.activeSnapshot(); const apiKey = (await this.configStore.activeAccessToken()).trim(); if (config.apiUrl.length === 0 || config.modelName.length === 0 || apiKey.length === 0) { @@ -277,14 +292,14 @@ export class ModelProviderGeneralChatAdapter implements GeneralChatPort { text: userText, status: 'pending', timestamp: new Date().toISOString(), - images: [] + images: images.slice() }; if (observer) { await observer.onUserMessage(userMessage); } - const messages = ModelProviderGeneralChatAdapter.toProviderMessages(history); - messages.push({ role: 'user', content: userText }); const protocol = ModelProviderGeneralChatAdapter.resolveProtocol(config.apiUrl); + const messages = ModelProviderGeneralChatAdapter.toProviderMessages(history, protocol); + messages.push(ModelProviderGeneralChatAdapter.userProviderMessage(userText, images, protocol)); const parser = new ModelProviderSseParser(protocol); const streamChunks: string[] = []; let assistantText = ''; @@ -495,19 +510,110 @@ export class ModelProviderGeneralChatAdapter implements GeneralChatPort { throw new Error(RemoteI18n.f('generalChat.requestFailed', `${statusCode}`)); } - private static toProviderMessages(history: ChatMessage[]): ModelProviderMessage[] { + private static toProviderMessages( + history: ChatMessage[], + protocol: ModelProviderProtocol + ): ModelProviderMessage[] { const messages: ModelProviderMessage[] = []; history.forEach((message: ChatMessage) => { - const text = (message.text || '').trim(); const status = (message.status || '').toLowerCase(); - if (status !== 'failed' && - (message.role === 'user' || message.role === 'assistant') && text.length > 0) { - messages.push({ role: message.role, content: text }); + if (status === 'failed' || (message.role !== 'user' && message.role !== 'assistant')) { + return; + } + const text = (message.text || '').trim(); + if (message.role === 'user') { + const providerMessage = ModelProviderGeneralChatAdapter.userProviderMessage( + text, + message.images || [], + protocol + ); + if (ModelProviderGeneralChatAdapter.hasProviderContent(providerMessage)) { + messages.push(providerMessage); + } + return; + } + if (text.length > 0) { + messages.push({ role: 'assistant', content: text }); } }); return messages; } + private static userProviderMessage( + text: string, + images: ImageAttachment[], + protocol: ModelProviderProtocol + ): ModelProviderMessage { + const parts: ModelProviderContentPart[] = []; + if (protocol === ModelProviderProtocol.OpenAi && text.length > 0) { + parts.push({ type: 'text', text }); + } + images.forEach((image: ImageAttachment) => { + const dataUrl = (image.data_url || '').trim(); + if (dataUrl.length === 0) { + return; + } + const mediaType = ModelProviderGeneralChatAdapter.imageMediaType(dataUrl, image.mime_type); + const rawBase64 = ModelProviderGeneralChatAdapter.imageBase64(dataUrl); + if (rawBase64.length === 0) { + return; + } + if (protocol === ModelProviderProtocol.Anthropic) { + const source: ModelProviderImageSource = { + type: 'base64', + media_type: mediaType, + data: rawBase64 + }; + parts.push({ type: 'image', source }); + return; + } + const imageUrl: ModelProviderImageUrl = { + url: dataUrl.indexOf('data:') === 0 ? dataUrl : `data:${mediaType};base64,${rawBase64}` + }; + parts.push({ type: 'image_url', image_url: imageUrl }); + }); + if (protocol === ModelProviderProtocol.Anthropic && text.length > 0) { + parts.push({ type: 'text', text }); + } + if (parts.length === 0) { + return { role: 'user', content: text }; + } + if (parts.length === 1 && parts[0].type === 'text') { + return { role: 'user', content: text }; + } + return { role: 'user', content: parts }; + } + + private static hasProviderContent(message: ModelProviderMessage): boolean { + if (typeof message.content === 'string') { + return message.content.trim().length > 0; + } + return message.content.length > 0; + } + + private static imageMediaType(dataUrl: string, explicitMime?: string): string { + const trimmed = (explicitMime || '').trim(); + if (trimmed.length > 0) { + return trimmed; + } + if (dataUrl.indexOf('data:') === 0) { + const semicolon = dataUrl.indexOf(';'); + if (semicolon > 5) { + return dataUrl.slice(5, semicolon); + } + } + return 'image/jpeg'; + } + + private static imageBase64(dataUrl: string): string { + const marker = ';base64,'; + const index = dataUrl.indexOf(marker); + if (index >= 0) { + return dataUrl.slice(index + marker.length); + } + return dataUrl; + } + private static titleFromMessage(text: string): string { const normalized = text.trim().replace(/\s+/g, ' '); if (normalized.length === 0) { diff --git a/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/DeviceSmoke.test.ets b/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/DeviceSmoke.test.ets index 9ffaf9046..61756211d 100644 --- a/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/DeviceSmoke.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/ohosTest/ets/test/DeviceSmoke.test.ets @@ -21,7 +21,7 @@ export default function deviceSmokeTest() { await driver.click(130, 180); await driver.delayMs(300); - const remoteEntry = await driver.findComponent(ON.text('Remote')); + const remoteEntry = await driver.findComponent(ON.id('conversation-source-remote')); expect(remoteEntry !== undefined).assertTrue(); await remoteEntry.click(); await driver.delayMs(900); diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets new file mode 100644 index 000000000..d645eba24 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -0,0 +1,297 @@ +import { describe, it, expect } from '@ohos/hypium'; +import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; +import { ChatComposerPolicy, ComposerPrimaryAction } from '../main/ets/services/ChatComposerPolicy'; +import { ChatSurface } from '../main/ets/pages/state/ChatSurface'; +import { + GENERAL_CHAT_COMPOSER_CAPABILITIES, + REMOTE_CHAT_COMPOSER_CAPABILITIES, + REMOTE_CREATE_COMPOSER_CAPABILITIES +} from '../main/ets/pages/state/ChatComposerCapabilities'; +import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../main/ets/pages/state/RemoteCreateSessionState'; +import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; +import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; +import { ConversationHeaderPolicy } from '../main/ets/pages/policy/ConversationHeaderPolicy'; +import { ThinkingPresentationPolicy } from '../main/ets/pages/policy/ThinkingPresentationPolicy'; +import { + ConnectSheetLandingPolicy, + ConnectSheetStatusPolicy, + ConnectSheetStep +} from '../main/ets/pages/policy/ConnectSheetLandingPolicy'; +import { + RemoteConnectEntryKind, + RemoteSurfaceEntryPolicy +} from '../main/ets/pages/policy/RemoteSurfaceEntryPolicy'; +import { RemoteCompactHomePolicy } from '../main/ets/pages/policy/RemoteCompactHomePolicy'; +import { AccountDeviceSelectionPolicy } from '../main/ets/pages/policy/AccountDeviceSelectionPolicy'; +import { CONNECT_INTENT_AUTO, CONNECT_INTENT_SCAN } from '../main/ets/pages/state/AppShellState'; +import { + ConnectScanDecisionPolicy, + DetectedUrlAction +} from '../main/ets/services/ConnectScanDecisionPolicy'; + +export default function conversationPresentationUnitTest() { + describe('Local conversation empty home', () => { + it('keeps local chat on a blank timeline instead of suggestion chips', 0, () => { + const projection = ConversationViewState.project( + AppRoute.ChatHome, + new RemotePageState(), + new GeneralChatPageState(), + '' + ); + + expect(projection.surface).assertEqual(ChatSurface.General); + expect(projection.showSuggestionsWhenEmpty).assertFalse(); + expect(projection.timelineItems.length).assertEqual(0); + }); + }); + + describe('ThinkingPresentationPolicy', () => { + it('treats active and running as in-progress thinking', 0, () => { + expect(ThinkingPresentationPolicy.isRunning('active')).assertTrue(); + expect(ThinkingPresentationPolicy.isRunning('running')).assertTrue(); + expect(ThinkingPresentationPolicy.isRunning('ACTIVE')).assertTrue(); + expect(ThinkingPresentationPolicy.isRunning('complete')).assertFalse(); + expect(ThinkingPresentationPolicy.isRunning('')).assertFalse(); + }); + + it('hides the block when thinking is done and the body is empty', 0, () => { + expect(ThinkingPresentationPolicy.shouldRender('complete', '')).assertFalse(); + expect(ThinkingPresentationPolicy.shouldRender('complete', ' ')).assertFalse(); + }); + + it('keeps the block visible while running or when a body remains', 0, () => { + expect(ThinkingPresentationPolicy.shouldRender('running', '')).assertTrue(); + expect(ThinkingPresentationPolicy.shouldRender('complete', '先看现有会话结构')).assertTrue(); + }); + + it('expands by default only while running unless keepExpandedWhenDone is set', 0, () => { + expect(ThinkingPresentationPolicy.defaultExpanded(true, false)).assertTrue(); + expect(ThinkingPresentationPolicy.defaultExpanded(false, false)).assertFalse(); + expect(ThinkingPresentationPolicy.defaultExpanded(false, true)).assertTrue(); + }); + }); + + describe('ChatComposerPolicy add button', () => { + it('hides the add control unless attachments are supported', 0, () => { + expect(ChatComposerPolicy.shouldShowAddButton(true, false)).assertFalse(); + expect(ChatComposerPolicy.shouldShowAddButton(false, true)).assertFalse(); + expect(ChatComposerPolicy.shouldShowAddButton(true, true)).assertTrue(); + }); + + it('lets local chat pick images because the composer can honour attachments', 0, () => { + expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.surface).assertEqual(ChatSurface.General); + expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertTrue(); + expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.showAddButton).assertTrue(); + expect(ChatComposerPolicy.shouldShowAddButton( + GENERAL_CHAT_COMPOSER_CAPABILITIES.showAddButton, + GENERAL_CHAT_COMPOSER_CAPABILITIES.supportsAttachments + )).assertTrue(); + expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.showAddButton).assertTrue(); + expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertTrue(); + }); + + it('names the primary composer action for assistive labels', 0, () => { + expect(ChatComposerPolicy.primaryActionAccessibilityKey(ComposerPrimaryAction.Send, false)) + .assertEqual('chat.send'); + expect(ChatComposerPolicy.primaryActionAccessibilityKey(ComposerPrimaryAction.Stop, false)) + .assertEqual('chat.stop'); + expect(ChatComposerPolicy.primaryActionAccessibilityKey(ComposerPrimaryAction.Stop, true)) + .assertEqual('chat.stopListening'); + expect(ChatComposerPolicy.primaryActionAccessibilityKey(ComposerPrimaryAction.Voice, false)) + .assertEqual('chat.voiceInput'); + }); + }); + + describe('Conversation source naming', () => { + it('uses the same local/remote pair across the switcher and remote chrome', 0, () => { + expect(RemoteI18n.t('sidebar.local')).assertEqual('本地'); + expect(RemoteI18n.t('sidebar.code')).assertEqual('远程'); + expect(RemoteI18n.t('sidebar.code')).assertEqual(RemoteI18n.t('remote.title')); + expect(RemoteI18n.t('connect.accountDevicesSubtitle')).assertEqual('远程'); + expect(RemoteI18n.t('chat.remoteSession')).assertEqual('远程会话'); + }); + + it('does not keep leftover pending-copy keys that claim general chat is unavailable', 0, () => { + expect(RemoteI18n.t('chatHome.generalPending')).assertEqual('chatHome.generalPending'); + expect(RemoteI18n.t('chatHome.generalAdvicePending')).assertEqual('chatHome.generalAdvicePending'); + }); + }); + + describe('ConnectSheetLandingPolicy', () => { + it('opens unsigned-in automatic entry on the explainer, not the camera', 0, () => { + expect(ConnectSheetLandingPolicy.initialStep(CONNECT_INTENT_AUTO, false)) + .assertEqual(ConnectSheetStep.Intro); + expect(ConnectSheetLandingPolicy.initialStep(CONNECT_INTENT_AUTO, true)) + .assertEqual(ConnectSheetStep.Account); + expect(ConnectSheetLandingPolicy.initialStep(CONNECT_INTENT_SCAN, false)) + .assertEqual(ConnectSheetStep.Scan); + expect(ConnectSheetLandingPolicy.initialStep(CONNECT_INTENT_SCAN, true)) + .assertEqual(ConnectSheetStep.Scan); + }); + + it('keeps the current pairing step while a URL is present or pairing is busy', 0, () => { + expect(ConnectSheetLandingPolicy.visibleStep(ConnectSheetStep.Intro, false)) + .assertEqual(ConnectSheetStep.Intro); + expect(ConnectSheetLandingPolicy.visibleStep(ConnectSheetStep.Scan, false)) + .assertEqual(ConnectSheetStep.Scan); + expect(ConnectSheetLandingPolicy.visibleStep(ConnectSheetStep.Account, true)) + .assertEqual(ConnectSheetStep.Account); + expect(ConnectSheetLandingPolicy.visibleStep(ConnectSheetStep.Account, false)) + .assertEqual(ConnectSheetStep.Intro); + }); + + it('maps a failed pair to the matching reconnect hint', 0, () => { + expect(ConnectSheetStatusPolicy.failureHintKey('expired_room')) + .assertEqual('connect.hintExpiredRoom'); + expect(ConnectSheetStatusPolicy.failureHintKey('network')) + .assertEqual('connect.hintNetwork'); + expect(ConnectSheetStatusPolicy.isConnectError('failed', false, false, '', '失败', '', '')) + .assertTrue(); + expect(ConnectSheetStatusPolicy.isConnectError('pairing', true, false, '', '失败', '', '')) + .assertFalse(); + }); + }); + + describe('RemoteSurfaceEntryPolicy', () => { + it('treats switching to remote as navigation and keeps connect as an explicit action', 0, () => { + expect(RemoteSurfaceEntryPolicy.shouldOpenConnectSheet(RemoteConnectEntryKind.EnterSurface)) + .assertFalse(); + expect(RemoteSurfaceEntryPolicy.shouldOpenConnectSheet(RemoteConnectEntryKind.OpenConnect)) + .assertTrue(); + expect(RemoteSurfaceEntryPolicy.shouldOpenConnectSheet(RemoteConnectEntryKind.OpenScan)) + .assertTrue(); + }); + }); + + describe('RemoteCompactHomePolicy', () => { + it('keeps pick-session chrome only while the desktop link is actually connected', 0, () => { + expect(RemoteCompactHomePolicy.shouldShowConnectHome('connected')).assertFalse(); + }); + + it('opens the connect home after the desktop drops even when cached sessions remain', 0, () => { + expect(RemoteCompactHomePolicy.shouldShowConnectHome('reconnecting')).assertTrue(); + expect(RemoteCompactHomePolicy.shouldShowConnectHome('failed')).assertTrue(); + expect(RemoteCompactHomePolicy.shouldShowConnectHome('disconnected')).assertTrue(); + expect(RemoteCompactHomePolicy.shouldShowConnectHome('idle')).assertTrue(); + expect(RemoteCompactHomePolicy.headerSubtitle('reconnecting', 'Studio')).assertEqual(''); + expect(RemoteCompactHomePolicy.headerSubtitle('connected', 'Studio')).assertEqual('Studio'); + }); + }); + + describe('AccountDeviceSelectionPolicy', () => { + it('lets an online desktop be selected unless this phone is already switching', 0, () => { + expect(AccountDeviceSelectionPolicy.canSelectOnline(true, 'desk-1', 'phone-1', '')).assertTrue(); + expect(AccountDeviceSelectionPolicy.canSelectOnline(false, 'desk-1', 'phone-1', '')).assertFalse(); + expect(AccountDeviceSelectionPolicy.canSelectOnline(true, 'phone-1', 'phone-1', '')).assertFalse(); + expect(AccountDeviceSelectionPolicy.canSelectOnline(true, 'desk-1', 'phone-1', 'desk-1')).assertFalse(); + }); + + it('offers connect on device management for another online desktop or the last target after drop', 0, () => { + expect(AccountDeviceSelectionPolicy.shouldShowConnectAction(true, 'desk-1', 'phone-1', 'desk-1', 'connected')) + .assertFalse(); + expect(AccountDeviceSelectionPolicy.shouldShowConnectAction(true, 'desk-1', 'phone-1', 'desk-1', 'failed')) + .assertTrue(); + expect(AccountDeviceSelectionPolicy.shouldShowConnectAction(true, 'desk-2', 'phone-1', 'desk-1', 'connected')) + .assertTrue(); + expect(AccountDeviceSelectionPolicy.shouldShowConnectAction(false, 'desk-1', 'phone-1', 'desk-1', 'failed')) + .assertTrue(); + expect(AccountDeviceSelectionPolicy.shouldShowConnectAction(false, 'desk-2', 'phone-1', 'desk-1', 'failed')) + .assertFalse(); + }); + + it('reconnects the last control target when it is no longer live', 0, () => { + expect(AccountDeviceSelectionPolicy.shouldReconnectLastTarget('desk-1', 'desk-1', 'failed')).assertTrue(); + expect(AccountDeviceSelectionPolicy.shouldReconnectLastTarget('desk-1', 'desk-1', 'connected')).assertFalse(); + expect(AccountDeviceSelectionPolicy.shouldReconnectLastTarget('desk-2', 'desk-1', 'failed')).assertFalse(); + }); + }); + + describe('ConnectScanDecisionPolicy', () => { + it('pairs an ordinary QR immediately without asking for an account password', 0, () => { + expect(ConnectScanDecisionPolicy.decide(false, false, '', '', '')) + .assertEqual(DetectedUrlAction.PAIR_NOW); + expect(ConnectScanDecisionPolicy.decide(false, true, 'alice', '', 'desktop-1')) + .assertEqual(DetectedUrlAction.PAIR_NOW); + }); + + it('asks for a password only when the QR needs account auth and this phone is signed out', 0, () => { + expect(ConnectScanDecisionPolicy.decide(true, false, '', 'alice', 'desktop-1')) + .assertEqual(DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD); + }); + + it('reuses the signed-in cloud session instead of asking for the password again', 0, () => { + expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', 'alice', 'desktop-1')) + .assertEqual(DetectedUrlAction.USE_CLOUD_DEVICE); + expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', 'alice', '')) + .assertEqual(DetectedUrlAction.SHOW_CLOUD_DEVICES); + expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', '', 'desktop-1')) + .assertEqual(DetectedUrlAction.USE_CLOUD_DEVICE); + }); + + it('still asks for a password when the QR belongs to a different account', 0, () => { + expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', 'bob', 'desktop-1')) + .assertEqual(DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD); + }); + }); + + describe('ConversationHeaderPolicy', () => { + it('keeps local chat on a single-line title and only shows actions once a timeline exists', 0, () => { + const empty = ConversationHeaderPolicy.present(ChatSurface.General, '今日计划', '', '', false); + expect(empty.fallbackKey).assertEqual('app.title'); + expect(empty.title).assertEqual('今日计划'); + expect(empty.subtitle).assertEqual(''); + expect(empty.allowRename).assertFalse(); + expect(empty.showActions).assertFalse(); + const withTimeline = ConversationHeaderPolicy.present(ChatSurface.General, '今日计划', '', '', true); + expect(withTimeline.showActions).assertTrue(); + }); + + it('gives remote chat a subtitle, rename, and a persistent overflow control', 0, () => { + const named = ConversationHeaderPolicy.present(ChatSurface.Remote, '修登录', 'Studio', 'main', false); + expect(named.fallbackKey).assertEqual('chat.remoteSession'); + expect(named.subtitle).assertEqual('Studio'); + expect(named.allowRename).assertTrue(); + expect(named.showActions).assertTrue(); + const branded = ConversationHeaderPolicy.present(ChatSurface.Remote, '', '', 'feat/ui', false); + expect(branded.subtitle).assertEqual('BitFun · feat/ui'); + }); + + it('treats create as a titled conversation chrome without actions or rename', 0, () => { + const create = ConversationHeaderPolicy.present(ChatSurface.Create, 'ignored', 'Studio', 'main', true); + expect(create.fallbackKey).assertEqual('remote.create.title'); + expect(create.title).assertEqual(''); + expect(create.subtitle).assertEqual(''); + expect(create.allowRename).assertFalse(); + expect(create.showActions).assertFalse(); + }); + }); + + describe('Create conversation projection', () => { + it('projects remote create onto the shared conversation view with create capabilities', 0, () => { + const remote = new RemotePageState(); + remote.desktopName = 'Studio'; + const create = new RemoteCreateSessionState(); + create.setDraft('hello from create'); + create.setSelectedModelId('model-1'); + create.isSubmitting = true; + create.errorText = 'device mismatch'; + const projection = ConversationViewState.project( + AppRoute.RemoteCreate, + remote, + new GeneralChatPageState(), + '', + create + ); + expect(projection.surface).assertEqual(ChatSurface.Create); + expect(projection.composerCapabilities.supportsAttachments).assertFalse(); + expect(projection.composerCapabilities.showAddButton).assertFalse(); + expect(projection.chatInput).assertEqual('hello from create'); + expect(projection.isBusy).assertTrue(); + expect(projection.inlineStatusText).assertEqual('device mismatch'); + expect(projection.timelineItems.length).assertEqual(0); + expect(projection.desktopName).assertEqual('Studio'); + }); + }); +} 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 b941f9c53..761653212 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -89,8 +89,8 @@ import { ConversationController } from '../main/ets/pages/viewmodel/Conversation import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES -} from '../main/ets/pages/components/ChatComposerCapabilities'; -import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; +} from '../main/ets/pages/state/ChatComposerCapabilities'; +import { ChatSurface } from '../main/ets/pages/state/ChatSurface'; import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; import { AppNavigationBackAction, @@ -1094,6 +1094,9 @@ export default function conversationStateUnitTest() { expect(generalProjection.surface).assertEqual(ChatSurface.General); expect(generalProjection.chatInput).assertEqual('general draft'); expect(generalProjection.inlineStatusText).assertEqual('Configure model'); + expect(generalProjection.showSuggestionsWhenEmpty).assertFalse(); + expect(generalProjection.composerCapabilities.supportsAttachments).assertTrue(); + expect(generalProjection.composerCapabilities.showAddButton).assertTrue(); }); }); diff --git a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets index 4aece46c0..72cff66bc 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets @@ -86,8 +86,8 @@ import { ConversationViewState } from '../main/ets/pages/state/ConversationViewS import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES -} from '../main/ets/pages/components/ChatComposerCapabilities'; -import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; +} from '../main/ets/pages/state/ChatComposerCapabilities'; +import { ChatSurface } from '../main/ets/pages/state/ChatSurface'; import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; import { AppNavigationBackAction, diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets index 92e4aac37..0204acf61 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets @@ -97,8 +97,8 @@ import { ConversationViewState } from '../main/ets/pages/state/ConversationViewS import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES -} from '../main/ets/pages/components/ChatComposerCapabilities'; -import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; +} from '../main/ets/pages/state/ChatComposerCapabilities'; +import { ChatSurface } from '../main/ets/pages/state/ChatSurface'; import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; import { AppNavigationBackAction, diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets index adea80e19..b7b41ed17 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets @@ -1,5 +1,6 @@ import transportAndGeneralChatUnitTest from './TransportAndGeneralChatUnit.test'; import conversationStateUnitTest from './ConversationStateUnit.test'; +import conversationPresentationUnitTest from './ConversationPresentationUnit.test'; import lifecycleUnitTest from './LifecycleUnit.test'; import appRootLifecycleUnitTest from './AppRootLifecycleUnit.test'; import remoteControllersUnitTest from './RemoteControllersUnit.test'; @@ -8,6 +9,7 @@ import appRootRuntimeStartupUnitTest from './AppRootRuntimeStartupUnit.test'; export default function localUnitTest() { transportAndGeneralChatUnitTest(); conversationStateUnitTest(); + conversationPresentationUnitTest(); lifecycleUnitTest(); appRootLifecycleUnitTest(); remoteControllersUnitTest(); 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 7e4ded06f..59f6637d4 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -128,13 +128,13 @@ import { ConversationModelPresentationPolicy } from '../main/ets/pages/policy/Co import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES -} from '../main/ets/pages/components/ChatComposerCapabilities'; -import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; +} from '../main/ets/pages/state/ChatComposerCapabilities'; +import { ChatSurface } from '../main/ets/pages/state/ChatSurface'; import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; import { ConversationUiModel, ConversationUiModelCatalog -} from '../main/ets/pages/components/ConversationUiModels'; +} from '../main/ets/pages/state/ConversationUiModels'; import { AppNavigationBackAction, AppNavigationPathSpec, @@ -777,6 +777,27 @@ export default function remoteControllersUnitTest() { expect(threshold.conversationPaneWidth).assertEqual(360); expect(threshold.previewPaneWidth).assertEqual(360); }); + + it('aligns dual-fold preview focus to the single vertical crease', 0, () => { + const layout = FilePreviewPlacementPolicy.resolveLayout(true, true, 2210, [ + new ConversationLayoutCrease(1100, 16) + ]); + expect(layout.placement).assertEqual(FilePreviewPlacement.WideFocusSplit); + expect(layout.masterPaneWidth).assertEqual(0); + expect(layout.conversationPaneWidth).assertEqual(1100); + expect(layout.conversationPreviewGap).assertEqual(16); + expect(layout.previewPaneWidth).assertEqual(1094); + }); + + it('falls back from crease-aligned focus when one side is too narrow', 0, () => { + const layout = FilePreviewPlacementPolicy.resolveLayout(true, true, 900, [ + new ConversationLayoutCrease(200, 8) + ]); + expect(layout.placement).assertEqual(FilePreviewPlacement.WideFocusSplit); + expect(layout.conversationPaneWidth).assertEqual(449); + expect(layout.conversationPreviewGap).assertEqual(1); + expect(layout.previewPaneWidth).assertEqual(450); + }); }); describe('RemoteCreateSessionState', () => { @@ -1989,7 +2010,7 @@ export default function remoteControllersUnitTest() { it('keeps general chat independent from remote-only composer requirements', 0, () => { expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.surface).assertEqual(ChatSurface.General); - expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertFalse(); + expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertTrue(); expect(GENERAL_CHAT_COMPOSER_CAPABILITIES.requiresRemoteConnection).assertFalse(); // Nothing queues a message on this surface, so a draft written mid-reply // has nowhere to go until the reply finishes. @@ -2427,7 +2448,7 @@ export default function remoteControllersUnitTest() { )).assertTrue(); }); - it('keeps phone, single-fold, and dual-screen foldables compact', 0, () => { + it('keeps crease-less phones compact even when the viewport is extra-wide', 0, () => { expect(ConversationLayoutPolicy.useMasterDetail( ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, true, @@ -2435,19 +2456,47 @@ export default function remoteControllersUnitTest() { 'phone', [] )).assertFalse(); + }); + + it('uses master-detail for unfolded dual-fold surfaces', 0, () => { expect(ConversationLayoutPolicy.useMasterDetail( ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, true, false, 'phone', [new ConversationLayoutCrease(520, 8)] - )).assertFalse(); + )).assertTrue(); + expect(ConversationLayoutPolicy.useMasterDetail( + 2210, + false, + false, + 'phone', + [new ConversationLayoutCrease(1100, 16)] + )).assertTrue(); expect(ConversationLayoutPolicy.useMasterDetail( ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, true, false, 'tablet', [new ConversationLayoutCrease(520, 8)] + )).assertTrue(); + expect(ConversationLayoutPolicy.useMasterDetail( + 2210, + false, + false, + 'phone', + [], + true + )).assertTrue(); + }); + + it('keeps an unfolded crease compact when master-detail cannot fit', 0, () => { + expect(ConversationLayoutPolicy.useMasterDetail( + 500, + true, + false, + 'phone', + [new ConversationLayoutCrease(200, 8)] )).assertFalse(); }); 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 22dcfcb62..123935c56 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -53,7 +53,7 @@ import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelP import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; import { MarkdownParser } from '../main/ets/services/MarkdownParser'; import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; -import { toRemoteQuestionAnswer } from '../main/ets/pages/components/ConversationUiModels'; +import { toRemoteQuestionAnswer } from '../main/ets/pages/state/ConversationUiModels'; import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; @@ -92,8 +92,8 @@ import { ConversationViewState } from '../main/ets/pages/state/ConversationViewS import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES -} from '../main/ets/pages/components/ChatComposerCapabilities'; -import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; +} from '../main/ets/pages/state/ChatComposerCapabilities'; +import { ChatSurface } from '../main/ets/pages/state/ChatSurface'; import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; import { AppNavigationBackAction, @@ -266,6 +266,10 @@ class InMemorySettingsConfigStore extends GeneralChatConfigStore { async activeSnapshot(): Promise { return this.snapshotResult; } + + async activeAccessToken(): Promise { + return this.accessTokenResult; + } } export default function transportAndGeneralChatUnitTest() { @@ -357,6 +361,17 @@ export default function transportAndGeneralChatUnitTest() { requiresAccountAuth: false })).assertTrue(); }); + + it('does not prompt for an account password when this phone already has that cloud session', 0, () => { + const policy = new RemotePairingPolicy(); + const descriptor = RemoteDescriptorParser.parse( + 'https://relay.example.com/#/pair?room=room-a&pk=key-a&auth=account&user=alice' + ); + + expect(policy.shouldPromptForAccount(descriptor)).assertTrue(); + expect(policy.shouldPromptForAccount(descriptor, true, 'alice')).assertFalse(); + expect(policy.shouldPromptForAccount(descriptor, true, 'bob')).assertTrue(); + }); }); describe('X25519', () => { @@ -1049,6 +1064,93 @@ export default function transportAndGeneralChatUnitTest() { expect(openAiBody.indexOf('"role":"system"')).assertLarger(-1); expect(openAiBody.indexOf('blockchain')).assertLarger(-1); }); + + it('sends attached images on the Anthropic-compatible local chat path', 0, async () => { + const config = new InMemorySettingsConfigStore(); + config.snapshotResult = { + apiUrl: 'https://api.openbitfun.com', + modelName: 'vision-model', + hasApiKey: true + }; + config.accessTokenResult = 'sk-test-token'; + const transport = new FakeModelProviderHttpTransport(); + transport.responses.push(modelProviderRecordedResponse( + 200, + 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"图里是一只猫"}}\n\n' + )); + const adapter = new ModelProviderGeneralChatAdapter(config, transport); + const image = imageAttachment('cat.png', 'data:image/png;base64,aGVsbG8='); + + const result = await adapter.sendMessage('chat-1', '这是什么', [image]); + + expect(transport.requests.length).assertEqual(1); + expect(transport.requests[0].body.indexOf('"type":"image"')).assertLarger(-1); + expect(transport.requests[0].body.indexOf('"media_type":"image/png"')).assertLarger(-1); + expect(transport.requests[0].body.indexOf('"data":"aGVsbG8="')).assertLarger(-1); + expect(transport.requests[0].body.indexOf('data:image/png;base64,aGVsbG8=')).assertEqual(-1); + expect(transport.requests[0].body.indexOf('这是什么')).assertLarger(-1); + expect((result.userMessage.images || []).length).assertEqual(1); + expect(result.assistantMessage.text).assertEqual('图里是一只猫'); + }); + + it('sends attached images on the OpenAI-compatible local chat path', 0, async () => { + const config = new InMemorySettingsConfigStore(); + config.snapshotResult = { + apiUrl: 'https://llm.example.com/v1', + modelName: 'gpt-vision', + hasApiKey: true + }; + config.accessTokenResult = 'sk-openai'; + const transport = new FakeModelProviderHttpTransport(); + transport.responses.push(modelProviderRecordedResponse( + 200, + 'data: {"id":"cmpl-1","choices":[{"delta":{"content":"A screenshot"}}]}\n\n' + )); + const adapter = new ModelProviderGeneralChatAdapter(config, transport); + + await adapter.sendMessage( + 'chat-1', + 'describe this', + [imageAttachment('ui.jpg', 'data:image/jpeg;base64,dGVzdA==')] + ); + + expect(transport.requests[0].protocol).assertEqual(ModelProviderProtocol.OpenAi); + expect(transport.requests[0].body.indexOf('"type":"image_url"')).assertLarger(-1); + expect(transport.requests[0].body.indexOf('data:image/jpeg;base64,dGVzdA==')).assertLarger(-1); + expect(transport.requests[0].body.indexOf('describe this')).assertLarger(-1); + }); + + it('replays prior local-chat images when continuing a conversation', 0, async () => { + const config = new InMemorySettingsConfigStore(); + config.snapshotResult = { + apiUrl: 'https://api.openbitfun.com', + modelName: 'vision-model', + hasApiKey: true + }; + config.accessTokenResult = 'sk-test-token'; + const transport = new FakeModelProviderHttpTransport(); + transport.responses.push(modelProviderRecordedResponse( + 200, + 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"还是同一只猫"}}\n\n' + )); + const adapter = new ModelProviderGeneralChatAdapter(config, transport); + const history: ChatMessage[] = [ + chatMessageWithImage( + 'user-1', + 'user', + '这是什么', + 'cat.png', + 'data:image/png;base64,aGVsbG8=' + ), + chatMessage('assistant-1', 'assistant', '图里是一只猫', 'completed') + ]; + + await adapter.sendMessage('chat-1', '再看一眼颜色', [], history); + + expect(transport.requests[0].body.indexOf('"data":"aGVsbG8="')).assertLarger(-1); + expect(transport.requests[0].body.indexOf('再看一眼颜色')).assertLarger(-1); + expect(transport.requests[0].body.indexOf('图里是一只猫')).assertLarger(-1); + }); }); describe('ModelProviderSseParser', () => {