diff --git a/package.json b/package.json index 71bceae0b0..0e6d177815 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "models-dev:check": "node scripts/update-models-dev-snapshot.mjs --check", "models-dev:update": "node scripts/update-models-dev-snapshot.mjs", "check:build-prereqs": "node scripts/check-build-prereqs.mjs", + "harmony:architecture": "node scripts/check-harmonyos-architecture.mjs", "check:core-boundaries": "node scripts/check-core-boundaries.mjs", "check:core-boundaries:test": "node --test scripts/check-core-boundaries.test.mjs", "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs && node --test scripts/check-github-config.test.mjs", diff --git a/scripts/check-harmonyos-architecture.mjs b/scripts/check-harmonyos-architecture.mjs new file mode 100644 index 0000000000..2ccdeff49b --- /dev/null +++ b/scripts/check-harmonyos-architecture.mjs @@ -0,0 +1,341 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); +const etsRoot = path.join(repoRoot, 'src/apps/mobile/harmonyos/entry/src/main/ets'); +const pagesRoot = path.join(etsRoot, 'pages'); + +function walkEts(root) { + const entries = fs.readdirSync(root, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...walkEts(entryPath)); + } else if (entry.isFile() && entry.name.endsWith('.ets')) { + files.push(entryPath); + } + } + return files; +} + +function relative(file) { + return path.relative(repoRoot, file).split(path.sep).join('/'); +} + +function imports(file) { + const source = fs.readFileSync(file, 'utf8'); + const specs = [...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((match) => match[1]); + return specs.map((spec) => { + if (!spec.startsWith('.')) { + return spec; + } + return path.relative(etsRoot, path.resolve(path.dirname(file), spec)).split(path.sep).join('/'); + }); +} + +function filesUnder(root) { + return walkEts(root).sort(); +} + +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 serviceToPages = services + .filter((file) => imports(file).some((spec) => spec === 'pages' || spec.startsWith('pages/'))) + .map(relative); +const componentToViewmodel = components + .filter((file) => imports(file).some((spec) => spec === 'pages/viewmodel' || spec.startsWith('pages/viewmodel/'))) + .map(relative); +const viewmodelToComponents = viewmodels + .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); +const positionalActionConstructors = allPages + .filter((file) => /export\s+class\s+\w+(?:Actions|Hooks)\b/.test(fs.readFileSync(file, 'utf8')) && + /\bconstructor\s*\(/.test(fs.readFileSync(file, 'utf8'))) + .map(relative); +const sharedConversationFields = [ + 'sessions', + 'activeSession', + 'persistedMessages', + 'optimisticMessages', + 'activeTurnMessage', + 'hasMoreMessages', + 'timelineItems', + 'timelineRevision', + 'isBusy', + 'modelCatalog', + 'selectedModelId', + 'statusText', + 'chatInput', + 'selectedImages', + 'isVoiceListening' +]; +const conversationPageStateFiles = [ + path.join(pagesRoot, 'state/GeneralChatPageState.ets'), + path.join(pagesRoot, 'state/RemotePageState.ets') +]; +const duplicatedConversationTraceFields = conversationPageStateFiles.flatMap((file) => { + const source = fs.readFileSync(file, 'utf8'); + return sharedConversationFields + .filter((field) => new RegExp(`@Trace\\s+${field}\\s*:`).test(source)) + .map((field) => `${relative(file)}:${field}`); +}); +const appRootRuntimeFile = path.join(pagesRoot, 'runtime/AppRootRuntime.ets'); +const appRootRuntimeSource = fs.readFileSync(appRootRuntimeFile, 'utf8'); +const appRootRuntimeLines = appRootRuntimeSource.split(/\r?\n/).length - 1; +const appRootPresentationFile = path.join(pagesRoot, 'components/AppRootPresentation.ets'); +const appRootPresentationSource = fs.readFileSync(appRootPresentationFile, 'utf8'); +const appRootPresentationLines = appRootPresentationSource.split(/\r?\n/).length - 1; +const requiredPresentationFiles = [ + 'components/AppRootOverlaySurfaces.ets', + 'components/ChatMessageChrome.ets', + 'components/ConnectManualPairingOverlay.ets', + 'components/ConversationRouteSurface.ets', + 'components/ToolInteractionPanels.ets', + 'components/WideConversationHost.ets', + 'components/remote/RemoteSurfaceHost.ets' +]; +const missingPresentationFiles = requiredPresentationFiles + .filter((file) => !fs.existsSync(path.join(pagesRoot, file))); +const componentLineBudgets = [ + ['components/ChatMessageBubble.ets', 1000], + ['components/ConnectView.ets', 700], + ['components/ToolStatusList.ets', 1120] +]; +const extractedFilePreviewMethods = [ + 'openFilePreview', + 'closeFilePreview', + 'refreshFilePreview', + 'openFilePreviewLink', + 'invalidateFilePreviewTarget' +].filter((method) => new RegExp(`^\\s{2}${method}\\s*\\(`, 'm').test(appRootRuntimeSource)); +const extractedSettingsMethods = [ + 'saveGeneralChatConfig', + 'testGeneralChatConfig', + 'validateGeneralChatConfig', + 'probeGeneralChatConfig', + 'effectiveGeneralChatApiKey', + 'applyGeneralChatConfig', + 'refreshGeneralChatModelCatalog' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedCloudAccountMethods = [ + 'persistDelegatedAccountSession', + 'loginCloudAccount', + 'restoreCloudAccountSession', + 'loadGeneralChatAccountModels', + 'syncCloudAccount', + 'applyCloudAccountSession', + 'logoutCloudAccount', + 'listCloudAccountDevices', + 'getRemotePermissionMode', + 'setRemotePermissionMode', + 'restoreCloudTarget', + 'expireCloudAccountSession', + 'handleRemoteConnectionError', + 'selectCloudAccountDevice' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+|protected\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedConversationMethods = [ + 'isGeneralComposerRoute', + 'visibleChatInput', + 'visibleSelectedImages', + 'visibleVoiceListening', + 'setChatInputForRoute', + 'setSelectedImagesForRoute', + 'addSelectedImagesForRoute', + 'removeSelectedImageForRoute', + 'clearComposerForRoute', + 'setVoiceListeningForRoute', + 'setAllVoiceListening', + 'voiceInputSnapshot', + 'visibleChatBusy', + 'visibleStatusText', + 'setVisibleStatusText' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteConversationMethods = [ + 'sendChatMessage', + 'stopActiveTask', + 'renameActiveSession', + 'copyMessage', + 'downloadFile', + 'retryMessage', + 'approveTool', + 'rejectTool', + 'cancelTool', + 'answerQuestion', + 'resetChatTimeline', + 'syncChatTimelineFromStore', + 'startPolling', + 'currentChatPollingCursor', + 'updateChatPollingCursor', + 'applyChatSessionSnapshot', + 'hasRunningActiveTurn', + 'projectedTimelineItems', + 'syncAfterTurnEnded' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteCreateMethods = [ + 'createSession', + 'openRemoteCreateSession', + 'closeRemoteCreateSession', + 'loadRemoteCreateChoices', + 'loadRemoteCreateModelCatalog', + 'loadRemoteCreateDevices', + 'loadRemoteCreateWorkspaces', + 'toggleRemoteCreateDevices', + 'toggleRemoteCreateWorkspaces', + 'selectRemoteCreateDevice', + 'selectRemoteCreateWorkspace', + 'submitRemoteCreateSession', + 'createSessionInWorkspace', + 'openSession', + 'applyRemoteActiveSession', + 'deleteSession' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedGeneralConversationMethods = [ + 'openHomeSession', + 'openHomeSessionInPlace', + 'deleteHomeSession', + 'activeGeneralChatAsRemoteSession', + 'activeGeneralUploadedFileCount', + 'archiveHomeSession', + 'exportHomeSession', + 'openGeneralSession', + 'startGeneralChat', + 'sendVisibleChatMessage', + 'stopActiveChatTask', + 'closeActiveChat', + 'renameVisibleSession', + 'retryVisibleMessage', + 'downloadVisibleFile', + 'selectModel', + 'sendGeneralChatMessage', + 'stopGeneralChatStream', + 'startVisibleGeneralChat', + 'generalChatHomeStatusText', + 'prepareNewGeneralChat', + 'onVisibleChatInputChange', + 'visibleGeneralChatDraftId', + 'restoreGeneralChatDraft', + 'latestUserMessageText', + 'showHomeToast', + 'resetGeneralChatTimeline', + 'syncGeneralChatTimelineFromStore' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteConnectionForwards = [ + 'applyWorkspace', + 'applyRemotePairingProjection', + 'ensureRemoteAvailable', + 'setRemoteConnectionState', + 'setRemoteUrl', + 'setRemoteUserId', + 'setRemoteAuthenticatedUserId', + 'setRemoteStatusText', + 'setRemoteConnectionFailureKind', + 'setRemoteBusy', + 'setRemoteUrlInputVisible' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const appRootRuntimeStateGetters = [ + 'remoteUrl', 'userId', 'authenticatedUserId', 'statusText', 'connectionState', + 'connectionFailureKind', 'isBusy', 'showRemoteUrlInput', 'workspaceName', 'workspacePath', + 'workspaceBranch', 'workspaceKind', 'assistantId', 'desktopName', 'desktopId', 'activeSession', + 'messages', 'pendingMessages', 'activeTurnMessage', 'timelineItems', 'hasMoreMessages' +].filter((getter) => new RegExp(`^\\s{2}get\\s+${getter}\\s*\\(`, 'm').test(appRootRuntimeSource)); +const extractedOwnerForwards = [ + 'currentRoute', 'isRoute', 'isGeneralChatVisible', 'pushRoute', 'replaceRoute', 'popRoute', + 'handleConversationIntent', 'pasteRemoteUrl', 'scanRemoteUrl', 'handleDetectedRemoteUrl', + 'showRecentWorkspaces', 'showAssistants', 'refreshSessions', 'loadMoreSessions', 'setSessionFilter', + 'openAddConnection', 'selectRemoteCreateModel', 'loadRecentWorkspacesInBackground', + 'loadOlderMessages', 'removeSelectedImage', 'persistVisibleGeneralChatDraft', 'stopPolling', + 'nudgeChatPolling', 'pollActiveSession', 'startHeartbeat', 'stopHeartbeat', + 'checkConnectionHealth', 'resumeRemoteActivity' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); + +const expected = { + serviceToPages: [], + componentToViewmodel: [], + viewmodelToComponents: [], + v1Components: [], + positionalActionConstructors: [], + duplicatedConversationTraceFields: [], + extractedFilePreviewMethods: [], + extractedSettingsMethods: [], + extractedCloudAccountMethods: [], + extractedConversationMethods: [], + extractedRemoteConversationMethods: [], + extractedRemoteCreateMethods: [], + extractedGeneralConversationMethods: [], + extractedRemoteConnectionForwards: [], + appRootRuntimeStateGetters: [], + extractedOwnerForwards: [], + missingPresentationFiles: [] +}; + +function sameSet(actual, wanted) { + return actual.length === wanted.length && actual.every((item, index) => item === wanted[index]); +} + +const actual = { + serviceToPages, + componentToViewmodel, + viewmodelToComponents, + v1Components, + positionalActionConstructors, + duplicatedConversationTraceFields, + extractedFilePreviewMethods, + extractedSettingsMethods, + extractedCloudAccountMethods, + extractedConversationMethods, + extractedRemoteConversationMethods, + extractedRemoteCreateMethods, + extractedGeneralConversationMethods, + extractedRemoteConnectionForwards, + appRootRuntimeStateGetters, + extractedOwnerForwards, + missingPresentationFiles +}; +let failed = false; +for (const [name, wanted] of Object.entries(expected)) { + if (!sameSet(actual[name], wanted)) { + failed = true; + console.error(`${name} mismatch`); + console.error(`expected: ${JSON.stringify(wanted)}`); + console.error(`actual: ${JSON.stringify(actual[name])}`); + } +} +if (appRootRuntimeLines > 500) { + failed = true; + console.error(`AppRootRuntime line budget exceeded: expected <=500, actual=${appRootRuntimeLines}`); +} +if (appRootPresentationLines > 500) { + failed = true; + console.error(`AppRootPresentation line budget exceeded: expected <=500, actual=${appRootPresentationLines}`); +} +for (const [file, budget] of componentLineBudgets) { + const source = fs.readFileSync(path.join(pagesRoot, file), 'utf8'); + const lineCount = source.split(/\r?\n/).length - 1; + if (lineCount > budget) { + failed = true; + console.error(`${file} line budget exceeded: expected <=${budget}, actual=${lineCount}`); + } +} + +if (failed) { + process.exitCode = 1; +} else { + console.log('HarmonyOS architecture contracts are satisfied.'); +} diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index c177c473f1..15e9ccc1a3 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -2,6 +2,44 @@ These rules apply to all changes under `src/apps/mobile/harmonyos`. +## MVVM Refactor Boundaries + +This app has one `entry` module, so MVVM is the file-organization boundary for +the module. Keep the official responsibilities explicit: + +- Model/services own data access, persistence, transport, and business logic; + they do not import views or page components. +- Views own presentation and user input; they consume projected state and emit + 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. + +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 + `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 + components. +4. 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`, + `@Prop`, `@Link`, or `@Watch` declarations. `@BuilderParam` remains supported. +6. 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. + +The current local HarmonyOS verification loop is: + +```bash +source scripts/ohos-env.sh +"$HVIGORW" --mode module -p product=default -p module=entry@default assembleHap --no-daemon +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon +``` + ## Visual reference fidelity - Before drawing a system glyph, text approximation, or new bitmap, search the existing HarmonyOS media resources and the approved desktop reference images. Reuse the established asset when one exists. diff --git a/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md b/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md new file mode 100644 index 0000000000..8185fa3cff --- /dev/null +++ b/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md @@ -0,0 +1,575 @@ +# HarmonyOS 端 MVVM 架构重构设计 + +Date: 2026-08-06 + +Status: Implementation in progress; S0-S5 and S7 are complete, while S6 component decomposition and the wide-screen visual matrix remain pending + +Scope: `src/apps/mobile/harmonyos/entry/src/main/ets` + +Baseline: commit `6c35485bb`(窄屏 Local/Remote 统一完成后) + +Reference: 华为官方文档 +[MVVM模式(状态管理V2)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V13/arkts-mvvm-v2-V13)、 +[MVVM模式(V1)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-mvvm)、 +[状态管理(V1)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-state-management-v1) + +Related designs: + +- [`adaptive-conversation-ui-redesign.md`](adaptive-conversation-ui-redesign.md) +- [`wide-conversation-navigation-design.md`](wide-conversation-navigation-design.md) +- [`responsive-file-preview-design.md`](responsive-file-preview-design.md) +- [`native-code-preview-implementation-design.md`](native-code-preview-implementation-design.md) + +本文只负责**代码结构**,不改变任何用户可见行为。上述四篇设计继续负责路由合同、折痕几何、文件预览 placement 和会话 UI/UX;本文的每一个阶段都以"这些文档描述的行为在真机上完全不变"为验收前提。发生冲突时,以现有行为文档为准,重构方案让路。 + +--- + +## 0. 结论摘要 + +- **架构基准**:MVVM 是鸿蒙官方文档明确定义的模式,官方把它定位为**单模块内的文件组织方式**;整个应用的模块化官方推荐三层架构(products / features / commons)。本项目 `build-profile.json5` 只有一个 `entry` 模块,正落在 MVVM 覆盖的范围内——**MVVM 是本次重构正确且足够的框架,三层架构不在本次范围**。 +- **好消息**:ViewModel 层已经是干净的。7 个 `*ViewModel` 共 1379 行,**没有任何一个 import `components/`**。MVVM 里最难守住的一条,这里已经守住了。 +- **重构结果**:`services/` → `pages/`、`components` → `viewmodel`、`viewmodel` → `components` 当前均为零;运行时组合根已拆为 `AppRootRuntime` 与 `AppRootRuntimeComposition`,特性行为由四个 Controller 持有。 +- **一条被更正的判断**:初版诊断把"10 个 `components/*` import `../state/`"列为分层违规,**这是错的**,详见 §3.4。 +- **状态管理范式统一到 V2**:基线有 15 个 V1 struct(5114 行)与 19 个 V2 struct 混用;S5 已将这 15 个组件全部迁移到 V2。V2 是官方对新项目的推荐范式,也是官方 MVVM 示例的形式,详见 §2.8 与 §5 的 S5 阶段。 +- **实施方式**:S0–S7 八个阶段,每个阶段独立可发布、可回滚,前三个阶段零行为变更。 + +--- + +## 1. 架构基准 + +### 1.1 官方 MVVM 的三条职责界定 + +引自华为官方文档: + +- **model** —— 负责数据的获取和存储以及业务逻辑,**不与 view 关联**; +- **view** —— 负责界面展现和用户输入,**不与 model 关联**; +- **viewmodel** —— 作为连接二者的桥梁,负责将 model 数据转为 view 数据并管理界面状态。 + +官方 V2 示例的绑定形式是 `@ComponentV2` + `@Local` 持有 ViewModel 实例。 + +本文后续所有"违规"判定,都直接引用上面三句,不引入本文自创的架构偏好。 + +### 1.2 范围界定:MVVM vs 三层架构 + +官方对二者的分工是明确的: + +> MVVM 的目录组织方式一般适用于**单个模块内**的文件组织;为了更好地适配复杂应用开发,建议采用**三层架构**对**整个应用**功能进行模块化。 + +| 层级 | 编译产物 | 依赖约束 | +| --- | --- | --- | +| products(产品定制层) | Entry HAP | 可依赖 features / commons,禁止横向调用 | +| features(基础特性层) | HAR / HSP | 可依赖 commons,避免反向依赖 products | +| commons(公共能力层) | HAR / HSP | 不可依赖上层 | + +**本项目现状**:`build-profile.json5` 的 `modules` 只有 `entry` 一项,`compatibleSdkVersion 6.0.1(21)` / `targetSdkVersion 6.1.1(24)`。单模块 = MVVM 的适用范围。 + +**三层架构的引入时机**(记录,本次不做):当需要为不同设备形态提供差异化入口(折叠屏 / 平板 / 车机各自的 Entry HAP),或 `services/` 需要被鸿蒙端之外复用时,才是把 `services/` 抽成 commons HAR、把会话/Remote 抽成 features HSP 的时机。在只有一个 entry 的现在做这件事,只增加构建复杂度,不带来收益。 + +### 1.3 ArkTS/ArkUI 层面必须遵守的既有教训 + +这些是本模块已经付出过代价的约束,重构中任何一步都不得违反: + +1. **`@Builder` 的值参数不具备响应式**。只有按引用传入的单个对象参数才会驱动重渲染;builder 内部读 `this.` 才是可靠的。拆分 builder 时,凡是原先从父 builder 传入的宽度、来源等标量,一律改为在子 builder 内部读状态。 +2. **`NavPathStack` 不可观测**。任何存活于 `Navigation` 之外的界面(抽屉是典型)都不能靠它驱动刷新,必须消费 `AppShellState.activeRoute` 这个 `@Trace` 镜像。该镜像由 `AppShellViewModel.syncActiveRoute()` 统一维护,**新增导航路径必须经由 `AppShellViewModel`**。 +3. **V1 / V2 混用现状**:`@Component/@State/@Prop` 与 `@ComponentV2/@Local/@Param/@Event` 并存。本次**全量迁移到 V2**,范式统一后 §1.3.1 和 §1.3.2 两条约束的心智负担也随之下降(V2 的观测边界比 V1 明确)。分布数据见 §2.8,实施见 §5 的 S5 阶段。 + +--- + +## 2. 现状测量 + +以下全部为实测值,非估算。 + +### 2.1 规模基线 + +| 目录 | 文件数 | 行数 | +| --- | --- | --- | +| `pages/components` | 39 | 14518 | +| `services`(含 `general-chat` 21 / 3296) | 51 | 7492 | +| `pages/state` | 21 | 5645 | +| `i18n` | — | 581 | +| `model` | — | 465 | +| `pages/navigation` | — | 110 | +| 测试 `entry/src/test` | 8 | 6612 | + +### 2.2 `pages/state/` 的真实构成(一个目录装了三层) + +| 类别 | 文件 | 行数 | +| --- | --- | --- | +| ViewModel | `AppShellViewModel` 98、`ConversationViewModel` 22、`GeneralChatConversationViewModel` 336、`RemoteActivityViewModel` 163、`RemoteConnectionViewModel` 353、`RemoteSessionViewModel` 236、`RemoteWorkspaceViewModel` 171 | 1379 | +| State(`@ObservedV2` 绑定对象) | `AppShellState` 58、`ConversationViewState` 120、`FilePreviewState` 107、`GeneralChatPageState` 200、`RemoteCreateSessionState` 113、`RemotePageState` 373 | 971 | +| Policy(纯逻辑,零 `@Trace`) | `ConversationLayoutPolicy` 156、`FilePreviewPlacementPolicy` 185、`ConversationModelPresentationPolicy` 82、`ConversationSessionFilterPolicy` 51、`SessionActionPolicy` 31 | 505 | +| God Facade | `AppRootRuntime` | 2608 | +| 其他 | `ConversationIntentDispatcher`、`FilePreviewTarget` 等 | 约 182 | + +### 2.3 两个引力井的内部构成 + +**`AppRootPresentation.ets`(1449 行)**——可分离,各段落关注点互不相干: + +| 段落 | 行数 | 性质 | +| --- | --- | --- | +| 7 个 action DTO 定义(L62–270) | 209 | 属于 model 定义,不该在 view 文件里 | +| Remote UI builders | 305 | 一个独立特性面 | +| Remote 辅助方法 | 143 | 同上 | +| 宽屏几何计算 | 179 | 纯计算,可脱离 UI,当前零单测覆盖 | +| 宽屏 builders | 275 | 一个独立布局面 | + +共 25 个 `@Builder`、约 50 个私有方法、21 个 `@Local`(其中 13 个属于宽屏几何、8 个属于 Remote 过滤/元数据)。两组 `@Local` 混在同一 struct 内,意味着改宽屏分栏宽度会连带触发 Remote 过滤区重算。 + +**`AppRootRuntime.ets`(2608 行)**——性质不同,是"所有特性的门面开在同一个类上": + +- 183 个方法级条目,约 101 个 public,其中 **45 个是一行转发**; +- 75 个 import; +- 字段初始化块从 L140 延伸到 L761(621 行); +- 单个方法最长 `selectCloudAccountDevice` 91 行。 + +### 2.4 接线代码 + +12 个 `*Hooks` / `*Actions` 类:定义 412 行,在 `AppRootRuntime` 中的构造点 235 行,合计约 **650 行纯接线**。 + +其中 7 个定义在 `AppRootPresentation.ets` 内(L62–270,209 行)。构造点规模:`AppRootPresentationActions` 96 行、`RemoteSessionViewModelHooks` 46 行、`ConversationIntentDispatcherHooks` 39 行、两个 Hooks 各 23 行、一个 8 行。 + +全部为**位置参数构造**: + +```ts +new AppRootPresentationActions(a, b, c, d, /* …共 96 行实参 */) +``` + +代价不只是行数——新增一个回调要同步改三处(DTO 定义、构造点、消费点),且位置参数在 ArkTS 里没有编译期的名字保护:两个相邻的同签名回调若被调换顺序,编译通过、运行时行为错乱。这是本模块唯一一类"改对了也无法在编译期确认"的修改。 + +### 2.5 会话状态的重复 + +`GeneralChatPageState`(200)与 `RemotePageState`(373)有**约 15 个字段同名同义**。为了让上层统一消费,又长出两层扇入扇出: + +- `services/AppRootRouteState.ets`(88 行)——存在的唯一理由是在两者之间搬数据; +- `ConversationViewState.project(route, remote, general, …)`——再做一遍同样的归约; +- 分散各处的 `compact` 布尔与 `if (source === General)` 分支。 + +后果:每新增一项会话能力(附件、引用、重发……),要在两个 State 各写一次,再在两个投影层各接一次。 + +### 2.6 组件层 + +内联 glyph / icon builder 共 **538 行**,分布在 10 个文件:`AppSidebar` 179、`ConnectView` 99、`ToolStatusList` 91、`ConversationView` 61、`ChatMessageBubble` 35、`SessionActionSurface` 19、`CreateSessionSheet` 18,`ComposerBar` / `RemoteCreateSessionView` / `ChatTimeline` 各 12。 + +第二梯队大结构体:`ToolStatusList` 1442 行 / 16 builders、`ConnectView` 1344 / 24、`ChatMessageBubble` 1246 / 18、`AppSidebar` 908 / 29。 + +### 2.7 现有安全网 + +`entry/src/test/` 共 6612 行 hypium 用例: + +| 文件 | 行数 | +| --- | --- | +| `RemoteControllersUnit` | 2177 | +| `TransportAndGeneralChatUnit` | 1255 | +| `LocalTestFixtures` | 1078 | +| `ConversationStateUnit` | 1057 | +| `LifecycleUnit` | 748 | +| `AppRootLifecycleUnit` | 129 | +| `ArchitectureUnit` | 95 | +| `AppRootRuntimeStartupUnit` | 51 | + +本地运行方式(已实测通过,BUILD SUCCESSFUL 11s,报告落在 `entry/.test/default/outputs/test/reports/`): + +```bash +source scripts/ohos-env.sh +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon +``` + +注:现有 `ArchitectureUnit`(95 行)测的是**行为**(生成号失效、时间线归约、路由栈不变量),不是分层。分层目前无任何自动化约束。 + +### 2.8 V1 / V2 范式分布(重构前基线) + +**结构体**:V1(`@Component`)15 个,共 **5114 行**;V2(`@ComponentV2`)19 个。 + +**装饰器用量**: + +| V1 | 次数 | V2 | 次数 | +| --- | --- | --- | --- | +| `@Prop` | 79 | `@Param` | 155 | +| `@State` | 53 | `@Local` | 62 | +| `@BuilderParam` | 7 | `@Event` | 104 | +| `@Watch` | 4 | `@Trace` | 99 | +| `@Link` | 2 | `@ObservedV2` | 5 | +| `@Observed` / `@ObjectLink` / `@Provide` / `@Consume` / `@StorageLink` / `@StorageProp` | 0 | `@Monitor` | 4 | + +(`@BuilderParam` 在 V1 与 V2 中均受支持,不属于迁移面。) + +**V1 文件清单与迁移面**: + +| 文件 | 行数 | `@State` | `@Prop` | `@Link` | `@Watch` | +| --- | --- | --- | --- | --- | --- | +| `ConnectView.ets` | 1344 | 12 | 16 | — | — | +| `AppSidebar.ets` | 908 | 7 | 11 | — | — | +| `RemoteControlSettingsSheet.ets` | 872 | 13 | 12 | — | 1 | +| `ModelServiceSettingsPanel.ets` | 662 | 10 | 5 | — | — | +| `SettingsSheet.ets` | 297 | 4 | 8 | — | — | +| `CreateSessionSheet.ets` | 226 | — | 4 | 2 | — | +| `MarkdownContent.ets` | 199 | — | 1 | — | — | +| `BitFunAccountLoginPage.ets` | 146 | 5 | — | — | — | +| `StreamingMarkdownContent.ets` | 142 | 1 | 3 | — | 3 | +| `FileReferenceCard.ets` | 85 | — | 8 | — | — | +| `ThinkingBlock.ets` | 67 | 1 | 5 | — | — | +| `ChatStatusBar.ets` | 60 | — | 4 | — | — | +| `AppRoot.ets` | 48 | — | — | — | — | +| `ConversationSourceSwitcher.ets` | 40 | — | 1 | — | — | +| `DefaultAccountAvatar.ets` | 18 | — | 1 | — | — | + +**集中度**:前 4 个文件占 3786 行(V1 总量的 74%)、86 个 V1 状态装饰器(占 65%)。其中 `ConnectView` 与 `AppSidebar` 同时也是 S6 拆分的目标,可就近编排。 + +**当前是否已有跨范式错误用法**:已逐文件核查,**没有**。5 个 `@ObservedV2` 类(`AppShellState`、`RemotePageState`、`GeneralChatPageState`、`RemoteCreateSessionState`、`FilePreviewState`)**没有任何一处被 V1 的 `@State` / `@Prop` / `@Link` 持有**——官方不支持 `@ObservedV2` 对象走 V1 观测机制,这条目前没有被踩到。 + +所以全量迁移 V2 **不是在修复既有 bug,而是在消除一类风险**:只要 V1 struct 还在,任何一次后续改动都可能把某个 `@ObservedV2` 对象传进 V1 的 `@Prop`,届时得到的是"编译通过、界面不刷新"——与本模块此前踩过的抽屉不刷新(§1.3.2)完全同型、且同样难以定位的故障。 + +--- + +## 3. 诊断 + +### 3.1 符合官方定义的部分 + +- **ViewModel 层是干净的**:7 个 VM 共 1379 行,零 import `components/`。ViewModel 完全不知道 UI 存在。 +- **Policy 层是纯的**:5 个 Policy 共 505 行,零 `@Trace` / 零 `@ObservedV2`,可直接单测。 +- **已有一处标准 MVVM 三件套**:`ConversationViewState`(投影,120)→ `ConversationViewHost`(哑视图,91)→ `ConversationIntent` / `ConversationIntentDispatcher`(意图,120)。**这是本次重构要推广的形状,不需要发明新范式。** + +### 3.2 硬违规(按 §1.1 官方定义判定) + +| 官方职责 | 违规 | 证据 | +| --- | --- | --- | +| model **不与 view 关联** | `services/` → `pages/` 反向依赖 | `AppRootRouteState`、`FileTargetResolver`、`RemoteFilePreviewController`、`MessageFileReferenceProjector` 共 4 个文件 import `../pages/` | +| view **不与 model 关联** | view 文件持有 model 定义,导致真实模块环 | `AppRootPresentation.ets` L62–270 定义 209 行 action DTO → `AppRootRuntime` 反向 import `AppRootPresentation` | +| viewmodel 是**桥梁** | `AppRootRuntime` 不是桥梁,是 God Facade | 2608 行 / 101 public / 45 一行转发 / 621 行字段初始化块;所有 view 绑到同一个巨型对象,而非各自绑到所属特性的 VM | + +### 3.3 结构性问题(不算违规,但是主要成本来源) + +1. **接线子系统化**(§2.4,约 650 行)——位置参数构造带来无编译期保护的修改风险。 +2. **会话状态双份实现**(§2.5)——每项能力写四遍。 +3. **目录命名说谎**(§2.2)——`pages/state/` 一个目录装了 ViewModel / State / Policy / God Facade 四类东西,"这个文件属于哪一层"无法从路径判断,也导致分层断言写不出来。 +4. **组件层关注点混合**(§2.6)——538 行内联图标 + 四个千行级结构体。 + +### 3.4 更正:一条被推翻的初版判断 + +初版诊断把 **"10 个 `components/*` import `../state/`" 列为分层被打穿。这个判断是错的**,此处保留记录以免后续重复犯错。 + +逐文件查证结果——这 10 个文件 import 的**全部是 State 类与 Policy 类,没有一个 import `*ViewModel`**: + +``` +AppShell.ets → AppShellState +AppSidebar.ets → SessionActionPolicy +ConversationViewHost.ets → ConversationViewState +ComposerBar.ets → ConversationModelPresentationPolicy +FilePreviewSurface.ets → FilePreviewState +ConversationIntent.ets → FilePreviewTarget +ConversationViewSettings → ConversationSessionFilterPolicy +RemoteSessionList.ets → SessionActionPolicy, ConversationSessionFilterPolicy +RemoteCreateSessionView → RemoteCreateSessionState +AppRootPresentation.ets → AppShellState 等 6 个 State/Policy +``` + +View 持有 `@ObservedV2` 状态对象**正是 ArkUI V2 官方推荐的绑定方式**,不是违规。真正的问题是 §3.3 第 3 条:目录名叫 `state`,内容却是四层,让合规的 import 看起来像违规。 + +**因此 S6 的目标已相应修正**:从"切断 `components → state` 的 import"改为"消除内联图标与多关注点混合"。 + +--- + +## 4. 目标结构 + +依赖单向向下,`pages/state/` 按真实层次拆开: + +``` +pages/ + ├─ AppRoot.ets @Entry,组合根 + ├─ actions/ 所有 Actions/Hooks 接口定义(从 view 文件搬出,环即断) + ├─ viewmodel/ 7 个 *ViewModel + 按特性拆出的 Controller + ├─ state/ 纯 @ObservedV2 绑定对象 + ├─ policy/ 纯逻辑,无装饰器,全部可单测 + ├─ layout/ WideLayoutGeometry 等纯几何计算 + ├─ navigation/ AppRouteContract(叶子) + └─ components/ 哑视图 + Glyphs 图标库 +services/ model 层:领域与传输,禁止 import ../pages +model/ i18n/ 叶子 +``` + +**五条硬约束**(S0 写入 `AGENTS.md` 并以"已知清单"模式开始由 `ArchitectureUnit` 拦截新增违规;第 5 条在 S5 完成后转为强制,第 1–3 条在 S7 完成后转为强制): + +1. `services/**` 不得 import `../pages/`; +2. `pages/components/**` 不得 import `pages/viewmodel/`(import `state/` `policy/` 合法); +3. 不存在任何模块环,`viewmodel → components` 方向禁止; +4. Actions/Hooks 一律 `interface` + 对象字面量,禁止位置参数构造; +5. **组件一律 `@ComponentV2`**,禁止新增 `@Component` / `@State` / `@Prop` / `@Link` / `@Watch`(`@BuilderParam` 不在此列,V2 亦支持)。 + +**每个特性面的标准形状**(推广 §3.1 已有的三件套): + +``` +XxxViewState 投影:把 model 数据转成 view 数据 +XxxHost 哑视图:只接 @Param 和回调 +XxxIntent 意图:view 向上表达"用户想做什么" +XxxViewModel 桥梁:持有 state、消费 services、处理 intent +``` + +--- + +## 5. 分阶段方案 + +按"风险调整后收益"排序。S0–S2 零行为变更。每阶段独立可发布、可回滚。 + +### S0 · 立规则与护栏(0.5 天,零行为变更) + +**做什么** + +1. 把 §1.1 官方三条职责、§1.2 范围界定、§4 五条硬约束写入 `src/apps/mobile/harmonyos/AGENTS.md`; +2. 把 §2.7 的本地测试命令补进 `AGENTS.md`(目前未文档化); +3. 扩展 `ArchitectureUnit.test.ets`,新增两组源文件扫描断言,均采用**"已知清单"模式**——断言"当前违规集合 == 登记清单",从此新增违规立即失败,存量按阶段递减: + - 分层断言:登记当前 5 处(`services → pages` 4 处 + `runtime → presentation` 1 处),S7 清零; + - **范式断言**:登记当前 15 个 V1 文件(§2.8 清单),S5 清零。这一条从 S0 当天起就阻止新增 V1 组件,避免迁移期间边迁边长。 + +**为什么先做**:规则来自官方文档,不需要团队内部论证;两份清单让后续每阶段的进度可测,且"只减不增"是机器保证的。 + +**风险**:无。不触碰产物代码。 + +--- + +### S1 · 从 view 中取出 model 定义,断环 + 拆分引力井(1–2 天,零行为变更) + +**做什么** + +1. **7 个 action DTO(L62–270,209 行)→ `pages/actions/`**。单独这一步就消掉硬违规 ② 与循环依赖,建议独立成第一个 commit。 +2. Remote builders + helpers(448 行)→ `pages/components/remote/RemoteSurfaceHost.ets`,带走 8 个 Remote `@Local`。 +3. 宽屏几何(179 行)→ `pages/layout/WideLayoutGeometry.ets`,纯函数,**顺带补单测**(当前零覆盖)。宽屏 builders 带走 13 个几何 `@Local`。 +4. `pages/state/` 按 §4 拆成 `viewmodel/` `state/` `policy/`——纯改目录与 import 路径,零逻辑改动,但让 S0 的断言写得出来。 + +目标:`AppRootPresentation.ets` 从 1449 行收敛到约 300 行的装配壳。 + +**实际结果(2026-08-07)**:7 组 action DTO 已迁入 `pages/actions/`;Remote、 +窄屏路由、宽屏会话与根级 overlay 分别由 `RemoteSurfaceHost`、 +`ConversationRouteSurface`、`WideConversationHost`、`AppRootOverlaySurfaces` +持有。宽屏几何已迁入 `pages/layout/WideLayoutGeometry.ets`,并由 +`ArchitectureUnit` 覆盖关键几何约束。`AppRootPresentation.ets` 从基线 1449 行 +收敛到 406 行,保留响应式测量、`Navigation`、compact preview overlay、Remote +settings sheet 与顶层装配。架构门禁要求该文件不超过 500 行,并要求上述拆分文件 +持续存在。 + +HAP、LocalTest 与窄屏真机 Local → Remote → Local 往返均通过。真机 smoke 曾发现 +`@BuilderParam` slot 内直接构造 V2 组件会触发 `class constructor cannot called without +'new'`;现已改为由 `@Builder` 方法承接 slot,并复验进程在完整往返中持续存活。 +当前两个 target 分别为 1080 × 2444 真机和 466 × 466 模拟器,均不能提供宽屏三栏 +验收条件,因此 S1 的宽屏视觉复验仍记为待办。 + +**风险点(本阶段唯一)**:`@Builder` 值参数不响应式(§1.3.1)。拆分后凡是原先由父 builder 传入的标量,必须改为子 builder 内读状态——`wideMasterPaneCurrentWidth()` 就是这个坑的既有修复案例。 + +**验证**:完整验证回路 + **必须真机复验宽屏三栏与窄屏抽屉来源切换**。 + +--- + +### S2 · 消灭位置参数接线(2–3 天,零行为变更) + +**做什么**:12 个 `*Hooks` / `*Actions` 由 `class` + 位置构造改为 `interface` + 对象字面量。 + +```ts +// before —— 96 行实参,顺序错了编译期无感 +new AppRootPresentationActions(onA, onB, onC, /* … */) + +// after —— 字段名保护,新增回调只改两处 +const actions: AppRootPresentationActions = { + onA: () => { /* … */ }, + onB: () => { /* … */ }, + onC: () => { /* … */ } +}; +``` + +约 650 行接线降至约 250 行。可按 12 个类逐个 commit,每个独立可回滚。 + +**风险**:低。ArkTS 对象字面量要求有明确声明类型,`interface` 满足;改造过程中若某个 Hooks 含方法实现而非纯回调字段,保留为 class 但改为具名参数对象构造。 + +--- + +### S3 · 统一会话状态(3–5 天,**有行为风险**) + +**做什么** + +1. 抽出承载 §2.5 那 15 个共享字段的公共载体;`GeneralChatPageState` / `RemotePageState` 只保留各自特有字段; +2. 删除 `services/AppRootRouteState.ets`(88 行)——同时消掉硬违规 ① 的四分之一; +3. 收敛 `ConversationViewState.project` 的双源分支。 + +**前置 spike(0.5 天,必做)**:验证 ArkUI V2 的 `@Trace` 能否穿透 `@ObservedV2` 基类继承——本模块目前没有先例,不能假设。 + +- 若可以 → 用继承(`ConversationSessionState` 基类)。 +- 若不行 → **退化为组合**:两个 State 各持有一个 `ConversationCore` 字段,投影层只读 core。效果等价,只是访问路径多一层。 + +**Spike 结论(2026-08-06)**:采用组合方案。当前工程没有可证明 `@Trace` +跨 `@ObservedV2` 基类继承订阅关系的运行时先例,HAP 编译和 LocalTest 只能证明语法与 +状态行为,不能证明 UI 订阅穿透。`GeneralChatPageState` 与 `RemotePageState` 因此各自组合 +独立的 `ConversationCoreState`,组件和 `ConversationViewState` 直接读取 core。已通过窄屏 +真机 Local → Remote → Local 往返验证;宽屏真机仍需在折叠设备展开后复验。 + +**风险**:本方案中最高。但安全网充足——`ConversationStateUnit`(1057)+ `RemoteControllersUnit`(2177)直接覆盖这块。 + +**验证**:完整回路 + 真机走通四条路径:本地新建/继续会话、Remote 新建/继续会话、窄屏抽屉来源切换、宽屏来源切换。 + +--- + +### S4 · 拆解 God Facade(4–6 天,分批) + +**做什么**:按 S3 建立的特性边界,把 `AppRootRuntime` 切成 `ConversationController` / `RemoteConnectionController` / `SettingsController` / `FilePreviewController`,`AppRootRuntime` 退化为持有它们的组合根。 + +**实施结果(2026-08-07,已完成)**:已落地 `FilePreviewController`、 +`SettingsController`,并建立 `ConversationController` 的首批跨表面 composer/voice 状态边界; +对应旧方法已从 `AppRootRuntime` 删除,静态门禁禁止回流。现有连接实现也已从 +`RemoteConnectionViewModel` 更名为 `RemoteConnectionController`,根运行时的 21 个状态 getter +和 11 个连接状态转发已删除;路由、workspace/session 列表、polling/heartbeat 的 28 个 +owner 转发也已改为直接绑定。云账号凭据、持久化、云模型目录、权限设置与账号设备切换 +闭环也已迁入 `SettingsController`,包括原 91 行的 `selectCloudAccountDevice`。 +远程会话的发送、停止/重试、工具动作、时间线投影与 polling cursor 运行态已迁入 +`ConversationController`;Remote 新建会话的设备/workspace/模型选择、提交与路由流程也由其 +统一持有。本地会话的打开/新建/发送、草稿、归档与时间线投影同样已收口到该 owner。 +根运行时由 2608 行降至 372 行;纯依赖实例化和回调接线迁入 +`AppRootRuntimeComposition`,其抽象端口仍由根运行时实现,避免装配层反向拥有页面生命周期行为。 +HAP、完整 LocalTest 与窄屏真机 Local → Remote → Local 往返均通过。尚未完成 +宽屏复验,仍等待可用的展开设备。 + +顺序(每步独立 commit): + +1. 清理 45 个一行转发——调用点直接指向真正的 owner; +2. 拆 621 行字段初始化块(L140–761)为各 Controller 的构造; +3. 处理 `selectCloudAccountDevice`(91 行)等长方法; +4. 按官方 V2 形状收口:view 用 `@ComponentV2` + `@Local` 持有**所属特性的** ViewModel,而非同一个巨型对象。 + +**与 S5 的次序说明**:本阶段涉及的装配层(`AppRootPresentation` 及其拆出的 host)已经是 V2,`AppRoot.ets` 虽是 V1 但无任何状态装饰器,因此第 4 步不需要等 S5。S5 排在其后,是因为它的主体(`ConnectView`、`AppSidebar` 等叶子组件)与 Controller 拆分互不相干,放在结构稳定之后迁移,可以避免同一文件被两种性质的改动连续翻动。 + +目标:`AppRootRuntime` < 500 行。消除硬违规 ③。 + +**风险**:中。生命周期是重点——`aboutToAppear` / `onPageShow` / `onPageHide` / `aboutToDisappear` / `handleRootBack` 的调用顺序与轮询启停必须逐一保持。`LifecycleUnit`(748)+ `AppRootLifecycleUnit`(129)+ `AppRootRuntimeStartupUnit`(51)覆盖此处。 + +--- + +### S5 · V1 全量迁移到 V2(4–5 天,**逐文件有行为风险,已完成 2026-08-07**) + +**做什么**:把 §2.8 清单里的 15 个 V1 struct 全部迁到 `@ComponentV2`,之后 `pages/` 下不再存在 V1 装饰器。 + +**为什么值得单列一个阶段**(而不是像初版那样"顺手统一"): + +1. **官方推荐**。V2 是官方对新项目的推荐范式,官方 MVVM 示例也是 `@ComponentV2` + `@Local` 持有 ViewModel 实例的形式。范式统一后 §4 的目标结构与官方文档一一对应,不需要读代码的人在两套心智模型间切换。 +2. **消除一类难定位故障**。§2.8 已核查:目前**没有**任何 `@ObservedV2` 对象被 V1 装饰器持有。但只要 V1 struct 还在,后续任何一次改动都可能把状态对象传进 `@Prop`,得到"编译通过、界面不刷新"——与抽屉不刷新(§1.3.2)同型的故障,本模块已经为这类问题付出过一次排查成本。 +3. **观测边界更明确**。V2 的 `@Trace` 深度观测与 `@Monitor` 的新旧值回调,比 V1 的 `@Observed` / `@ObjectLink` 嵌套观测更容易推理,也更容易在 review 中判断对错。 + +**迁移映射表**(逐条替换,不是全局改名): + +| V1 | V2 | 语义差异——**必须逐字段确认,这是本阶段的主要风险**| +| --- | --- | --- | +| `@Component` | `@ComponentV2` | — | +| `@State`(53) | `@Local` | 基本等价,子组件自有状态 | +| `@Prop`(79) | `@Param` | **不等价**。V1 `@Prop` 是**深拷贝**,子组件可以本地改写;V2 `@Param` 是**按引用只读**,子组件不可赋值。凡是子组件确实在本地改写该字段的,需迁为 `@Param @Once`(仅初始同步、之后子组件自持)或 `@Local` + 显式初始化 | +| `@Link`(2) | `@Param` + `@Event` | **不等价**。V2 取消了双向绑定,须拆成"向下传值 + 向上回调"。仅 `CreateSessionSheet.ets` 的 `sessionTitle` / `instruction` 两处 | +| `@Watch`(4) | `@Monitor` | 回调签名不同,`@Monitor` 提供新旧值;`RemoteControlSettingsSheet` 1 处、`StreamingMarkdownContent` 3 处 | +| `@BuilderParam`(7) | 不变 | V2 同样支持,不属于迁移面 | + +**顺序**(每个文件独立 commit,从小到大以便先摸清坑): + +1. 先迁 5 个小文件(`DefaultAccountAvatar` 18、`ConversationSourceSwitcher` 40、`AppRoot` 48、`ChatStatusBar` 60、`ThinkingBlock` 67)——`AppRoot` 无任何状态装饰器,是纯粹的 `@Component` → `@ComponentV2` 改名,可作为第一个 commit 验证工具链; +2. 迁 `@Link` / `@Watch` 三个特殊文件(`CreateSessionSheet` 226、`StreamingMarkdownContent` 142、`RemoteControlSettingsSheet` 872)——语义变化集中在这里,单独处理便于 review; +3. 迁剩余中等文件(`FileReferenceCard` 85、`BitFunAccountLoginPage` 146、`MarkdownContent` 199、`SettingsSheet` 297、`ModelServiceSettingsPanel` 662); +4. 最后迁 `AppSidebar`(908)与 `ConnectView`(1344)——这两个占 V1 总量 44%,且是 S6 的拆分目标,**先迁后拆**:若先拆再迁,会在拆分过程中制造 V1/V2 交界,把两类风险叠在同一个 commit 里。 + +**风险**:中。集中在 `@Prop` → `@Param` 的 79 处——**不能批量替换**,每一处都要确认子组件是否本地改写。`StreamingMarkdownContent` 尤其要小心:它的 3 个 `@Prop` 全部带 `@Watch`,流式 Markdown 的增量渲染依赖这套回调时序。 + +**验证**:完整回路,且**每个 commit 都要真机验证该组件所在界面**。重点回归:连接流程(`ConnectView`)、侧栏与会话列表(`AppSidebar`)、Remote 控制设置(`RemoteControlSettingsSheet`)、流式回复渲染(`StreamingMarkdownContent`)、新建会话(`CreateSessionSheet`)。 + +**完成标志**:`ArchitectureUnit` 的 V1 已知清单清空,范式断言由"等于清单"翻为"必须为空";此后新增 V1 组件在 CI 直接失败。 + +**实际结果**:15 个 V1 页面组件全部迁移。逐字段审计结论是:只读父输入迁为 +`@Param`;需要用户编辑的值由子组件 `@Local` draft 持有,并通过显式事件上送; +`CreateSessionSheet` 的两个 `@Link` 拆为 `@Param` + `@Event`; +`StreamingMarkdownContent` 与 `RemoteControlSettingsSheet` 的监听迁为 `@Monitor`。 +本轮没有字段符合“只接收一次父级初值、之后完全由子组件持有”的语义,因此没有使用 +`@Param @Once`。HAP 编译同时验证 `@Param` 未被子组件赋值,架构门禁中的 V1 清单 +已经为空。HAP、LocalTest、窄屏启动与 Local → Remote → Local 往返均通过。 + +--- + +### S6 · 纯化组件层(3–4 天,纯视觉风险) + +**做什么** + +1. 侧栏和工具列表的重复 glyph 已分别收口到 `SidebarGlyphs.ets`、`ToolGlyphs.ets`; +2. 按视觉关注点拆出 `ConnectAccountDevicePage`(账号设备选择)、 + `ChatMessageContent`(图片/Markdown/文件卡片)两个 V2 子组件, + `AppSidebar` 从 908 行降至 700 行,`ConnectView` 从 1344 行降至 1055 行。 + `ToolStatusList` 的业务分组和交互状态仍保留在原 owner,避免纯视觉迁移改变工具动作时序。 +3. S1 同时完成根展示面的纯视觉拆分:Remote、窄屏路由、宽屏会话和 overlay 已由 + 四组 V2 host/surface 组件持有,`AppRootPresentation` 当前为 406 行。 +4. 第二批拆分已落地:`ConnectManualPairingOverlay` 持有手工配对表单, + `ToolInteractionPanels` 持有工具 JSON 编辑/批准和问答草稿,`ChatMessageChrome` + 持有用户气泡、重试提示和流式三点动画。对应主文件当前分别为 + `ConnectView` 695 行、`ToolStatusList` 1106 行、`ChatMessageBubble` 972 行;预算已写入 + `pnpm run harmony:architecture`,禁止展示职责回流。 + +**目标已按 §3.4 修正**:不包含"切断 `components → state`"——该 import 合法。**也不再包含装饰器统一**——S5 已完成,本阶段拆出的新组件天然是 V2。 + +**风险**:纯视觉回归。**每一步必须真机截图,窄屏 + 宽屏 × 浅色 + 深色四组**;所有颜色走 `Theme.ets` 语义 token,`pnpm run theme:color-audit:all` 必须干净。 + +**实际进度(2026-08-07,进行中)**:已完成窄屏浅色启动、侧栏展开、 +Local → Remote → Local 往返截图;新接入 HUAWEI MatePad Pro `WEB-W00` +(2880 × 1920),已安装本轮 HAP,并完成 Pad 浅色/深色下 Local、Remote Home 和连接 +设备面板截图,应用进程持续存活。宽屏合同不等于 Pad 合同:现有 +`ConversationLayoutPolicy` 同时读取零/一/两道纵向折痕,两道折痕的三折叠继续使用 +“左屏 master + 中/右两屏同一个 detail”,正文与关键热区选择不跨第二道折痕的最宽 +内容带;零/一/两道折痕、非对称三屏和非法折痕均有 LocalTest 覆盖。 + +三折叠完整展开及双屏/三屏动态切换仍需要真实两折痕设备验证,Pad 不能替代该项; +文件预览打开/关闭矩阵也尚未闭合,因此 S6 仍不能标记为完成。 + +--- + +### S7 · 关闭护栏(0.5 天) + +原 3 处 `services/` → `pages/` 反向依赖已在 S1/S3 的文件归属迁移中清零;当前 +`pnpm run harmony:architecture` 的 `serviceToPages`、`componentToViewmodel`、 +`viewmodelToComponents` 均为空,V1 清单也为空。门禁已从基线清单切换为永久空集, +并补齐了 `AGENTS.md` 与 `ArchitectureUnit` 的归属说明。 + +--- + +## 6. 每阶段固定验证回路 + +```bash +source scripts/ohos-env.sh + +# 1. 构建 +"$HVIGORW" --mode module -p product=default -p module=entry@default assembleHap --no-daemon + +# 2. 本地单元测试(6612 行 hypium 用例) +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon + +# 3. 颜色审计 +pnpm run theme:color-audit:all + +# 4. 真机验证(折叠设备 5ZU0226202001116) +hdc -t 5ZU0226202001116 shell snapshot_display -f /data/local/tmp/s.jpeg +hdc -t 5ZU0226202001116 file recv /data/local/tmp/s.jpeg ./s.jpeg +``` + +设备侧注意事项(已踩过的坑): + +- bundle 名是 **`com.example.bitfun_mobile`**,不是 `com.bitfun.mobile`; +- `hdc` 必须带 `-t `,否则报 `[Fail]ExecuteCommand need connect-key`(列出了两个 target); +- 外屏分辨率 1080×2444;点击用 `hdc -t shell uinput -T -c X Y`。 + +**真机验证的最低集合**(每阶段都要过):窄屏抽屉 Local ↔ Remote 来源切换、宽屏三栏、文件预览打开/关闭、深浅色各一轮。 + +--- + +## 7. 明确不做的事 + +- **不引入三层架构(products / features / commons)**。理由见 §1.2:单 entry 模块,收益为零、构建复杂度为正。 +- **不引入新的状态管理库或跨端抽象层**。问题是组织方式,不是工具。 +- **不重构 `services/general-chat/`(21 文件 / 3296 行)内部结构**。它自身分层是干净的,只需在 S7 切断对 `pages/` 的反向依赖。 +- **不追求行数目标本身**。S1 + S2 净减约 800 行是副产品;真正的收益是"改一处不用改三处"和"违规能被 CI 挡住"。 + +> 初版方案曾把"V1 → V2 全量迁移"列在本节。该判断已推翻——理由见 §5 的 S5 阶段,迁移已提升为独立阶段。 + +--- + +## 8. 遗留事项 + +- **窄屏"刷新"与"助手选择"入口缺失**(baseline `6c35485bb` 引入)。删除 `RemoteHomeView.ets` 统一窄屏 Remote 界面时,这两个入口一并移除,宽屏本来就没有。待定:是否补进共享侧栏的 `...` 菜单。此项与本重构无依赖关系,可独立处理。 +- **S6 组件纯化尚未完成**。优先继续拆分 `ToolStatusList`、`ChatMessageBubble` 与 + `ConnectView`,每次拆分保持动作 owner 和时序不变。 +- **视觉验证矩阵尚未闭合**。仍需补窄屏深色、文件预览打开/关闭,以及宽屏三栏的 + 深浅色截图;后者等待可用的展开折叠屏或平板 target。 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 35e2db0e71..a2296504cc 100644 --- a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md +++ b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md @@ -6,7 +6,7 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 ## 实施状态 -截至 2026-07-30: +截至 2026-08-07: ### 已实现 @@ -35,6 +35,7 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 - 在同一设备的折叠单屏态(`1080 x 2444`)验证:页面保持原单屏头部和 Composer,可打开原侧边栏;点击 `Remote` 继续打开原“选择桌面设备”Sheet,系统返回可关闭 Sheet 并恢复本地 Home;本地历史会话的显示保持原样。 - 折叠单屏连接已有在线桌面后验证:远程 Home 保留原头部、菜单和会话列表;进入已有远程会话后保留原会话头部与 Composer;系统返回从远程会话回到远程 Home;打开原侧边栏并选择本地会话可恢复本地内容。全程未发送消息、运行命令或启动远程任务。 - 单屏根 `ChatHome` 的系统返回基线已核实:历史本地会话仍投影在根路由,侧边栏可见时也未接入根返回拦截,因此返回会退出 Ability。本次宽屏改动不改变该行为;是否优化应作为独立单屏导航问题处理。 +- 在 HUAWEI MatePad Pro(`WEB-W00`,`2880 x 1920`)安装最新 HAP,浅色与深色均验证本地/Remote 来源选择器、常驻 master、Remote 未连接占位和居中的连接设备面板;布局边界稳定,应用进程持续存活。Pad 验证只覆盖无折痕宽屏,不替代下述三折叠真机项。 ### 待验证 @@ -450,6 +451,7 @@ MasterDetail -> 双屏和三屏共同使用的 master-detail | 展开宽屏,本地会话 | 来源选择器保持“本地”,会话选中态正确,右侧显示当前会话 | | 展开宽屏,远程 Home | 来源选择器选中“Remote”,可一步切回本地,不显示全局侧边栏按钮 | | 展开宽屏,远程会话 | 来源选择器保持“Remote”,会话选中态正确,右侧显示当前会话,不显示全局侧边栏按钮 | +| 宽屏点击远程会话 | 左侧立即选中新会话;右侧立即进入该会话,慢加载时显示时间线骨架,完成后原位替换为历史消息 | | 三屏完整展开,本地会话 | 左屏显示本地 master,中间和右侧共同显示一个本地 detail | | 三屏完整展开,远程会话 | 左屏显示远程 master,中间和右侧共同显示一个远程 detail | | 三屏远程断开 | 左屏仍显示来源选择器,右侧两屏显示一个连续断开状态 | diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/FilePreviewTarget.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/model/FilePreviewTarget.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index 4a918697db..3c22ccc445 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -1,9 +1,9 @@ import { AppRootPresentation } from './components/AppRootPresentation'; import { ArkUiAppRootHostAdapter } from './host/AppRootHostAdapter'; -import { AppRootRuntime } from './state/AppRootRuntime'; +import { AppRootRuntime } from './runtime/AppRootRuntime'; @Entry -@Component +@ComponentV2 struct AppRoot { private readonly hostAdapter: ArkUiAppRootHostAdapter = new ArkUiAppRootHostAdapter(); private readonly runtime: AppRootRuntime = new AppRootRuntime(this.hostAdapter); @@ -38,7 +38,7 @@ struct AppRoot { remoteCreateState: this.runtime.remoteCreateState, generalPageState: this.runtime.generalChatPageState, filePreviewState: this.runtime.filePreviewState, - deviceId: this.runtime.remoteConnectionViewModel.getDeviceId(), + deviceId: this.runtime.remoteConnectionController.getDeviceId(), actions: this.runtime.presentationActions }) } 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 new file mode 100644 index 0000000000..63fecd397e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -0,0 +1,152 @@ +import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ConversationIntent } from './ConversationIntent'; +import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; + +export interface AppRootPresentationActions { + readonly onNavigationBack: (route: AppRoute) => boolean; + readonly onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void; + readonly onCloseSidebar: () => void; + readonly onWideConversationSource: (source: ConversationSource) => void; + readonly onCompactConversationSource: (source: ConversationSource) => void; + readonly onCompactLayoutEntered: () => void; + readonly onLayoutModeChanged: (wideLayout: boolean) => void; + readonly onRemoteHome: RemoteHomePresentationActions; + readonly onRemoteCreate: RemoteCreatePresentationActions; + readonly onSidebar: SidebarPresentationActions; + readonly onSettings: SettingsPresentationActions; + readonly onConnect: ConnectPresentationActions; + readonly onFilePreview: FilePreviewPresentationActions; + readonly generalStatus: () => string; +} + +export interface FilePreviewPresentationActions { + readonly close: () => void; + readonly refresh: () => void; + readonly download: (path: string) => void; + readonly openLink: (reference: string, label: string) => void; +} + +export interface RemoteCreatePresentationActions { + readonly back: () => void; + readonly toggleDevices: () => void; + readonly toggleWorkspaces: () => void; + readonly selectDevice: (device: CloudAccountDevice) => void; + readonly selectWorkspace: (path: string) => void; + readonly draftChanged: (value: string) => void; + readonly voiceInput: () => void; + readonly selectModel: (modelId: string) => void; + readonly send: () => void; +} + +export interface RemoteHomePresentationActions { + readonly openSidebar: () => void; + readonly connectWorkspace: () => void; + readonly addConnection: () => void; + readonly openSettings: () => void; + readonly refresh: () => void; + readonly showWorkspaces: () => void; + readonly showAssistants: () => void; + readonly selectWorkspace: (path: string) => void; + readonly selectAssistant: (path: string) => void; + readonly cancelWorkspace: () => void; + readonly cancelAssistant: () => void; + readonly queryChanged: (query: string) => void; + readonly search: () => void; + readonly loadMore: () => void; + readonly reconnect: () => void; + readonly disconnect: () => void; + readonly clearPairing: () => void; + readonly create: (agentType: string) => void; + readonly createInPlace: (agentType: string) => void; + readonly createAssistant: () => void; + readonly createInWorkspace: (path: string, agentType: string) => void; + readonly createInWorkspaceInPlace: (path: string, agentType: string) => void; + readonly openSession: (session: RemoteSession) => void; + readonly openSessionInPlace: (session: RemoteSession) => void; + readonly deleteSession: (session: RemoteSession) => void; +} + +export interface SidebarPresentationActions { + readonly close: () => void; + readonly newChat: () => void; + readonly enterCode: () => void; + readonly settings: () => void; + readonly openAccount: () => void; + readonly openSession: (session: RemoteSession) => void; + readonly archive: (session: RemoteSession, archived: boolean) => void; + readonly exportSession: (session: RemoteSession) => void; + readonly deleteSession: (session: RemoteSession) => void; +} + +export interface SettingsPresentationActions { + readonly close: () => void; + readonly addConnection: () => void; + readonly disconnect: () => void; + readonly reconnect: () => void; + readonly openAccount: () => void; + readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; + readonly cloudSync: () => Promise; + readonly cloudLogout: () => Promise; + readonly cloudListDevices: () => Promise; + readonly getPermissionMode: () => Promise; + readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; + readonly testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; + readonly saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; +} + +export interface ConnectPresentationActions { + readonly back: () => void; + readonly connect: (password?: string) => void; + readonly clearPairing: () => void; + readonly urlChanged: (url: string) => void; + readonly userChanged: (user: string) => void; + readonly detected: (url: string) => boolean; + readonly inputVisible: (visible: boolean) => void; + readonly paste: () => void; + readonly scan: () => void; + readonly cloudListDevices: () => Promise; + readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; +} + +export function emptyAppRootPresentationActions(): AppRootPresentationActions { + return { + onNavigationBack: () => false, + onConversationIntent: () => {}, + onCloseSidebar: () => {}, + onWideConversationSource: () => {}, + onCompactConversationSource: () => {}, + onCompactLayoutEntered: () => {}, + onLayoutModeChanged: () => {}, + onRemoteHome: { + openSidebar: () => {}, connectWorkspace: () => {}, addConnection: () => {}, openSettings: () => {}, + refresh: () => {}, showWorkspaces: () => {}, showAssistants: () => {}, selectWorkspace: () => {}, + selectAssistant: () => {}, cancelWorkspace: () => {}, cancelAssistant: () => {}, queryChanged: () => {}, + search: () => {}, loadMore: () => {}, reconnect: () => {}, disconnect: () => {}, clearPairing: () => {}, + create: () => {}, createInPlace: () => {}, createAssistant: () => {}, createInWorkspace: () => {}, + createInWorkspaceInPlace: () => {}, openSession: () => {}, openSessionInPlace: () => {}, deleteSession: () => {} + }, + onRemoteCreate: { + back: () => {}, toggleDevices: () => {}, toggleWorkspaces: () => {}, selectDevice: () => {}, + selectWorkspace: () => {}, draftChanged: () => {}, voiceInput: () => {}, selectModel: () => {}, send: () => {} + }, + onSidebar: { + close: () => {}, newChat: () => {}, enterCode: () => {}, settings: () => {}, openAccount: () => {}, + openSession: () => {}, archive: () => {}, exportSession: () => {}, deleteSession: () => {} + }, + onSettings: { + close: () => {}, addConnection: () => {}, disconnect: () => {}, reconnect: () => {}, openAccount: () => {}, + cloudLogin: async () => '', cloudSync: async () => '', cloudLogout: async () => {}, + cloudListDevices: async () => [], getPermissionMode: async () => 'ask', + setPermissionMode: async (mode: RemotePermissionMode) => mode, + testGeneral: async () => '', saveGeneral: async () => '' + }, + onConnect: { + back: () => {}, connect: () => {}, clearPairing: () => {}, urlChanged: () => {}, userChanged: () => {}, + detected: () => false, inputVisible: () => {}, paste: () => {}, scan: () => {}, + cloudListDevices: async () => [], cloudSelectDevice: async () => {} + }, + onFilePreview: { close: () => {}, refresh: () => {}, download: () => {}, openLink: () => {} }, + generalStatus: () => '' + }; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets similarity index 94% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets index 6531352eeb..76dc0a66e0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets @@ -1,5 +1,5 @@ -import { ConversationUiQuestionAnswer } from './ConversationUiModels'; -import { FilePreviewRequest } from '../state/FilePreviewTarget'; +import { ConversationUiQuestionAnswer } from '../components/ConversationUiModels'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; export enum ConversationIntentType { OpenSidebar = 'open_sidebar', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets similarity index 67% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index 0c2c8bbf7e..a07455141e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -1,10 +1,10 @@ import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteModels'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; -import { ConversationIntent, ConversationIntentType } from '../components/ConversationIntent'; +import { ConversationIntent, ConversationIntentType } from './ConversationIntent'; import { toRemoteQuestionAnswer } from '../components/ConversationUiModels'; -import { FilePreviewRequest } from './FilePreviewTarget'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; -export class ConversationIntentDispatcherHooks { +export interface ConversationIntentDispatcherHooks { readonly openSidebar: () => void; readonly back: () => void; readonly newRemoteSession: () => void; @@ -35,33 +35,6 @@ export class ConversationIntentDispatcherHooks { readonly send: () => Promise; readonly voiceInput: () => Promise; readonly inputChanged: (route: AppRoute, value: string) => void; - - constructor( - openSidebar: () => void, back: () => void, newRemoteSession: () => void, newGeneralSession: () => void, - activeGeneralSession: () => RemoteSession, activeGeneralSessionId: () => string, - isGeneralBusy: () => boolean, isPinned: (id: string) => boolean, - pin: (session: RemoteSession, pinned: boolean, busy: boolean) => Promise, - archive: (session: RemoteSession) => Promise, deleteSession: (session: RemoteSession) => Promise, - showToast: (text: string) => void, uploadedFileCount: () => number, - stop: () => Promise, loadOlder: () => Promise, approve: (id: string, input?: Object) => Promise, - reject: (id: string) => Promise, cancel: (id: string) => Promise, - answer: (id: string, answers: RemoteQuestionAnswerPayload) => Promise, rename: (title: string) => Promise, - copy: (text: string) => Promise, retry: (text: string) => Promise, selectModel: (id: string) => Promise, - pickImages: () => Promise, removeImage: (id: string) => void, - openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void, downloadFile: (path: string) => void, - send: () => Promise, voiceInput: () => Promise, inputChanged: (route: AppRoute, value: string) => void - ) { - this.openSidebar = openSidebar; this.back = back; this.newRemoteSession = newRemoteSession; - this.newGeneralSession = newGeneralSession; this.activeGeneralSession = activeGeneralSession; - this.activeGeneralSessionId = activeGeneralSessionId; this.isGeneralBusy = isGeneralBusy; - this.isPinned = isPinned; this.pin = pin; this.archive = archive; this.delete = deleteSession; - this.showToast = showToast; this.uploadedFileCount = uploadedFileCount; this.stop = stop; - this.loadOlder = loadOlder; this.approve = approve; this.reject = reject; this.cancel = cancel; - this.answer = answer; this.rename = rename; this.copy = copy; this.retry = retry; - this.selectModel = selectModel; this.pickImages = pickImages; this.removeImage = removeImage; - this.openFilePreview = openFilePreview; this.downloadFile = downloadFile; this.send = send; - this.voiceInput = voiceInput; this.inputChanged = inputChanged; - } } export class ConversationIntentDispatcher { 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 new file mode 100644 index 0000000000..734b9f7ea4 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -0,0 +1,185 @@ +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { AppRouteContract, ConversationSource } from '../navigation/AppRouteContract'; +import { AppShellState } from '../state/AppShellState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppSidebar } from './AppSidebar'; +import { ConnectView } from './ConnectView'; +import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { SettingsSheet } from './SettingsSheet'; + +@ComponentV2 +export struct AppSidebarSurface { + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Event onOpenRemoteViewSettings: () => void = () => {}; + + build() { + AppSidebar({ + sessions: this.source() === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), + pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: this.source() === ConversationSource.Remote ? '' : + (AppRouteContract.isGeneralComposerRoute(this.shellState.activeRoute) ? + this.generalPageState.conversation.activeSession.sessionId : ''), + connectionState: this.remotePageState.connectionState, + accountUserId: this.remotePageState.accountUserId, + activeSection: this.source() === ConversationSource.Remote ? 'remote' : 'chat', + showConversationSourceSwitcher: true, + showViewSettingsButton: this.source() === ConversationSource.Remote, + showCustomContent: this.source() === ConversationSource.Remote, + conversationSource: this.source(), + contentSlot: () => { + this.RemoteContent() + }, + onClose: this.actions.onSidebar.close, + onNewChat: () => this.newChat(), + onEnterCode: this.actions.onSidebar.enterCode, + onConversationSource: this.actions.onCompactConversationSource, + onOpenViewSettings: this.onOpenRemoteViewSettings, + onSearchQueryChange: (query: string) => { + if (this.source() === ConversationSource.Remote) this.actions.onRemoteHome.queryChanged(query); + }, + onOpenSettings: () => this.openSettings(), + onOpenAccount: this.actions.onSidebar.openAccount, + onOpenSession: this.actions.onSidebar.openSession, + onArchiveSession: this.actions.onSidebar.archive, + onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession + }) + } + + @Builder + private RemoteContent() { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Master, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + showSelectedSession: true, + compact: true + }) + } + + private source(): ConversationSource { + return AppRouteContract.conversationSource(this.shellState.activeRoute); + } + + private newChat(): void { + if (this.source() === ConversationSource.Remote) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.createAssistant(); + } else { + this.actions.onSidebar.newChat(); + } + } + + private openSettings(): void { + if (this.source() === ConversationSource.Remote) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.openSettings(); + } else { + this.actions.onSidebar.settings(); + } + } +} + +@ComponentV2 +export struct AppSettingsSurface { + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param deviceId: string = ''; + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + + build() { + if (this.shellState.settingsMode === 'remote' || this.shellState.settingsMode === 'account') { + RemoteControlSettingsSheet({ + desktopName: this.remotePageState.desktopName, + desktopId: this.remotePageState.desktopId, + userId: this.remotePageState.userId, + accountUsername: this.remotePageState.accountUsername, + accountUserId: this.remotePageState.accountUserId, + deviceId: this.deviceId, + controlTargetType: this.remotePageState.controlTargetType, + controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, + connectionState: this.remotePageState.connectionState, + statusText: this.remotePageState.conversation.statusText, + isBusy: this.remotePageState.conversation.isBusy, + onClose: this.actions.onSettings.close, + onOpenAccount: this.actions.onSettings.openAccount, + onAddConnection: this.actions.onSettings.addConnection, + cloudLogin: this.actions.onSettings.cloudLogin, + cloudSync: this.actions.onSettings.cloudSync, + cloudLogout: this.actions.onSettings.cloudLogout, + cloudListDevices: this.actions.onSettings.cloudListDevices, + getPermissionMode: this.actions.onSettings.getPermissionMode, + setPermissionMode: this.actions.onSettings.setPermissionMode, + openAccountOnAppear: this.shellState.settingsMode === 'account', + onDisconnect: this.actions.onSettings.disconnect, + onReconnect: this.actions.onSettings.reconnect + }) + } else { + SettingsSheet({ + generalChatApiUrl: this.generalPageState.apiUrl, + generalChatModelName: this.generalPageState.modelName, + hasGeneralChatApiKey: this.generalPageState.hasApiKey, + generalChatModelCatalog: this.generalPageState.conversation.modelCatalog, + selectedGeneralChatModelId: this.generalPageState.conversation.selectedModelId, + accountUsername: this.remotePageState.accountUsername, + authenticatedUserId: this.remotePageState.accountUserId, + deviceId: this.deviceId, + onOpenAccount: this.actions.onSettings.openAccount, + onTestGeneralChatConfig: this.actions.onSettings.testGeneral, + onSaveGeneralChatConfig: this.actions.onSettings.saveGeneral, + onClose: this.actions.onSettings.close + }) + } + } +} + +@ComponentV2 +export struct AppConnectSurface { + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param deviceId: string = ''; + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + + build() { + ConnectView({ + remoteUrl: this.remotePageState.remoteUrl, + userId: this.remotePageState.userId, + statusText: this.remotePageState.conversation.statusText, + connectionState: this.remotePageState.connectionState, + connectionFailureKind: this.remotePageState.connectionFailureKind, + isBusy: this.remotePageState.conversation.isBusy, + isConnected: this.remotePageState.connectionState === 'connected', + desktopName: this.remotePageState.desktopName, + deviceId: this.deviceId, + accountUserId: this.remotePageState.accountUserId, + controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, + requiresAccountAuth: this.remotePageState.requiresAccountAuth, + accountUsername: this.remotePageState.accountUsername, + startWithScanner: true, + onBack: this.actions.onConnect.back, + onConnect: this.actions.onConnect.connect, + onRemoteUrlChange: this.actions.onConnect.urlChanged, + onUserIdChange: this.actions.onConnect.userChanged, + onRemoteUrlDetected: this.actions.onConnect.detected, + onRemoteUrlInputVisibleChange: this.actions.onConnect.inputVisible, + cloudListDevices: this.actions.onConnect.cloudListDevices, + cloudSelectDevice: this.actions.onConnect.cloudSelectDevice + }) + .width('100%') + .height('100%') + } +} 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 c38cd9de27..cd5564c45f 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 @@ -1,47 +1,42 @@ import display from '@ohos.display'; import deviceInfo from '@ohos.deviceInfo'; import mediaQuery from '@ohos.mediaquery'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; -import { CloudAccountDevice } from '../../services/CloudAccountClient'; -import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteUiState } from '../../services/RemoteUiState'; import { AppShell } from './AppShell'; -import { AppSidebar } from './AppSidebar'; -import { ConnectView } from './ConnectView'; -import { ConversationIntent } from './ConversationIntent'; -import { ComposerPresentation } from './ComposerBar'; -import { ConversationViewSettings } from './ConversationViewSettings'; -import { ConversationViewHost } from './ConversationViewHost'; -import { toConversationUiModelCatalog } from './ConversationUiModels'; import { FilePreviewSurface } from './FilePreviewSurface'; -import { GeneralChatHeader } from './GeneralChatHeader'; -import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; -import { RemoteCreateSessionView } from './RemoteCreateSessionView'; -import { RemoteSessionList } from './RemoteSessionList'; -import { RemoteSessionLoadingView } from './RemoteSessionLoadingView'; -import { SidebarToggleButton } from './SidebarToggleButton'; -import { SessionActionPresentation } from './SessionActionSurface'; -import { SettingsSheet } from './SettingsSheet'; -import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED } from './Theme'; -import { AppRoute, AppRouteContract, ConversationSource } from '../navigation/AppRouteContract'; +import { PAGE_BG } from './Theme'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; import { AppShellState } from '../state/AppShellState'; import { ConversationLayoutCrease, ConversationLayoutPolicy -} from '../state/ConversationLayoutPolicy'; +} from '../policy/ConversationLayoutPolicy'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; -import { ConversationViewState } from '../state/ConversationViewState'; -import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; +import { FilePreviewState } from '../state/FilePreviewState'; import { FilePreviewLayout, FilePreviewPlacement, FilePreviewPlacementPolicy -} from '../state/FilePreviewPlacementPolicy'; - -const WIDE_DETAIL_CONTENT_MAX_WIDTH: number = 920; +} from '../policy/FilePreviewPlacementPolicy'; +import { WideLayoutGeometry } from '../layout/WideLayoutGeometry'; +import { ConversationRouteSurface } from './ConversationRouteSurface'; +import { WideConversationHost } from './WideConversationHost'; +import { + AppConnectSurface, + AppSettingsSurface, + AppSidebarSurface +} from './AppRootOverlaySurfaces'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; function safeFoldStatus(): display.FoldStatus { try { @@ -59,216 +54,6 @@ function safeDeviceType(): string { } } -export class AppRootPresentationActions { - readonly onNavigationBack: (route: AppRoute) => boolean; - readonly onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void; - readonly onCloseSidebar: () => void; - readonly onWideConversationSource: (source: ConversationSource) => void; - readonly onCompactConversationSource: (source: ConversationSource) => void; - readonly onCompactLayoutEntered: () => void; - readonly onRemoteHome: RemoteHomePresentationActions; - readonly onRemoteCreate: RemoteCreatePresentationActions; - readonly onSidebar: SidebarPresentationActions; - readonly onSettings: SettingsPresentationActions; - readonly onConnect: ConnectPresentationActions; - readonly onFilePreview: FilePreviewPresentationActions; - readonly generalStatus: () => string; - - constructor( - onNavigationBack: (route: AppRoute) => boolean, - onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void, - onCloseSidebar: () => void, - onWideConversationSource: (source: ConversationSource) => void, - onCompactConversationSource: (source: ConversationSource) => void, - onCompactLayoutEntered: () => void, - onRemoteHome: RemoteHomePresentationActions, - onRemoteCreate: RemoteCreatePresentationActions, - onSidebar: SidebarPresentationActions, - onSettings: SettingsPresentationActions, - onConnect: ConnectPresentationActions, - onFilePreview: FilePreviewPresentationActions, - generalStatus: () => string - ) { - this.onNavigationBack = onNavigationBack; - this.onConversationIntent = onConversationIntent; - this.onCloseSidebar = onCloseSidebar; - this.onWideConversationSource = onWideConversationSource; - this.onCompactConversationSource = onCompactConversationSource; - this.onCompactLayoutEntered = onCompactLayoutEntered; - this.onRemoteHome = onRemoteHome; - this.onRemoteCreate = onRemoteCreate; - this.onSidebar = onSidebar; - this.onSettings = onSettings; - this.onConnect = onConnect; - this.onFilePreview = onFilePreview; - this.generalStatus = generalStatus; - } -} - -export class FilePreviewPresentationActions { - readonly close: () => void; - readonly refresh: () => void; - readonly download: (path: string) => void; - readonly openLink: (reference: string, label: string) => void; - - constructor( - close: () => void, - refresh: () => void, - download: (path: string) => void, - openLink: (reference: string, label: string) => void - ) { - this.close = close; - this.refresh = refresh; - this.download = download; - this.openLink = openLink; - } -} - -export class RemoteCreatePresentationActions { - readonly back: () => void; - readonly toggleDevices: () => void; - readonly toggleWorkspaces: () => void; - readonly selectDevice: (device: CloudAccountDevice) => void; - readonly selectWorkspace: (path: string) => void; - readonly draftChanged: (value: string) => void; - readonly voiceInput: () => void; - readonly selectModel: (modelId: string) => void; - readonly send: () => void; - - constructor( - back: () => void, - toggleDevices: () => void, - toggleWorkspaces: () => void, - selectDevice: (device: CloudAccountDevice) => void, - selectWorkspace: (path: string) => void, - draftChanged: (value: string) => void, - voiceInput: () => void, - selectModel: (modelId: string) => void, - send: () => void - ) { - this.back = back; - this.toggleDevices = toggleDevices; - this.toggleWorkspaces = toggleWorkspaces; - this.selectDevice = selectDevice; - this.selectWorkspace = selectWorkspace; - this.draftChanged = draftChanged; - this.voiceInput = voiceInput; - this.selectModel = selectModel; - this.send = send; - } -} - -export class RemoteHomePresentationActions { - readonly openSidebar: () => void; readonly connectWorkspace: () => void; - readonly addConnection: () => void; readonly openSettings: () => void; - readonly refresh: () => void; readonly showWorkspaces: () => void; readonly showAssistants: () => void; - readonly selectWorkspace: (path: string) => void; readonly selectAssistant: (path: string) => void; - readonly cancelWorkspace: () => void; readonly cancelAssistant: () => void; - readonly queryChanged: (query: string) => void; readonly search: () => void; readonly loadMore: () => void; - readonly reconnect: () => void; readonly disconnect: () => void; readonly clearPairing: () => void; - readonly create: (agentType: string) => void; readonly createInPlace: (agentType: string) => void; - readonly createAssistant: () => void; - readonly createInWorkspace: (path: string, agentType: string) => void; - readonly createInWorkspaceInPlace: (path: string, agentType: string) => void; - readonly openSession: (session: RemoteSession) => void; - readonly openSessionInPlace: (session: RemoteSession) => void; - readonly deleteSession: (session: RemoteSession) => void; - - constructor( - openSidebar: () => void, connectWorkspace: () => void, addConnection: () => void, openSettings: () => void, - refresh: () => void, showWorkspaces: () => void, showAssistants: () => void, - selectWorkspace: (path: string) => void, selectAssistant: (path: string) => void, - cancelWorkspace: () => void, cancelAssistant: () => void, queryChanged: (query: string) => void, - search: () => void, loadMore: () => void, reconnect: () => void, disconnect: () => void, - clearPairing: () => void, create: (agentType: string) => void, createInPlace: (agentType: string) => void, - createAssistant: () => void, - createInWorkspace: (path: string, agentType: string) => void, - createInWorkspaceInPlace: (path: string, agentType: string) => void, openSession: (session: RemoteSession) => void, - openSessionInPlace: (session: RemoteSession) => void, - deleteSession: (session: RemoteSession) => void - ) { - this.openSidebar = openSidebar; this.connectWorkspace = connectWorkspace; this.addConnection = addConnection; - this.openSettings = openSettings; this.refresh = refresh; this.showWorkspaces = showWorkspaces; - this.showAssistants = showAssistants; this.selectWorkspace = selectWorkspace; this.selectAssistant = selectAssistant; - this.cancelWorkspace = cancelWorkspace; this.cancelAssistant = cancelAssistant; this.queryChanged = queryChanged; - this.search = search; this.loadMore = loadMore; this.reconnect = reconnect; this.disconnect = disconnect; - this.clearPairing = clearPairing; this.create = create; this.createInPlace = createInPlace; - this.createAssistant = createAssistant; this.createInWorkspace = createInWorkspace; - this.createInWorkspaceInPlace = createInWorkspaceInPlace; this.openSession = openSession; - this.openSessionInPlace = openSessionInPlace; this.deleteSession = deleteSession; - } -} - -export class SidebarPresentationActions { - readonly close: () => void; readonly newChat: () => void; readonly enterCode: () => void; - readonly settings: () => void; readonly openAccount: () => void; - readonly openSession: (session: RemoteSession) => void; - readonly archive: (session: RemoteSession, archived: boolean) => void; - readonly exportSession: (session: RemoteSession) => void; readonly deleteSession: (session: RemoteSession) => void; - constructor( - close: () => void, newChat: () => void, enterCode: () => void, settings: () => void, openAccount: () => void, - openSession: (session: RemoteSession) => void, archive: (session: RemoteSession, archived: boolean) => void, - exportSession: (session: RemoteSession) => void, deleteSession: (session: RemoteSession) => void - ) { - this.close = close; this.newChat = newChat; this.enterCode = enterCode; this.settings = settings; - this.openAccount = openAccount; - this.openSession = openSession; this.archive = archive; this.exportSession = exportSession; this.deleteSession = deleteSession; - } -} - -export class SettingsPresentationActions { - readonly close: () => void; readonly addConnection: () => void; readonly disconnect: () => void; - readonly reconnect: () => void; - readonly openAccount: () => void; - readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; - readonly cloudSync: () => Promise; - readonly cloudLogout: () => Promise; - readonly cloudListDevices: () => Promise; - readonly getPermissionMode: () => Promise; - readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; - readonly testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; - readonly saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; - constructor( - close: () => void, addConnection: () => void, disconnect: () => void, reconnect: () => void, - openAccount: () => void, - cloudLogin: (relayUrl: string, username: string, password: string) => Promise, - cloudSync: () => Promise, cloudLogout: () => Promise, - cloudListDevices: () => Promise, - getPermissionMode: () => Promise, - setPermissionMode: (mode: RemotePermissionMode) => Promise, - testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise, - saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise - ) { - this.close = close; this.addConnection = addConnection; this.disconnect = disconnect; - this.reconnect = reconnect; this.openAccount = openAccount; this.cloudLogin = cloudLogin; this.cloudSync = cloudSync; - this.cloudLogout = cloudLogout; this.cloudListDevices = cloudListDevices; - this.getPermissionMode = getPermissionMode; this.setPermissionMode = setPermissionMode; - this.testGeneral = testGeneral; - this.saveGeneral = saveGeneral; - } -} - -export class ConnectPresentationActions { - readonly back: () => void; readonly connect: (password?: string) => void; readonly clearPairing: () => void; - readonly urlChanged: (url: string) => void; readonly userChanged: (user: string) => void; - readonly detected: (url: string) => boolean; readonly inputVisible: (visible: boolean) => void; - readonly paste: () => void; readonly scan: () => void; - readonly cloudListDevices: () => Promise; - readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; - constructor( - back: () => void, connect: (password?: string) => void, clearPairing: () => void, - urlChanged: (url: string) => void, userChanged: (user: string) => void, - detected: (url: string) => boolean, inputVisible: (visible: boolean) => void, - paste: () => void, scan: () => void, cloudListDevices: () => Promise, - cloudSelectDevice: (device: CloudAccountDevice) => Promise - ) { - this.back = back; this.connect = connect; this.clearPairing = clearPairing; - this.urlChanged = urlChanged; this.userChanged = userChanged; this.detected = detected; - this.inputVisible = inputVisible; this.paste = paste; this.scan = scan; - this.cloudListDevices = cloudListDevices; this.cloudSelectDevice = cloudSelectDevice; - } -} - @ComponentV2 export struct AppRootPresentation { @Param shellState: AppShellState = new AppShellState(); @@ -291,14 +76,8 @@ export struct AppRootPresentation { @Local wideMasterPaneCollapsed: boolean = false; @Local wideMasterPaneMotionActive: boolean = false; @Local restoreCollapsedMasterAfterPreview: boolean = false; - @Local remoteWideSortMode: string = 'project'; - @Local remoteWorkspaceFilter: string = ''; - @Local remoteAgentFilter: string = ''; - @Local remoteStatusFilter: string = ''; @Local showRemoteViewSettings: boolean = false; - @Local showRemoteWorkspaceMetadata: boolean = false; - @Local showRemoteUpdatedMetadata: boolean = false; - @Local showRemoteStatusMetadata: boolean = false; + @Local remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); private readonly deviceType: string = safeDeviceType(); private verticalCreases: ConversationLayoutCrease[] = []; private wideQueryListener?: mediaQuery.MediaQueryListener; @@ -312,19 +91,7 @@ export struct AppRootPresentation { this.wideLayoutMatched = result.matches; this.refreshWideGeometry(); }; - @Param actions: AppRootPresentationActions = new AppRootPresentationActions( - () => false, () => {}, () => {}, () => {}, () => {}, () => {}, - new RemoteHomePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, - () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, - () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new RemoteCreatePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new SidebarPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new SettingsPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, async (_relayUrl: string, _username: string, _password: string): Promise => '', async (): Promise => '', async (): Promise => {}, async (): Promise => [], async (): Promise => 'ask', async (mode: RemotePermissionMode): Promise => mode, async (_url: string, _key: string, _model: string, _clear: boolean): Promise => '', async (_url: string, _key: string, _model: string, _clear: boolean): Promise => ''), - new ConnectPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => false, () => {}, () => {}, - () => {}, async (): Promise => [], async (_device: CloudAccountDevice): Promise => {}), - new FilePreviewPresentationActions(() => {}, () => {}, () => {}, () => {}), - () => '' - ); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); aboutToAppear(): void { this.bindResponsiveQueries(); @@ -378,423 +145,58 @@ export struct AppRootPresentation { @Builder RouteContent(route: AppRoute) { - if (this.isGeneralWideRoute(route) && this.isWideLayout()) { - this.WideGeneralChatContent(route) - } else if (this.showsWideRemoteConversation(route) && - this.filePreviewPlacement() === FilePreviewPlacement.WideFocusSplit) { - this.WideRemotePreviewFocusContent() - } else if (this.showsWideRemoteConversation(route)) { - this.WideRemoteChatContent() - } else if (route === AppRoute.RemoteHome && this.isWideLayout()) { - this.WideRemoteHomeContent() - } else if (route === AppRoute.RemoteCreate && this.isWideLayout()) { - this.WideRemoteCreateContent() + if (this.isWideLayout() && this.isConversationRoute(route)) { + WideConversationHost({ + route, + shellState: this.shellState, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + filePreviewLayout: this.filePreviewLayout(), + wideMasterPaneWidth: this.wideMasterPaneWidth, + wideMasterDetailGap: this.wideMasterDetailGap, + wideDetailContentOffset: this.wideDetailContentOffset, + wideDetailContentWidth: this.wideDetailContentWidth, + wideCollapsedDetailContentOffset: this.wideCollapsedDetailContentOffset, + wideCollapsedDetailContentWidth: this.wideCollapsedDetailContentWidth, + wideMasterPaneCollapsed: this.wideMasterPaneCollapsed, + wideMasterPaneMotionActive: this.wideMasterPaneMotionActive, + onCollapseMasterPane: () => this.collapseWideMasterPane(), + onRestoreMasterPane: () => this.restoreWideMasterPane(), + onOpenRemoteViewSettings: () => { this.showRemoteViewSettings = true; } + }) } else { - this.RouteSurfaceContent(route, true, route !== AppRoute.ChatHome) - } - } - - @Builder - RouteSurfaceContent( - route: AppRoute, - showSidebarButton: boolean, - showBackButton: boolean, - showSidebarRestoreButton: boolean = false, - useWidePresentation: boolean = false - ) { - Column() { - if (route === AppRoute.RemoteHome) { - this.CompactRemoteHomeContent() - } else if (route === AppRoute.RemoteCreate) { - RemoteCreateSessionView({ - state: this.remoteCreateState, - presentation: useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Create, - isVoiceListening: this.remoteCreateState.isVoiceListening, - modelCatalog: toConversationUiModelCatalog(this.remotePageState.modelCatalog), - selectedModelId: this.remoteCreateState.selectedModelId, - showSidebarRestoreButton: showSidebarRestoreButton, - onRestoreSidebar: () => { - this.restoreWideMasterPane(); - }, - onBack: this.actions.onRemoteCreate.back, - onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, - onToggleWorkspaceMenu: this.actions.onRemoteCreate.toggleWorkspaces, - onSelectDevice: this.actions.onRemoteCreate.selectDevice, - onSelectWorkspace: (workspace) => this.actions.onRemoteCreate.selectWorkspace(workspace?.path || ''), - onDraftChange: this.actions.onRemoteCreate.draftChanged, - onVoiceInput: this.actions.onRemoteCreate.voiceInput, - onSelectModel: this.actions.onRemoteCreate.selectModel, - onSend: this.actions.onRemoteCreate.send - }) - } else { - ConversationViewHost({ - viewState: ConversationViewState.project(route, this.remotePageState, this.generalPageState, - this.actions.generalStatus()), - activeFilePreviewPath: route === AppRoute.RemoteChat && this.filePreviewState.visible ? - this.filePreviewState.target.remotePath : '', - activeFilePreviewLoading: route === AppRoute.RemoteChat && this.filePreviewState.visible && - this.filePreviewState.phase === FilePreviewPhase.Loading, - showSidebarButton: showSidebarButton, - showBackButton: showBackButton, - showSidebarRestoreButton: showSidebarRestoreButton, - composerPresentation: useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Compact, - contentHorizontalOffset: useWidePresentation ? this.collapsedDetailVisualBias() : 0, - onRestoreSidebar: () => { - this.restoreWideMasterPane(); - }, - onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(route, intent) - }) - } - }.width('100%').height('100%').backgroundColor(PAGE_BG) - } - - @Builder - WideGeneralChatContent(route: AppRoute) { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.General, false) - this.WideMasterDetailGap() - } - this.WideConversationDetail(route, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - WideRemoteHomeContent() { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, false) - this.WideMasterDetailGap() - } - - Column() { - this.RemoteFlowPlaceholder() - } - .layoutWeight(1) - .height('100%') - .backgroundColor(PAGE_BG) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - WideRemoteCreateContent() { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, false) - this.WideMasterDetailGap() - } - this.WideConversationDetail(AppRoute.RemoteCreate, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - /** - * The single wide master pane shell. Local and Remote differ only in the - * session content they hand to the shared sidebar, so the header, source - * switcher, content origin and footer never move when the source changes. - */ - @Builder - WideMasterPane(source: ConversationSource, showSelectedSession: boolean) { - Column() { - Column() { - AppSidebar({ - sessions: source === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), - pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: source === ConversationSource.Remote ? '' : - this.generalPageState.activeSession.sessionId, - connectionState: this.remotePageState.connectionState, - accountUserId: this.remotePageState.accountUserId, - activeSection: source === ConversationSource.Remote ? 'remote' : 'chat', - showConversationSourceSwitcher: true, - showCollapseButton: true, - showViewSettingsButton: source === ConversationSource.Remote, - showCustomContent: source === ConversationSource.Remote, - conversationSource: source, - contentSlot: () => { - this.RemoteMasterContent(showSelectedSession); - }, - onClose: this.actions.onSidebar.close, - onNewChat: source === ConversationSource.Remote ? - this.actions.onRemoteHome.createAssistant : this.actions.onSidebar.newChat, - onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), - onConversationSource: this.actions.onWideConversationSource, - onCollapse: () => { - this.collapseWideMasterPane(); - }, - onOpenViewSettings: () => { - this.showRemoteViewSettings = true; - }, - onSearchQueryChange: (query: string) => { - if (source === ConversationSource.Remote) { - this.actions.onRemoteHome.queryChanged(query); - } - }, - onOpenSettings: source === ConversationSource.Remote ? - this.actions.onRemoteHome.openSettings : this.actions.onSidebar.settings, - onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, - onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession - }) - } - .width('100%') - .height('100%') - .backgroundColor(FLOATING_PANEL_BG) - .borderRadius(18) - .clip(true) - .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) - } - .width(this.wideMasterPaneCurrentWidth()) - .height('100%') - .padding({ left: 10, right: 6, top: 10, bottom: 10 }) - .backgroundColor(PAGE_BG) - .transition(this.wideMasterPaneMotionActive ? - TransitionEffect.translate({ x: -28, y: 0 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseInOut }) : - TransitionEffect.opacity(1)) - } - - /** - * Remote session content for the shared sidebar shell. The wide master pane - * opens sessions in place next to the list; the compact drawer has to close - * itself and navigate, so every entry point is routed through a compact flag - * instead of a second copy of the list. - */ - @Builder - RemoteMasterContent(showSelectedSession: boolean, compact: boolean = false) { - Column() { - this.RemoteStatusRow() - if (this.isRemoteInitialLoading()) { - RemoteSessionLoadingView() - } else if (this.canShowRemoteSessionList()) { - RemoteSessionList({ - sessions: this.remotePageState.visibleSessions(), - query: this.remotePageState.sessionQuery, - sortMode: this.remoteWideSortMode, - workspaceFilter: this.remoteWorkspaceFilter, - agentFilter: this.remoteAgentFilter, - statusFilter: this.remoteStatusFilter, - workspaceName: this.remotePageState.workspaceName, - workspacePath: this.remotePageState.workspacePath, - workspaceKind: this.remotePageState.workspaceKind, - recentWorkspaces: this.remotePageState.recentWorkspaces, - actionPresentation: SessionActionPresentation.Popover, - showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, - showUpdatedMetadata: this.showRemoteUpdatedMetadata, - showStatusMetadata: this.showRemoteStatusMetadata, - hasMoreSessions: this.remotePageState.hasMoreSessions, - isBusy: this.remotePageState.isBusy || this.remotePageState.isLoadingSessions, - selectedSessionId: showSelectedSession ? this.remotePageState.activeSession.sessionId : '', - onCreate: () => { - this.createRemoteSession('code', compact); - }, - onCreateAssistantSession: () => { - this.createRemoteAssistantSession(compact); - }, - onCreateInWorkspace: (path: string, agentType: string) => { - this.createRemoteSessionInWorkspace(path, agentType, compact); - }, - onSelectWorkspace: (path: string) => { - this.actions.onRemoteHome.selectWorkspace(path); - }, - onOpenSession: (session: RemoteSession) => { - this.openRemoteSession(session, compact); - }, - onDeleteSession: (session: RemoteSession) => { - this.actions.onRemoteHome.deleteSession(session); - }, - onLoadMore: () => { - this.actions.onRemoteHome.loadMore(); - } - }) - } else { - this.RemoteDisconnectedState() - } - } - .width('100%') - .height('100%') - .alignItems(HorizontalAlign.Start) - .padding({ bottom: 84 }) - } - - /** Connection status lives in the remote content, not in the shared header. */ - @Builder - RemoteStatusRow() { - Row({ space: 6 }) { - this.RemoteStatusIndicator() - Text(this.remoteStatusText()) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) + ConversationRouteSurface({ + route, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + showSidebarButton: true, + // Compact conversations own the drawer, not a back control: Local and + // Remote both open the sidebar over the chat instead of leaving it. + showBackButton: false, + onRestoreSidebar: () => this.restoreWideMasterPane() + }) } - .width('100%') - .margin({ top: 16, bottom: 6 }) - .alignItems(VerticalAlign.Center) } @Builder RemoteViewSettingsSheet() { - ConversationViewSettings({ - sessions: this.remotePageState.visibleSessions(), - workspaceName: this.remotePageState.workspaceName, - workspacePath: this.remotePageState.workspacePath, - workspaceKind: this.remotePageState.workspaceKind, - recentWorkspaces: this.remotePageState.recentWorkspaces, - sortMode: this.remoteWideSortMode, - workspaceFilter: this.remoteWorkspaceFilter, - agentFilter: this.remoteAgentFilter, - statusFilter: this.remoteStatusFilter, - showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, - showUpdatedMetadata: this.showRemoteUpdatedMetadata, - showStatusMetadata: this.showRemoteStatusMetadata, - onSortModeChange: (mode: string) => { - this.remoteWideSortMode = mode; - }, - onWorkspaceFilterChange: (value: string) => { - RemoteLogger.info(`wide view-settings workspace received=${value.length > 0 ? value : ''}`); - this.remoteWorkspaceFilter = value; - }, - onAgentFilterChange: (value: string) => { - this.remoteAgentFilter = value; - }, - onStatusFilterChange: (value: string) => { - this.remoteStatusFilter = value; - }, - onWorkspaceMetadataChange: (value: boolean) => { - this.showRemoteWorkspaceMetadata = value; - }, - onUpdatedMetadataChange: (value: boolean) => { - this.showRemoteUpdatedMetadata = value; - }, - onStatusMetadataChange: (value: boolean) => { - this.showRemoteStatusMetadata = value; - }, - onClose: () => { - this.showRemoteViewSettings = false; - } + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Settings, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + onCloseSettings: () => { this.showRemoteViewSettings = false; } }) } - @Builder - RemoteStatusIndicator() { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(14) - .height(14) - .color(MUTED) - } else { - Stack() { - Text('') - } - .width(7) - .height(7) - .backgroundColor(this.remoteStatusColor()) - .borderRadius(4) - } - } - - @Builder - RemoteDisconnectedState() { - Column({ space: 12 }) { - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(42) - .fontColor([INK]) - } - .width(74) - .height(74) - .backgroundColor(CARD) - .borderRadius(24) - .border({ width: 1, color: LINE }) - Text(RemoteI18n.t('remote.connectTitle')) - .fontSize(18) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('remote.connectText')) - .fontSize(13) - .lineHeight(20) - .fontColor(MUTED) - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('connect.connect')) - .width(136) - .height(44) - .fontSize(15) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center) - .borderRadius(22) - .onClick(() => { - this.actions.onRemoteHome.connectWorkspace(); - }) - } - .layoutWeight(1) - .width('100%') - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 20, right: 20, bottom: 48 }) - } - - @Builder - WideRemoteChatContent() { - if (this.filePreviewPlacement() === FilePreviewPlacement.WideTriplePane) { - Row() { - this.WideMasterPane(ConversationSource.Remote, true) - this.WidePaneGap(this.filePreviewLayout().masterConversationGap) - this.WideConversationDetail( - AppRoute.RemoteChat, - false, - this.filePreviewLayout().conversationPaneWidth - ) - this.WidePaneGap(this.filePreviewLayout().conversationPreviewGap) - this.FilePreviewPane(this.filePreviewLayout().previewPaneWidth) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } else { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, true) - this.WideMasterDetailGap() - } - this.WideConversationDetail(AppRoute.RemoteChat, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - } - - @Builder - WideRemotePreviewFocusContent() { - Row() { - this.WideConversationDetail( - AppRoute.RemoteChat, - false, - this.filePreviewLayout().conversationPaneWidth - ) - this.WidePaneGap(this.filePreviewLayout().conversationPreviewGap) - this.FilePreviewPane(this.filePreviewLayout().previewPaneWidth) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - @Builder FilePreviewPane(paneWidth: number = 0) { Column() { @@ -816,232 +218,10 @@ export struct AppRootPresentation { .backgroundColor(PAGE_BG) } - @Builder - WideConversationDetail(route: AppRoute, showBackButton: boolean, paneWidth: number = 0) { - if (paneWidth > 0) { - Column() { - this.RouteSurfaceContent(route, false, showBackButton, false, true) - } - .width(paneWidth) - .height('100%') - .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) - .backgroundColor(PAGE_BG) - } else { - Stack({ alignContent: Alignment.TopStart }) { - Row() { - if (this.currentDetailContentOffset() > 0) { - Blank().width(this.currentDetailContentOffset()) - } - Row() { - Column() { - this.RouteSurfaceContent(route, false, showBackButton, false, true) - } - .width('100%') - .height('100%') - .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) - .backgroundColor(PAGE_BG) - } - .width(this.currentDetailContentWidth() > 0 ? this.currentDetailContentWidth() : '100%') - .height('100%') - .justifyContent(FlexAlign.Center) - if (this.currentDetailContentOffset() > 0) { - Blank().layoutWeight(1) - } - } - .width('100%') - .height('100%') - .justifyContent(FlexAlign.Center) - .backgroundColor(PAGE_BG) - - if (this.wideMasterPaneCollapsed) { - SidebarToggleButton({ - restore: true, - controlSize: 44, - onToggle: () => { - this.restoreWideMasterPane(); - } - }) - .position({ x: this.currentDetailContentOffset() + 12, y: 12 }) - .zIndex(2) - .transition(TransitionEffect.scale({ x: 0.9, y: 0.9 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 180, curve: Curve.EaseOut })) - } - } - .layoutWeight(1) - .height('100%') - .backgroundColor(PAGE_BG) - } - } - - @Builder - WideMasterDetailGap() { - if (this.wideMasterDetailGap > 0) { - Row() { - } - .width(this.wideMasterDetailGap) - .height('100%') - .backgroundColor(LINE) - } - } - - @Builder - WidePaneGap(width: number) { - if (width > 0) { - Row() { - } - .width(width) - .height('100%') - .backgroundColor(LINE) - } - } - - /** - * Compact Remote landing surface. The session list lives in the shared drawer - * now, so this route only carries connection state and the way back into the - * drawer — the same shape the Local composer route has. - */ - @Builder - CompactRemoteHomeContent() { - Column() { - GeneralChatHeader({ - title: RemoteI18n.t('remote.title'), - showSidebarButton: true, - onOpenSidebar: this.actions.onRemoteHome.openSidebar - }) - if (this.canShowRemoteSessionList()) { - this.CompactRemoteEmptyState() - } else { - this.RemoteDisconnectedState() - } - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - CompactRemoteEmptyState() { - Column({ space: 10 }) { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(28) - .height(28) - .color(MUTED) - .margin({ bottom: 8 }) - } - Text(this.compactRemoteEmptyTitle()) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .textAlign(TextAlign.Center) - Text(this.compactRemoteEmptyText()) - .fontSize(14) - .lineHeight(21) - .fontColor(MUTED) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .textAlign(TextAlign.Center) - .constraintSize({ maxWidth: 280 }) - Text(RemoteI18n.t('remote.startSession')) - .width(148) - .height(46) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center) - .borderRadius(23) - .margin({ top: 12 }) - .onClick(() => { - this.actions.onRemoteHome.createAssistant(); - }) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 24, right: 24, bottom: 56 }) - } - - @Builder - RemoteFlowPlaceholder() { - Column() { - Row({ space: 8 }) { - if (this.wideMasterPaneCollapsed) { - SidebarToggleButton({ - restore: true, - controlSize: 48, - onToggle: () => { - this.restoreWideMasterPane(); - } - }) - } else { - Blank().width(48).height(48) - } - Column({ space: 4 }) { - Text(RemoteI18n.t('remote.chats')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Text(this.remoteDesktopName()) - .fontSize(13) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Center) - Blank().width(48).height(48) - } - .width('100%') - .height(76) - .padding({ left: 16, right: 16, top: 14, bottom: 12 }) - .border({ width: { bottom: 1 }, color: LINE }) - - Column({ space: 8 }) { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(28) - .height(28) - .color(MUTED) - .margin({ bottom: 8 }) - } - Text(this.remoteFlowPlaceholderTitle()) - .fontSize(22) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Text(this.remoteStatusText()) - .fontSize(14) - .fontColor(MUTED) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 24, right: 24, bottom: 48 }) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - private isWideLayout(): boolean { return this.largeScreenLayout; } - /** - * Read inside the master pane builder rather than passed in: a @Builder only - * re-renders on parameters passed by reference, so a width handed over as a - * value would freeze at whatever the pane measured on its first render. - */ - private wideMasterPaneCurrentWidth(): number { - return this.filePreviewPlacement() === FilePreviewPlacement.WideTriplePane ? - this.filePreviewLayout().masterPaneWidth : this.wideMasterPaneWidth; - } - private collapseWideMasterPane(): void { if (!this.isWideLayout() || this.filePreviewState.visible) { return; @@ -1067,24 +247,6 @@ export struct AppRootPresentation { }, 240); } - private currentDetailContentOffset(): number { - return this.wideMasterPaneCollapsed ? - this.wideCollapsedDetailContentOffset : this.wideDetailContentOffset; - } - - private currentDetailContentWidth(): number { - return this.wideMasterPaneCollapsed ? - this.wideCollapsedDetailContentWidth : this.wideDetailContentWidth; - } - - private collapsedDetailVisualBias(): number { - if (!this.wideMasterPaneCollapsed || this.wideCollapsedDetailContentOffset > 0) { - return 0; - } - const availableMargin = (this.wideCollapsedDetailContentWidth - WIDE_DETAIL_CONTENT_MAX_WIDTH) / 2; - return Math.min(72, Math.max(0, availableMargin)); - } - private filePreviewPlacement(): FilePreviewPlacement { return this.filePreviewLayout().placement; } @@ -1099,16 +261,9 @@ export struct AppRootPresentation { ); } - private isGeneralWideRoute(route: AppRoute): boolean { - return route === AppRoute.ChatHome || route === AppRoute.GeneralChat; - } - - private showsWideRemoteConversation(route: AppRoute): boolean { - if (!this.isWideLayout()) { - return false; - } - return route === AppRoute.RemoteChat || - (route === AppRoute.RemoteHome && this.remotePageState.activeSession.sessionId.length > 0); + private isConversationRoute(route: AppRoute): boolean { + return route === AppRoute.ChatHome || route === AppRoute.GeneralChat || + route === AppRoute.RemoteHome || route === AppRoute.RemoteCreate || route === AppRoute.RemoteChat; } private bindResponsiveQueries(): void { @@ -1153,8 +308,7 @@ export struct AppRootPresentation { } private areaWidth(width: Object): number { - const value = Number.parseFloat(`${width}`); - return Number.isNaN(value) ? 0 : value; + return WideLayoutGeometry.areaLength(width); } private refreshWideGeometry(): void { @@ -1178,6 +332,7 @@ export struct AppRootPresentation { this.wideDetailContentWidth = geometry.detailContentWidth; this.wideCollapsedDetailContentOffset = geometry.collapsedDetailContentOffset; this.wideCollapsedDetailContentWidth = geometry.collapsedDetailContentWidth; + this.actions.onLayoutModeChanged(this.largeScreenLayout); if (wasWideLayout && !this.largeScreenLayout) { this.actions.onCompactLayoutEntered(); } @@ -1202,126 +357,6 @@ export struct AppRootPresentation { } } - /** - * Session entry points shared by the wide master pane and the compact drawer. - * The wide pane keeps the list on screen and swaps the detail pane; the - * compact drawer has to dismiss itself first and then navigate. - */ - private openRemoteSession(session: RemoteSession, compact: boolean): void { - if (compact) { - this.actions.onSidebar.openSession(session); - return; - } - this.actions.onRemoteHome.openSessionInPlace(session); - } - - private createRemoteSession(agentType: string, compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.create(agentType); - return; - } - this.actions.onRemoteHome.createInPlace(agentType); - } - - private createRemoteSessionInWorkspace(path: string, agentType: string, compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.createInWorkspace(path, agentType); - return; - } - this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); - } - - private createRemoteAssistantSession(compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - } - this.actions.onRemoteHome.createAssistant(); - } - - private compactSidebarSource(): ConversationSource { - return AppRouteContract.conversationSource(this.shellState.activeRoute); - } - - /** The compact drawer's new-chat and settings entries follow the active source. */ - private compactSidebarNewChat(source: ConversationSource): void { - if (source === ConversationSource.Remote) { - this.createRemoteAssistantSession(true); - return; - } - this.actions.onSidebar.newChat(); - } - - private compactSidebarSettings(source: ConversationSource): void { - if (source === ConversationSource.Remote) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.openSettings(); - return; - } - this.actions.onSidebar.settings(); - } - - private canShowRemoteSessionList(): boolean { - return this.remotePageState.connectionState === 'connected' || this.remotePageState.visibleSessions().length > 0 || - this.remotePageState.isLoadingHome || this.remotePageState.isLoadingSessions; - } - - private isRemoteInitialLoading(): boolean { - return this.remotePageState.isLoadingHome || this.isRemoteConnecting(); - } - - private isRemoteConnecting(): boolean { - return this.remotePageState.connectionState === 'parsing' || - this.remotePageState.connectionState === 'pairing' || - this.remotePageState.connectionState === 'reconnecting'; - } - - private remoteStatusText(): string { - if (this.remotePageState.statusText.length > 0) { - return this.remotePageState.statusText; - } - return this.remoteDesktopName(); - } - - private remoteStatusColor(): ResourceColor { - if (this.remotePageState.connectionState === 'connected') { - return GREEN; - } - if (this.remotePageState.connectionState === 'failed' || this.remotePageState.connectionState === 'disconnected') { - return RED; - } - return MUTED; - } - - private remoteDesktopName(): string { - return this.remotePageState.desktopName.length > 0 ? this.remotePageState.desktopName : - RemoteI18n.t('remote.settings.noDesktop'); - } - - private compactRemoteEmptyTitle(): string { - if (this.isRemoteInitialLoading()) { - return RemoteI18n.t('common.loading'); - } - return this.remotePageState.visibleSessions().length > 0 ? - RemoteI18n.t('remote.pickSession') : RemoteI18n.t('remote.emptyTitle'); - } - - private compactRemoteEmptyText(): string { - if (this.isRemoteInitialLoading()) { - return this.remoteStatusText(); - } - return this.remotePageState.visibleSessions().length > 0 ? - RemoteI18n.t('remote.pickSessionText') : RemoteI18n.t('remote.emptyText'); - } - - private remoteFlowPlaceholderTitle(): string { - if (this.isRemoteInitialLoading()) { - return RemoteI18n.t('common.loading'); - } - return this.remotePageState.visibleSessions().length > 0 ? '选择会话' : RemoteI18n.t('remote.emptyTitle'); - } - private remoteViewSettingsSheetOptions(): SheetOptions { if (!this.isWideLayout()) { return { @@ -1343,107 +378,32 @@ export struct AppRootPresentation { }; } - /** - * The compact drawer runs the same sidebar shell as the wide master pane, so - * Local and Remote are two sources inside one session container instead of a - * drawer and a separate destination page. The drawer outlives every route - * change, and a @Builder does not re-render on value parameters, so the source - * is read from the current route on each render instead of being passed in. - */ @Builder SidebarContent() { - AppSidebar({ - sessions: this.compactSidebarSource() === ConversationSource.Remote ? - [] : this.generalPageState.recentSessions(), - pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: this.compactSidebarSource() === ConversationSource.Remote ? '' : - (AppRouteContract.isGeneralComposerRoute(this.shellState.activeRoute) ? - this.generalPageState.activeSession.sessionId : ''), - connectionState: this.remotePageState.connectionState, - accountUserId: this.remotePageState.accountUserId, - activeSection: this.compactSidebarSource() === ConversationSource.Remote ? 'remote' : 'chat', - showConversationSourceSwitcher: true, - showViewSettingsButton: this.compactSidebarSource() === ConversationSource.Remote, - showCustomContent: this.compactSidebarSource() === ConversationSource.Remote, - conversationSource: this.compactSidebarSource(), - contentSlot: () => { - this.RemoteMasterContent(true, true); - }, - onClose: this.actions.onSidebar.close, - onNewChat: () => { - this.compactSidebarNewChat(this.compactSidebarSource()); - }, - onEnterCode: this.actions.onSidebar.enterCode, - onConversationSource: this.actions.onCompactConversationSource, - onOpenViewSettings: () => { - this.showRemoteViewSettings = true; - }, - onSearchQueryChange: (query: string) => { - if (this.compactSidebarSource() === ConversationSource.Remote) { - this.actions.onRemoteHome.queryChanged(query); - } - }, - onOpenSettings: () => { - this.compactSidebarSettings(this.compactSidebarSource()); - }, - onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, - onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession + AppSidebarSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + generalPageState: this.generalPageState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + onOpenRemoteViewSettings: () => { this.showRemoteViewSettings = true; } }) } @Builder SettingsContent() { - if (this.shellState.settingsMode === 'remote' || this.shellState.settingsMode === 'account') { - RemoteControlSettingsSheet({ desktopName: this.remotePageState.desktopName, desktopId: this.remotePageState.desktopId, - userId: this.remotePageState.userId, accountUsername: this.remotePageState.accountUsername, - accountUserId: this.remotePageState.accountUserId, deviceId: this.deviceId, - controlTargetType: this.remotePageState.controlTargetType, - controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, - connectionState: this.remotePageState.connectionState, statusText: this.remotePageState.statusText, - isBusy: this.remotePageState.isBusy, onClose: this.actions.onSettings.close, - onOpenAccount: this.actions.onSettings.openAccount, - onAddConnection: this.actions.onSettings.addConnection, - cloudLogin: this.actions.onSettings.cloudLogin, - cloudSync: this.actions.onSettings.cloudSync, - cloudLogout: this.actions.onSettings.cloudLogout, - cloudListDevices: this.actions.onSettings.cloudListDevices, - getPermissionMode: this.actions.onSettings.getPermissionMode, - setPermissionMode: this.actions.onSettings.setPermissionMode, - openAccountOnAppear: this.shellState.settingsMode === 'account', - onDisconnect: this.actions.onSettings.disconnect, onReconnect: this.actions.onSettings.reconnect }) - } else { - SettingsSheet({ generalChatApiUrl: this.generalPageState.apiUrl, generalChatModelName: this.generalPageState.modelName, - hasGeneralChatApiKey: this.generalPageState.hasApiKey, - generalChatModelCatalog: this.generalPageState.modelCatalog, - selectedGeneralChatModelId: this.generalPageState.selectedModelId, - accountUsername: this.remotePageState.accountUsername, - authenticatedUserId: this.remotePageState.accountUserId, + AppSettingsSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + generalPageState: this.generalPageState, deviceId: this.deviceId, - onOpenAccount: this.actions.onSettings.openAccount, - onTestGeneralChatConfig: this.actions.onSettings.testGeneral, - onSaveGeneralChatConfig: this.actions.onSettings.saveGeneral, - onClose: this.actions.onSettings.close }) - } + actions: this.actions + }) } @Builder ConnectContent() { - ConnectView({ remoteUrl: this.remotePageState.remoteUrl, userId: this.remotePageState.userId, - showRemoteUrlInput: this.remotePageState.showRemoteUrlInput, statusText: this.remotePageState.statusText, - connectionState: this.remotePageState.connectionState, connectionFailureKind: this.remotePageState.connectionFailureKind, - isBusy: this.remotePageState.isBusy, isConnected: this.remotePageState.connectionState === 'connected', - desktopName: this.remotePageState.desktopName, desktopId: this.remotePageState.desktopId, deviceId: this.deviceId, - accountUserId: this.remotePageState.accountUserId, - controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, - requiresAccountAuth: this.remotePageState.requiresAccountAuth, accountUsername: this.remotePageState.accountUsername, - startWithScanner: true, - onBack: this.actions.onConnect.back, onConnect: this.actions.onConnect.connect, - onClearPairing: this.actions.onConnect.clearPairing, onRemoteUrlChange: this.actions.onConnect.urlChanged, - onUserIdChange: this.actions.onConnect.userChanged, onRemoteUrlDetected: this.actions.onConnect.detected, - onRemoteUrlInputVisibleChange: this.actions.onConnect.inputVisible, - onPasteRemoteUrl: this.actions.onConnect.paste, onScanRemoteUrl: this.actions.onConnect.scan, - cloudListDevices: this.actions.onConnect.cloudListDevices, - cloudSelectDevice: this.actions.onConnect.cloudSelectDevice }) - .width('100%').height('100%') + AppConnectSurface({ + remotePageState: this.remotePageState, + deviceId: this.deviceId, + actions: this.actions + }) } } 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 08cec8c99d..6e02ff01b0 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 @@ -5,43 +5,44 @@ import { ConversationSource } from '../navigation/AppRouteContract'; import { ConversationSourceSwitcher } from './ConversationSourceSwitcher'; import { SidebarToggleButton } from './SidebarToggleButton'; import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; -import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../state/SessionActionPolicy'; +import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy'; import { SessionDetailsView } from './SessionDetailsView'; +import { SidebarGlyph } from './SidebarGlyphs'; -@Component +@ComponentV2 export struct AppSidebar { - @Prop sessions: RemoteSession[] = []; - @Prop pinnedSessionId: string = ''; - @Prop selectedSessionId: string = ''; - @Prop connectionState: string = 'idle'; - @Prop activeSection: string = 'chat'; - @Prop accountUserId: string = ''; - @Prop showConversationSourceSwitcher: boolean = false; - @Prop showCollapseButton: boolean = false; - @Prop showViewSettingsButton: boolean = false; - @Prop showCustomContent: boolean = false; - @Prop conversationSource: ConversationSource = ConversationSource.General; - onClose: () => void = () => {}; - onNewChat: () => void = () => {}; - onEnterCode: () => void = () => {}; - onConversationSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; - onCollapse: () => void = () => {}; - onOpenViewSettings: () => void = () => {}; - onSearchQueryChange: (query: string) => void = (_query: string) => {}; - onOpenSettings: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - onArchiveSession: (session: RemoteSession, archived: boolean) => void = + @Param sessions: RemoteSession[] = []; + @Param pinnedSessionId: string = ''; + @Param selectedSessionId: string = ''; + @Param connectionState: string = 'idle'; + @Param activeSection: string = 'chat'; + @Param accountUserId: string = ''; + @Param showConversationSourceSwitcher: boolean = false; + @Param showCollapseButton: boolean = false; + @Param showViewSettingsButton: boolean = false; + @Param showCustomContent: boolean = false; + @Param conversationSource: ConversationSource = ConversationSource.General; + @Event onClose: () => void = () => {}; + @Event onNewChat: () => void = () => {}; + @Event onEnterCode: () => void = () => {}; + @Event onConversationSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; + @Event onCollapse: () => void = () => {}; + @Event onOpenViewSettings: () => void = () => {}; + @Event onSearchQueryChange: (query: string) => void = (_query: string) => {}; + @Event onOpenSettings: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Event onArchiveSession: (session: RemoteSession, archived: boolean) => void = (_session: RemoteSession, _archived: boolean) => {}; - onExportSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @State activeActionSessionId: string = ''; - @State showSessionActionSheet: boolean = false; - @State detailsSessionId: string = ''; - @State showSessionDetails: boolean = false; - @State showSearch: boolean = false; - @State sessionSearchQuery: string = ''; - @State archivedSessionsExpanded: boolean = false; + @Event onExportSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Local activeActionSessionId: string = ''; + @Local showSessionActionSheet: boolean = false; + @Local detailsSessionId: string = ''; + @Local showSessionDetails: boolean = false; + @Local showSearch: boolean = false; + @Local sessionSearchQuery: string = ''; + @Local archivedSessionsExpanded: boolean = false; /** * Session content for the current conversation source. The shell around it * (header, source switcher, content origin, footer) stays identical for every @@ -180,7 +181,7 @@ export struct AppSidebar { Row({ space: 6 }) { if (this.showViewSettingsButton) { Stack({ alignContent: Alignment.Center }) { - this.MoreDotsGlyph() + SidebarGlyph({ kind: 'session_more' }) } .width(38) .height(38) @@ -195,7 +196,7 @@ export struct AppSidebar { } Stack({ alignContent: Alignment.Center }) { - this.SearchGlyph() + SidebarGlyph({ kind: 'search' }) } .width(38) .height(38) @@ -282,7 +283,7 @@ export struct AppSidebar { private AuthenticatedFooter() { Row() { Row({ space: 9 }) { - this.EditGlyph() + SidebarGlyph({ kind: 'edit' }) Text(RemoteI18n.t('sidebar.newChat')) .fontSize(15) .fontWeight(FontWeight.Medium) @@ -303,7 +304,7 @@ export struct AppSidebar { Blank() Stack({ alignContent: Alignment.Center }) { - this.SettingsGlyph() + SidebarGlyph({ kind: 'settings' }) } .width(46) .height(46) @@ -338,7 +339,7 @@ export struct AppSidebar { @Builder NavRow(label: string, isActive: boolean, action: () => void) { Row({ space: 14 }) { - this.RemoteGlyph() + SidebarGlyph({ kind: 'remote', connectionState: this.connectionState }) Text(label) .fontSize(18) .fontWeight(FontWeight.Bold) @@ -476,21 +477,10 @@ export struct AppSidebar { }) } - @Builder - private MoreDotsGlyph() { - Row({ space: 3 }) { - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - } - .height(8) - .alignItems(VerticalAlign.Center) - } - @Builder private SessionMoreButton(session: RemoteSession) { Stack({ alignContent: Alignment.Center }) { - this.MoreDotsGlyph() + SidebarGlyph({ kind: 'session_more' }) } .width(34) .height(40) @@ -558,205 +548,6 @@ export struct AppSidebar { .padding({ top: 8, bottom: 8 }) } - @Builder - RemoteGlyph() { - Stack({ alignContent: Alignment.Center }) { - if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { - Image($r('app.media.remote_ref_sidebar_connected')) - .width(35) - .height(34) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(INK) - Text('') - .width(8) - .height(8) - .backgroundColor(GREEN) - .borderRadius(4) - .position({ x: 24, y: 22 }) - } else { - Image($r('app.media.remote_logo')) - .width(34) - .height(34) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(MUTED) - } - } - .width(35) - .height(34) - } - - @Builder - SearchGlyph() { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - - @Builder - NotebookGlyph() { - Stack() { - Text('') - .width(22) - .height(24) - .borderRadius(5) - .border({ width: 1.5, color: INK }) - .position({ x: 8, y: 5 }) - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(INK) - .position({ x: 5, y: 11 }) - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(INK) - .position({ x: 5, y: 20 }) - } - .width(34) - .height(34) - } - - @Builder - ClockGlyph() { - Stack() { - Text('') - .width(26) - .height(26) - .borderRadius(13) - .border({ width: 1.5, color: INK }) - .position({ x: 4, y: 4 }) - Text('') - .width(1.5) - .height(9) - .backgroundColor(INK) - .borderRadius(2) - .position({ x: 18, y: 10 }) - Text('') - .width(9) - .height(1.5) - .backgroundColor(INK) - .borderRadius(2) - .position({ x: 18, y: 20 }) - } - .width(34) - .height(34) - } - - @Builder - AppsGlyph() { - Column({ space: 8 }) { - Row({ space: 8 }) { - this.AppDot() - this.AppDot() - } - Row({ space: 8 }) { - this.AppDot() - this.AppDot() - } - } - .width(24) - .height(24) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - } - - @Builder - AppDot() { - Text('') - .width(8) - .height(8) - .borderRadius(4) - .backgroundColor(INK) - } - - @Builder - CodeFlowerGlyph() { - Stack() { - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 9, y: 1 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 17, y: 9 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 9, y: 17 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 1, y: 9 }) - Text('') - .width(14) - .height(14) - .borderRadius(7) - .backgroundColor(CARD) - .position({ x: 10, y: 10 }) - } - .width(34) - .height(34) - } - - @Builder - MoreGlyph() { - Row({ space: 5 }) { - this.DotGlyph() - this.DotGlyph() - this.DotGlyph() - } - .width(30) - .height(22) - .justifyContent(FlexAlign.Center) - .alignItems(VerticalAlign.Center) - } - - @Builder - DotGlyph() { - Text('') - .width(5) - .height(5) - .borderRadius(3) - .backgroundColor(INK) - } - - @Builder - EditGlyph() { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - - @Builder - SettingsGlyph() { - SymbolGlyph($r('sys.symbol.gearshape')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - private visibleRecentSessions(): RemoteSession[] { const query = this.sessionSearchQuery.trim().toLowerCase(); return this.sessions.filter((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 38375e4387..c1a6917fd0 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 @@ -2,17 +2,17 @@ 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'; -@Component +@ComponentV2 export struct BitFunAccountLoginPage { - cloudLogin: (relayUrl: string, username: string, password: string) => Promise = + @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; - onBack: () => void = () => {}; - onLoginSuccess: () => void = () => {}; - @State relayUrl: string = DEFAULT_CLOUD_RELAY_URL; - @State username: string = ''; - @State password: string = ''; - @State errorText: string = ''; - @State isBusy: boolean = false; + @Event onBack: () => void = () => {}; + @Event onLoginSuccess: () => void = () => {}; + @Local relayUrl: string = DEFAULT_CLOUD_RELAY_URL; + @Local username: string = ''; + @Local password: string = ''; + @Local errorText: string = ''; + @Local isBusy: boolean = false; build() { Stack({ alignContent: Alignment.TopStart }) { 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 b2b3e8531c..66628abaa6 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,16 +1,10 @@ import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; -import { FileReferenceCard } from './FileReferenceCard'; -import { MarkdownContent } from './MarkdownContent'; -import { StreamingMarkdownContent } from './StreamingMarkdownContent'; +import { INK, LINE, MUTED, SOFT } from './Theme'; +import { MessageFileCards, MessageImageGallery, MessageMarkdown } from './ChatMessageContent'; +import { ChatMessageRetryAction, ChatTypingDots, ChatUserMessageBubble } from './ChatMessageChrome'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolStatusList } from './ToolStatusList'; -import { FileTargetResolver } from '../../services/FileTargetResolver'; -import { - MessageFileReference, - MessageFileReferenceProjectionCache -} from '../../services/MessageFileReferenceProjector'; +import { MessageFileReference, MessageFileReferenceProjectionCache } from '../../services/MessageFileReferenceProjector'; interface SubagentTaskInput { description?: string; @@ -28,13 +22,6 @@ interface StructuredRenderGroup { path: string; } -interface ActivityGroupStats { - thinkingCount: number; - readCount: number; - searchCount: number; - otherCount: number; -} - @ComponentV2 export struct ChatMessageBubble { @Param item: ConversationUiMessage = { @@ -63,83 +50,21 @@ export struct ChatMessageBubble { @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; - @Local expandedActivityPath: string = ''; - @Local typingPhase: number = 0; - private typingTimerId: number = 0; private readonly fileReferenceCache: MessageFileReferenceProjectionCache = new MessageFileReferenceProjectionCache(); - aboutToAppear(): void { - if (!this.shouldShowTypingDots(this.item) && !this.hasRunningSubagentTask(this.item)) { - return; - } - this.typingTimerId = setInterval(() => { - this.typingPhase = (this.typingPhase + 1) % 3; - }, 360); - } - - aboutToDisappear(): void { - if (this.typingTimerId !== 0) { - clearInterval(this.typingTimerId); - this.typingTimerId = 0; - } - } - build() { if (this.item.role === 'user') { - this.UserBubble() + ChatUserMessageBubble({ + item: this.item, + showRetryAction: this.showRetryAction, + onRetryMessage: this.onRetryMessage + }) } else { this.AssistantBubble() } } - @Builder - UserBubble() { - Row() { - Blank() - Column({ space: 6 }) { - if (this.visibleMessageText(this.item).length > 0 || (this.item.images && this.item.images.length > 0)) { - Column({ space: 8 }) { - if (this.item.images && this.item.images.length > 0) { - this.UserMessageImages(this.item.images) - } - if (this.visibleMessageText(this.item).length > 0) { - Text(this.visibleMessageText(this.item)) - .fontSize(14) - .lineHeight(20) - .fontColor(INK) - } - } - .padding({ left: 10, right: 10, top: 10, bottom: 10 }) - .backgroundColor(SOFT) - .borderRadius(18) - .alignItems(HorizontalAlign.Start) - } - if (this.item.status === 'failed' && this.showRetryAction) { - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.sendFailed')) - .fontSize(12) - .fontColor(RED) - Text(RemoteI18n.t('common.retry')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .height(28) - .padding({ left: 10, right: 10 }) - .backgroundColor(ACCENT) - .borderRadius(14) - .onClick(() => { - this.onRetryMessage(this.item.text); - }) - } - } - } - .constraintSize({ maxWidth: '70%' }) - .alignItems(HorizontalAlign.End) - } - .width('100%') - .padding({ top: 8, bottom: 12 }) - } - @Builder AssistantBubble() { Row() { @@ -154,21 +79,11 @@ export struct ChatMessageBubble { } if (this.item.status === 'failed' && this.showRetryAction && (this.item.detail || '').trim().length > 0) { - Row({ space: 8 }) { - Text(RemoteI18n.t('generalChat.replyInterrupted')) - .fontSize(12) - .fontColor(RED) - Text(RemoteI18n.t('common.retry')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .height(28) - .padding({ left: 10, right: 10 }) - .backgroundColor(ACCENT) - .borderRadius(14) - .onClick(() => { - this.onRetryMessage(this.item.detail || '') - }) - } + ChatMessageRetryAction({ + assistant: true, + retryText: this.item.detail || '', + onRetry: this.onRetryMessage + }) } } .layoutWeight(1) @@ -203,7 +118,7 @@ export struct ChatMessageBubble { } } if (this.shouldShowTypingDots(item)) { - this.TypingDots() + ChatTypingDots() } if (item.tools && item.tools.length > 0) { this.Tools(item.tools) @@ -219,28 +134,6 @@ export struct ChatMessageBubble { } } - @Builder - AssistantAvatar() { - Row({ space: 4 }) { - Text('') - .width(6) - .height(6) - .backgroundColor(PRIMARY_ACTION_TEXT) - .borderRadius(3) - Text('') - .width(6) - .height(6) - .backgroundColor(PRIMARY_ACTION_TEXT) - .borderRadius(3) - } - .width(32) - .height(32) - .backgroundColor(ACCENT) - .borderRadius(16) - .justifyContent(FlexAlign.Center) - .alignItems(VerticalAlign.Center) - } - @Builder StructuredItems(items: ConversationUiMessageItem[], omitActiveThinking: boolean = false) { Column({ space: 10 }) { @@ -357,7 +250,7 @@ export struct ChatMessageBubble { } .width('100%') if (entry.tool && this.isRunningTool(entry.tool)) { - this.TypingDots() + ChatTypingDots() } if (entry.content && entry.content.trim().length > 0 && !this.isTextEntry(entry) && !this.isThinkingEntry(entry)) { this.MessageText(entry.content, activeScope && (!entry.subItems || entry.subItems.length === 0), `${this.item.id}-${path}-subagent`) @@ -422,122 +315,38 @@ export struct ChatMessageBubble { }) } - @Builder - TypingDots() { - Row({ space: 5 }) { - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(0)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(1)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(2)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - } - .height(24) - .padding({ left: 2 }) - } - - private typingDotOpacity(index: number): number { - return this.typingPhase === index ? 1.0 : 0.34; - } - @Builder MessageImages(images: ConversationUiImage[]) { - Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { - ForEach(images, (image: ConversationUiImage) => { - Image(image.data_url) - .width(92) - .height(92) - .objectFit(ImageFit.Cover) - .borderRadius(14) - .border({ width: 1, color: LINE }) - .margin({ right: 8, bottom: 8 }) - }, (image: ConversationUiImage) => image.name) - } - .width('100%') - } - - @Builder - UserMessageImages(images: ConversationUiImage[]) { - Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { - ForEach(images, (image: ConversationUiImage, index: number) => { - Image(image.data_url) - .width(112) - .height(112) - .objectFit(ImageFit.Cover) - .borderRadius(12) - .border({ width: 1, color: LINE }) - .margin({ right: index % 2 === 0 && images.length > 1 ? 8 : 0, bottom: index < images.length - 2 ? 8 : 0 }) - }, (image: ConversationUiImage, index: number) => `${image.name}-${index}`) - } - .width(images.length > 1 ? 232 : 112) + MessageImageGallery({ images }) } @Builder MessageText(text: string, active: boolean = false, streamKey: string = '') { - if (active) { - StreamingMarkdownContent({ - text, - active, - streamKey, - onCopyText: (body: string) => { - this.onCopyMessage(body); - }, - onOpenLink: (reference: string, label: string) => { - this.onOpenFilePreview(reference, label); - } - }) - } else { - MarkdownContent({ - text, - onCopyText: (body: string) => { - this.onCopyMessage(body); - }, - onOpenLink: (reference: string, label: string) => { - this.onOpenFilePreview(reference, label); - } - }) - } + MessageMarkdown({ + text, + active, + streamKey, + onCopyText: this.onCopyMessage, + onOpenLink: this.onOpenFilePreview + }) } @Builder FileCards(text: string) { - Column({ space: 8 }) { - ForEach(this.fileReferences(text), (file: MessageFileReference) => { - FileReferenceCard({ - path: file.path, - label: file.label, - status: this.fileStatus(file.path), - previewLabel: RemoteI18n.t('common.open'), - buttonLabel: this.fileButtonLabel(file.path), - disabled: this.downloadingFilePath === file.path, - selected: FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), - previewLoading: this.activeFilePreviewLoading && - FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), - onPreview: (path: string, label: string) => { - this.onOpenFilePreview(path, label); - }, - onDownload: (path: string) => { - this.onDownloadFile(path); - } - }) - }, (file: MessageFileReference) => file.id) - } - .width('100%') + MessageFileCards({ + text, + downloadingFilePath: this.downloadingFilePath, + downloadedFilePath: this.downloadedFilePath, + fileDownloadStatus: this.fileDownloadStatus, + activeFilePreviewPath: this.activeFilePreviewPath, + activeFilePreviewLoading: this.activeFilePreviewLoading, + onPreview: this.onOpenFilePreview, + onDownload: this.onDownloadFile + }) + } + + private fileReferences(text: string): MessageFileReference[] { + return this.fileReferenceCache.referencesFor(text); } private visibleMessageText(item: ConversationUiMessage): string { @@ -896,56 +705,6 @@ export struct ChatMessageBubble { return ''; } - private activityGroupTitle(group: StructuredRenderGroup): string { - const stats = this.activityGroupStats(group.items); - const total = stats.thinkingCount + stats.readCount + stats.searchCount + stats.otherCount; - return `已折叠 ${total} 个思考和工具调用`; - } - - private activityGroupDetail(group: StructuredRenderGroup): string { - const stats = this.activityGroupStats(group.items); - const parts: string[] = []; - if (stats.thinkingCount > 0) { - parts.push(`思考 ${stats.thinkingCount}`); - } - if (stats.readCount > 0) { - parts.push(`读取 ${stats.readCount}`); - } - if (stats.searchCount > 0) { - parts.push(`搜索 ${stats.searchCount}`); - } - if (stats.otherCount > 0) { - parts.push(`其他 ${stats.otherCount}`); - } - return parts.join(' · '); - } - - private activityGroupStats(items: ConversationUiMessageItem[]): ActivityGroupStats { - const stats: ActivityGroupStats = { - thinkingCount: 0, - readCount: 0, - searchCount: 0, - otherCount: 0 - }; - items.forEach((entry: ConversationUiMessageItem) => { - if (this.isThinkingEntry(entry)) { - stats.thinkingCount += 1; - return; - } - if (entry.tool) { - const kind = this.activityToolKind(entry.tool); - if (kind === 'read') { - stats.readCount += 1; - } else if (kind === 'search') { - stats.searchCount += 1; - } else { - stats.otherCount += 1; - } - } - }); - return stats; - } - private activityGroupTools(group: StructuredRenderGroup): ConversationUiToolStatus[] { const tools: ConversationUiToolStatus[] = []; group.items.forEach((entry: ConversationUiMessageItem) => { @@ -1141,13 +900,6 @@ export struct ChatMessageBubble { return ''; } - private hasRunningSubagentTask(item: ConversationUiMessage): boolean { - return (item.items || []).some((entry: ConversationUiMessageItem) => { - return !!entry.tool && this.normalizedToolName(entry.tool) === 'task' && - this.isRunningTool(entry.tool); - }); - } - private structuredItemKey(entry: ConversationUiMessageItem, path: string): string { if (entry.tool && entry.tool.id) { return `${path}-tool-${entry.tool.id}`; @@ -1217,30 +969,4 @@ export struct ChatMessageBubble { normalized === 'ask_user_question'; } - private fileReferences(text: string): MessageFileReference[] { - return this.fileReferenceCache.referencesFor(text); - } - - private fileStatus(path: string): string { - if ((this.downloadingFilePath === path || this.downloadedFilePath === path) && this.fileDownloadStatus.length > 0) { - return this.fileDownloadStatus; - } - if (path.indexOf('computer://') === 0) { - return RemoteI18n.t('chat.desktopFile'); - } - if (path.indexOf('file://') === 0) { - return RemoteI18n.t('chat.fileLink'); - } - return path; - } - - private fileButtonLabel(path: string): string { - if (this.downloadingFilePath === path) { - return RemoteI18n.t('chat.reading'); - } - if (this.downloadedFilePath === path) { - return RemoteI18n.t('common.done'); - } - return RemoteI18n.t('chat.download'); - } } 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 new file mode 100644 index 0000000000..c485c0add8 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets @@ -0,0 +1,109 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConversationUiMessage } from './ConversationUiModels'; +import { MessageImageGallery } from './ChatMessageContent'; +import { ACCENT, INK, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ChatTypingDots { + @Local phase: number = 0; + private timerId: number = 0; + + aboutToAppear(): void { + this.timerId = setInterval(() => { + this.phase = (this.phase + 1) % 3; + }, 360); + } + + aboutToDisappear(): void { + if (this.timerId !== 0) { + clearInterval(this.timerId); + this.timerId = 0; + } + } + + build() { + Row({ space: 5 }) { + ForEach([0, 1, 2], (index: number) => { + Text('•') + .width(6) + .height(18) + .fontSize(16) + .fontColor(MUTED) + .opacity(this.phase === index ? 1.0 : 0.34) + .animation({ duration: 180, curve: Curve.EaseInOut }) + }) + } + .height(24) + .padding({ left: 2 }) + } +} + +@ComponentV2 +export struct ChatMessageRetryAction { + @Param assistant: boolean = false; + @Param retryText: string = ''; + @Event onRetry: (text: string) => void = (_text: string) => {}; + + build() { + Row({ space: 8 }) { + Text(this.assistant ? RemoteI18n.t('generalChat.replyInterrupted') : RemoteI18n.t('chat.sendFailed')) + .fontSize(12) + .fontColor(RED) + Text(RemoteI18n.t('common.retry')) + .fontSize(12) + .fontColor(PRIMARY_ACTION_TEXT) + .height(28) + .padding({ left: 10, right: 10 }) + .backgroundColor(ACCENT) + .borderRadius(14) + .onClick(() => this.onRetry(this.retryText)) + } + } +} + +@ComponentV2 +export struct ChatUserMessageBubble { + @Param item: ConversationUiMessage = { + id: '', + role: 'user', + text: '', + status: '', + detail: '' + }; + @Param showRetryAction: boolean = false; + @Event onRetryMessage: (text: string) => void = (_text: string) => {}; + + build() { + Row() { + Blank() + Column({ space: 6 }) { + if (this.visibleText().length > 0 || (this.item.images && this.item.images.length > 0)) { + Column({ space: 8 }) { + if (this.item.images && this.item.images.length > 0) { + MessageImageGallery({ images: this.item.images, userStyle: true }) + } + if (this.visibleText().length > 0) { + Text(this.visibleText()).fontSize(14).lineHeight(20).fontColor(INK) + } + } + .padding({ left: 10, right: 10, top: 10, bottom: 10 }) + .backgroundColor(SOFT) + .borderRadius(18) + .alignItems(HorizontalAlign.Start) + } + if (this.item.status === 'failed' && this.showRetryAction) { + ChatMessageRetryAction({ retryText: this.item.text, onRetry: this.onRetryMessage }) + } + } + .constraintSize({ maxWidth: '70%' }) + .alignItems(HorizontalAlign.End) + } + .width('100%') + .padding({ top: 8, bottom: 12 }) + } + + private visibleText(): string { + const text = this.item.text.trim(); + return text === '(空消息)' || text === '(empty message)' ? '' : text; + } +} 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 new file mode 100644 index 0000000000..10df9e3317 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets @@ -0,0 +1,111 @@ +import { ConversationUiImage } from './ConversationUiModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { FileTargetResolver } from '../../services/FileTargetResolver'; +import { + MessageFileReference, + MessageFileReferenceProjectionCache +} from '../../services/MessageFileReferenceProjector'; +import { FileReferenceCard } from './FileReferenceCard'; +import { MarkdownContent } from './MarkdownContent'; +import { StreamingMarkdownContent } from './StreamingMarkdownContent'; +import { LINE } from './Theme'; + +@ComponentV2 +export struct MessageImageGallery { + @Param images: ConversationUiImage[] = []; + @Param userStyle: boolean = false; + + build() { + Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { + ForEach(this.images, (image: ConversationUiImage, index: number) => { + Image(image.data_url) + .width(this.userStyle ? 112 : 92) + .height(this.userStyle ? 112 : 92) + .objectFit(ImageFit.Cover) + .borderRadius(this.userStyle ? 12 : 14) + .border({ width: 1, color: LINE }) + .margin({ + right: this.userStyle ? (index % 2 === 0 && this.images.length > 1 ? 8 : 0) : 8, + bottom: this.userStyle ? (index < this.images.length - 2 ? 8 : 0) : 8 + }) + }, (image: ConversationUiImage, index: number) => `${image.name}-${index}`) + } + .width(this.userStyle ? (this.images.length > 1 ? 232 : 112) : '100%') + } +} + +@ComponentV2 +export struct MessageMarkdown { + @Param text: string = ''; + @Param active: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = + (_reference: string, _label: string) => {}; + + build() { + if (this.active) { + StreamingMarkdownContent({ + text: this.text, + active: this.active, + streamKey: this.streamKey, + onCopyText: this.onCopyText, + onOpenLink: this.onOpenLink + }) + } else { + MarkdownContent({ + text: this.text, + onCopyText: this.onCopyText, + onOpenLink: this.onOpenLink + }) + } + } +} + +@ComponentV2 +export struct MessageFileCards { + @Param text: string = ''; + @Param downloadingFilePath: string = ''; + @Param downloadedFilePath: string = ''; + @Param fileDownloadStatus: string = ''; + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; + @Event onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onDownload: (path: string) => void = (_path: string) => {}; + private readonly cache: MessageFileReferenceProjectionCache = new MessageFileReferenceProjectionCache(); + + build() { + Column({ space: 8 }) { + ForEach(this.cache.referencesFor(this.text), (file: MessageFileReference) => { + FileReferenceCard({ + path: file.path, + label: file.label, + status: this.fileStatus(file.path), + previewLabel: RemoteI18n.t('common.open'), + buttonLabel: this.fileButtonLabel(file.path), + disabled: this.downloadingFilePath === file.path, + selected: FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), + previewLoading: this.activeFilePreviewLoading && + FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), + onPreview: this.onPreview, + onDownload: this.onDownload + }) + }, (file: MessageFileReference) => file.id) + } + .width('100%') + } + + private fileStatus(path: string): string { + if ((this.downloadingFilePath === path || this.downloadedFilePath === path) && + this.fileDownloadStatus.length > 0) return this.fileDownloadStatus; + if (path.indexOf('computer://') === 0) return RemoteI18n.t('chat.desktopFile'); + if (path.indexOf('file://') === 0) return RemoteI18n.t('chat.fileLink'); + return path; + } + + private fileButtonLabel(path: string): string { + if (this.downloadingFilePath === path) return RemoteI18n.t('chat.reading'); + if (this.downloadedFilePath === path) return RemoteI18n.t('common.done'); + return RemoteI18n.t('chat.download'); + } +} 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 e61d366e2e..aa21d5e3f4 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 @@ -1,13 +1,13 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; -@Component +@ComponentV2 export struct ChatStatusBar { - @Prop title: string = ''; - @Prop detail: string = ''; - @Prop color: ResourceColor = MUTED; - @Prop canStop: boolean = false; - onStop: () => void = () => {}; + @Param title: string = ''; + @Param detail: string = ''; + @Param color: ResourceColor = MUTED; + @Param canStop: boolean = false; + @Event onStop: () => void = () => {}; build() { Row({ space: 10 }) { 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 8f2d8f4ba5..2c0aceb715 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 @@ -7,7 +7,7 @@ import { ConversationUiModelCatalog, ConversationUiSelectedImage } from './ConversationUiModels'; -import { ConversationModelPresentationPolicy } from '../state/ConversationModelPresentationPolicy'; +import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; export enum ComposerPresentation { 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 new file mode 100644 index 0000000000..e1e87a723d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets @@ -0,0 +1,245 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ConnectAccountDevicePage { + @Param deviceId: string = ''; + @Param controlTargetDeviceId: string = ''; + @Param connectionState: string = 'idle'; + @Event onBack: () => void = () => {}; + @Event onOpenScanner: () => void = () => {}; + @Event cloudListDevices: () => Promise = + async (): Promise => []; + @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = + async (_device: CloudAccountDevice): Promise => {}; + @Local accountDevices: CloudAccountDevice[] = []; + @Local accountDevicesBusy: boolean = false; + @Local accountDevicesError: string = ''; + @Local switchingDeviceId: string = ''; + @Local otherConnectionMethodsExpanded: boolean = false; + + aboutToAppear(): void { + this.refreshAccountDevices(); + } + + build() { + Column() { + Row({ space: 16 }) { + Stack() { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(23).fontColor([INK]).width(26).height(26) + } + .width(48).height(48).backgroundColor(SOFT).borderRadius(24) + .onClick(() => this.onBack()) + Column({ space: 4 }) { + Text(RemoteI18n.t('connect.accountDevicesTitle')) + .fontSize(22).fontWeight(FontWeight.Bold).fontColor(INK).width('100%') + Text(RemoteI18n.t('connect.accountDevicesSubtitle')) + .fontSize(13).fontColor(MUTED).width('100%') + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%').height(92) + .padding({ left: 28, right: 28, top: 18 }) + .alignItems(VerticalAlign.Top) + + Scroll() { + Column({ space: 18 }) { + Text(RemoteI18n.t('connect.accountDevicesBody')) + .fontSize(14).lineHeight(21).fontColor(MUTED).width('100%') + this.AccountDeviceList() + this.OtherConnectionMethods() + } + .width('100%') + .constraintSize({ minHeight: '100%' }) + .padding({ left: 28, right: 28, top: 10, bottom: 34 }) + } + .layoutWeight(1) + .width('100%') + .scrollBar(BarState.Off) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private AccountDeviceList() { + Column({ space: 4 }) { + Row() { + Text(RemoteI18n.t('connect.availableDevices')) + .fontSize(16).fontWeight(FontWeight.Bold).fontColor(INK) + Blank() + Text(this.accountDevicesBusy ? RemoteI18n.t('common.loading') : + (this.accountDevicesError.length > 0 ? RemoteI18n.t('common.retry') : RemoteI18n.t('common.refresh'))) + .fontSize(14) + .fontColor(this.accountDevicesBusy ? MUTED : + (this.accountDevicesError.length > 0 ? RED : ACCENT)) + .onClick(async () => { await this.refreshAccountDevices(); }) + } + .width('100%').height(38) + + if (this.accountDevicesBusy && this.accountDevices.length === 0) { + Column() { + this.AccountDeviceSkeletonRow() + this.AccountDeviceSkeletonRow() + } + .width('100%').height(120) + } else if (this.desktopDevices().length === 0) { + Row() { + Text(this.accountDevicesError || RemoteI18n.t('remote.settings.deviceEmpty')) + .fontSize(14).lineHeight(20).fontColor(MUTED).width('100%') + } + .width('100%').height(120).alignItems(VerticalAlign.Center) + } else { + Scroll() { + Column() { + ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { + this.AccountConnectDeviceRow(device) + }, (device: CloudAccountDevice): string => + `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) + } + .width('100%') + } + .width('100%').height(120).scrollBar(BarState.Off) + } + } + .width('100%').height(174) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) + .backgroundColor(CARD).borderRadius(8).border({ width: 1, color: LINE }) + } + + @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()) + } + } + .width('100%').backgroundColor(CARD).borderRadius(8).border({ width: 1, color: LINE }) + } + + @Builder + private AccountDeviceSkeletonRow() { + Row({ space: 12 }) { + Text('').width(26).height(22).backgroundColor(SOFT).borderRadius(5) + Column({ space: 7 }) { + Text('').width('58%').height(12).backgroundColor(SOFT).borderRadius(4) + Text('').width(52).height(9).backgroundColor(SOFT).borderRadius(4) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%').height(60).padding({ left: 4, right: 4 }).alignItems(VerticalAlign.Center) + } + + @Builder + private AccountConnectDeviceRow(device: CloudAccountDevice) { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(22).fontColor([MUTED]).width(26).height(24).opacity(device.online ? 0.68 : 0.38) + Column({ space: 3 }) { + Text(device.deviceName || device.deviceId) + .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.accountDeviceStatus(device)) + .fontSize(13).fontColor(device.online ? GREEN : MUTED) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + if (device.online) { + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) + } + } + .width('100%').height(60).padding({ left: 4, right: 4 }) + .alignItems(VerticalAlign.Center) + .opacity(this.canSelectAccountDevice(device) ? 1 : 0.64) + .onClick(async () => { + if (!this.canSelectAccountDevice(device)) return; + this.switchingDeviceId = device.deviceId; + this.accountDevicesError = ''; + try { + await this.cloudSelectDevice(device); + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceSwitchFailed'); + } finally { + this.switchingDeviceId = ''; + } + }) + } + + private async refreshAccountDevices(): Promise { + if (this.accountDevicesBusy) return; + this.accountDevicesBusy = true; + this.accountDevicesError = ''; + try { + this.accountDevices = await this.cloudListDevices(); + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceLoadFailed'); + } finally { + this.accountDevicesBusy = false; + if (!this.hasOnlineDesktopDevice()) { + this.otherConnectionMethodsExpanded = true; + } + } + } + + private desktopDevices(): CloudAccountDevice[] { + return this.accountDevices.filter((device: CloudAccountDevice): boolean => + device.deviceId !== this.deviceId && device.deviceName !== 'HarmonyOS Phone'); + } + + 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; + } + + private accountDeviceStatus(device: CloudAccountDevice): string { + if (device.deviceId === this.switchingDeviceId) { + return RemoteI18n.t('remote.settings.deviceConnecting'); + } + const presence = device.online ? RemoteI18n.t('remote.settings.deviceOnline') : + RemoteI18n.t('remote.settings.deviceOffline'); + if (device.deviceId === this.controlTargetDeviceId && this.connectionState === 'connected') { + return `${RemoteI18n.t('remote.settings.deviceControlling')} · ${presence}`; + } + if (device.deviceId === this.controlTargetDeviceId) { + return `${RemoteI18n.t('connect.deviceLastUsed')} · ${presence}`; + } + return presence; + } +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets new file mode 100644 index 0000000000..e1dd2ccf16 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets @@ -0,0 +1,107 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE, MODAL_SCRIM, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SOFT, SUBTLE } from './Theme'; + +@ComponentV2 +export struct ConnectManualPairingOverlay { + @Param remoteUrl: string = ''; + @Param userIdInput: string = ''; + @Param password: string = ''; + @Param requiresAccountAuth: boolean = false; + @Param canSubmit: boolean = false; + @Event onRemoteUrlChange: (value: string) => void = (_value: string) => {}; + @Event onUserIdChange: (value: string) => void = (_value: string) => {}; + @Event onPasswordChange: (value: string) => void = (_value: string) => {}; + @Event onCancel: () => void = () => {}; + @Event onSubmit: () => void = () => {}; + + build() { + Stack() { + Text('') + .width('100%') + .height('100%') + .backgroundColor(MODAL_SCRIM) + .onClick(this.onCancel) + + Column({ space: 20 }) { + Text(this.requiresAccountAuth ? + RemoteI18n.t('connect.accountPairTitle') : RemoteI18n.t('connect.manualPair')) + .fontSize(24) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .width('100%') + Text(this.requiresAccountAuth ? + RemoteI18n.t('connect.accountPairIntro') : RemoteI18n.t('connect.manualPairBody')) + .fontSize(17) + .lineHeight(24) + .fontColor(MUTED) + .width('100%') + TextInput({ placeholder: RemoteI18n.t('connect.pairCodePlaceholder'), text: this.remoteUrl }) + .height(62) + .fontSize(20) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(31) + .padding({ left: 20, right: 20 }) + .defaultFocus(true) + .onChange(this.onRemoteUrlChange) + if (this.requiresAccountAuth) { + TextInput({ + placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), + text: this.userIdInput + }) + .height(56) + .fontSize(18) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(28) + .padding({ left: 20, right: 20 }) + .onChange(this.onUserIdChange) + TextInput({ placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), text: this.password }) + .height(56) + .fontSize(18) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(28) + .padding({ left: 20, right: 20 }) + .type(InputType.Password) + .onChange(this.onPasswordChange) + Text(RemoteI18n.t('connect.accountPairBody')) + .fontSize(13) + .lineHeight(18) + .fontColor(MUTED) + .width('100%') + } + Row({ space: 12 }) { + Button(RemoteI18n.t('common.cancel')) + .layoutWeight(1) + .height(58) + .fontSize(19) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(29) + .onClick(this.onCancel) + Button(RemoteI18n.t('connect.pair')) + .layoutWeight(1) + .height(58) + .fontSize(19) + .fontWeight(FontWeight.Bold) + .fontColor(this.canSubmit ? PRIMARY_ACTION_TEXT : SUBTLE) + .backgroundColor(this.canSubmit ? PRIMARY_ACTION : SOFT) + .borderRadius(29) + .enabled(this.canSubmit) + .onClick(this.onSubmit) + } + .width('100%') + } + .width('82%') + .constraintSize({ maxWidth: 520 }) + .padding({ left: 28, right: 28, top: 30, bottom: 28 }) + .backgroundColor(CARD) + .borderRadius(34) + .border({ width: 1, color: LINE }) + } + .width('100%') + .height('100%') + } +} 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 8b917cecdd..b01a2402db 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 @@ -2,64 +2,52 @@ import { abilityAccessCtrl, Context, Permissions } from '@kit.AbilityKit'; import { customScan, scanBarcode, scanCore } from '@kit.ScanKit'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ConnectAccountDevicePage } from './ConnectAccountDevicePage'; +import { ConnectManualPairingOverlay } from './ConnectManualPairingOverlay'; import { ACCENT, CARD, CONNECT_HERO_ACCENT, CONNECT_HERO_BG, CONNECT_HERO_SECONDARY, - CONNECT_HERO_SURFACE, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT, + CONNECT_HERO_SURFACE, CONNECT_SCAN_ACCENT, GREEN, INK, LINE, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; -const CONNECT_SCAN_YELLOW: string = '#FFD021'; -const CONNECT_OVERLAY: string = '#99000000'; const CAMERA_PERMISSION: Permissions = 'ohos.permission.CAMERA'; -@Component +@ComponentV2 export struct ConnectView { private readonly scannerController: XComponentController = new XComponentController(); private scannerStarted: boolean = false; private scanCompleted: boolean = false; private scanStartRetryCount: number = 0; - @Prop remoteUrl: string = ''; - @Prop userId: string = ''; - @Prop showRemoteUrlInput: boolean = false; - @Prop statusText: string = RemoteI18n.t('status.waitingConnection'); - @Prop connectionState: string = 'idle'; - @Prop connectionFailureKind: string = ''; - @Prop isBusy: boolean = false; - @Prop isConnected: boolean = false; - @Prop desktopName: string = ''; - @Prop desktopId: string = ''; - @Prop deviceId: string = ''; - @Prop accountUserId: string = ''; - @Prop controlTargetDeviceId: string = ''; - @Prop requiresAccountAuth: boolean = false; - @Prop accountUsername: string = ''; - @Prop startWithScanner: boolean = true; - onBack: () => void = () => {}; - onConnect: (password?: string) => void = (_password?: string) => {}; - onClearPairing: () => void = () => {}; - onRemoteUrlChange: (value: string) => void = (_value: string) => {}; - onUserIdChange: (value: string) => void = (_value: string) => {}; - onRemoteUrlDetected: (value: string) => boolean = (_value: string) => false; - onRemoteUrlInputVisibleChange: (visible: boolean) => void = (_visible: boolean) => {}; - onPasteRemoteUrl: () => void = () => {}; - onScanRemoteUrl: () => void = () => {}; - cloudListDevices: () => Promise = async (): Promise => []; - cloudSelectDevice: (device: CloudAccountDevice) => Promise = + @Param remoteUrl: string = ''; + @Param userId: string = ''; + @Param statusText: string = RemoteI18n.t('status.waitingConnection'); + @Param connectionState: string = 'idle'; + @Param connectionFailureKind: string = ''; + @Param isBusy: boolean = false; + @Param isConnected: boolean = false; + @Param desktopName: string = ''; + @Param deviceId: string = ''; + @Param accountUserId: string = ''; + @Param controlTargetDeviceId: string = ''; + @Param requiresAccountAuth: boolean = false; + @Param accountUsername: string = ''; + @Param startWithScanner: boolean = true; + @Event onBack: () => void = () => {}; + @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 onRemoteUrlInputVisibleChange: (visible: boolean) => void = (_visible: boolean) => {}; + @Event cloudListDevices: () => Promise = async (): Promise => []; + @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = async (_device: CloudAccountDevice): Promise => {}; - @State showHelp: boolean = false; - @State pairingStep: string = 'intro'; - @State showManualPairing: boolean = false; - @State inlineScanError: string = ''; - @State accountPassword: string = ''; - @State cameraPermissionReady: boolean = false; - @State requestingCameraPermission: boolean = false; - @State accountDevices: CloudAccountDevice[] = []; - @State accountDevicesBusy: boolean = false; - @State accountDevicesError: string = ''; - @State switchingDeviceId: string = ''; - @State otherConnectionMethodsExpanded: boolean = false; + @Local pairingStep: string = 'intro'; + @Local showManualPairing: boolean = false; + @Local inlineScanError: string = ''; + @Local accountPassword: string = ''; + @Local cameraPermissionReady: boolean = false; + @Local requestingCameraPermission: boolean = false; aboutToAppear(): void { if (this.isAccountAuthenticated()) { this.pairingStep = 'account'; - this.refreshAccountDevices(); } else if (this.startWithScanner && this.remoteUrl.trim().length === 0) { this.pairingStep = 'scan'; } @@ -89,251 +77,17 @@ export struct ConnectView { @Builder AccountDeviceSelectionPage() { - Column() { - Row({ space: 16 }) { - Stack() { - this.BackGlyph() - } - .width(48) - .height(48) - .backgroundColor(SOFT) - .borderRadius(24) - .onClick(() => { - this.stopInlineScan(); - this.onBack(); - }) - Column({ space: 4 }) { - Text(RemoteI18n.t('connect.accountDevicesTitle')) - .fontSize(22) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .width('100%') - Text(RemoteI18n.t('connect.accountDevicesSubtitle')) - .fontSize(13) - .fontColor(MUTED) - .width('100%') - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .height(92) - .padding({ left: 28, right: 28, top: 18 }) - .alignItems(VerticalAlign.Top) - - Scroll() { - Column({ space: 18 }) { - Text(RemoteI18n.t('connect.accountDevicesBody')) - .fontSize(14) - .lineHeight(21) - .fontColor(MUTED) - .width('100%') - - this.AccountDeviceList() - this.OtherConnectionMethods() - } - .width('100%') - .constraintSize({ minHeight: '100%' }) - .padding({ left: 28, right: 28, top: 10, bottom: 34 }) - } - .layoutWeight(1) - .width('100%') - .scrollBar(BarState.Off) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - AccountDeviceList() { - Column({ space: 4 }) { - Row() { - Text(RemoteI18n.t('connect.availableDevices')) - .fontSize(16) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Blank() - Text(this.accountDevicesBusy ? RemoteI18n.t('common.loading') : - (this.accountDevicesError.length > 0 ? RemoteI18n.t('common.retry') : RemoteI18n.t('common.refresh'))) - .fontSize(14) - .fontColor(this.accountDevicesBusy ? MUTED : - (this.accountDevicesError.length > 0 ? RED : ACCENT)) - .onClick(async () => { - await this.refreshAccountDevices(); - }) - } - .width('100%') - .height(38) - - if (this.accountDevicesBusy && this.accountDevices.length === 0) { - Column() { - this.AccountDeviceSkeletonRow() - this.AccountDeviceSkeletonRow() - } - .width('100%') - .height(120) - } else if (this.desktopDevices().length === 0) { - Row() { - Text(this.accountDevicesError || RemoteI18n.t('remote.settings.deviceEmpty')) - .fontSize(14).lineHeight(20).fontColor(MUTED).width('100%') - } - .width('100%') - .height(120) - .alignItems(VerticalAlign.Center) - } else { - Scroll() { - Column() { - ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { - this.AccountConnectDeviceRow(device) - }, (device: CloudAccountDevice): string => - `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) - } - .width('100%') - } - .width('100%') - .height(120) - .scrollBar(BarState.Off) - } - - } - .width('100%') - .height(174) - .padding({ left: 16, right: 16, top: 8, bottom: 8 }) - .backgroundColor(CARD) - .borderRadius(8) - .border({ width: 1, color: LINE }) - } - - @Builder - OtherConnectionMethods() { - Column() { - Row({ space: 12 }) { - Text(RemoteI18n.t('connect.otherConnectionMethods')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - if (this.otherConnectionMethodsExpanded) { - SymbolGlyph($r('sys.symbol.chevron_up')) - .fontSize(13) - .fontColor([MUTED]) - } else { - SymbolGlyph($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.openScannerAfterPermission(); - }) - } - } - .width('100%') - .backgroundColor(CARD) - .borderRadius(8) - .border({ width: 1, color: LINE }) - } - - @Builder - AccountDeviceSkeletonRow() { - Row({ space: 12 }) { - Text('') - .width(26) - .height(22) - .backgroundColor(SOFT) - .borderRadius(5) - Column({ space: 7 }) { - Text('') - .width('58%') - .height(12) - .backgroundColor(SOFT) - .borderRadius(4) - Text('') - .width(52) - .height(9) - .backgroundColor(SOFT) - .borderRadius(4) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .height(60) - .padding({ left: 4, right: 4 }) - .alignItems(VerticalAlign.Center) - } - - @Builder - AccountConnectDeviceRow(device: CloudAccountDevice) { - Row({ space: 12 }) { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(22).fontColor([MUTED]).width(26).height(24).opacity(device.online ? 0.68 : 0.38) - Column({ space: 3 }) { - Text(device.deviceName || device.deviceId) - .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) - .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.accountDeviceStatus(device)) - .fontSize(13).fontColor(device.online ? GREEN : MUTED) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - if (device.online) { - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) - } - } - .width('100%') - .height(60) - .padding({ left: 4, right: 4 }) - .alignItems(VerticalAlign.Center) - .opacity(this.canSelectAccountDevice(device) ? 1 : 0.64) - .onClick(async () => { - if (!this.canSelectAccountDevice(device)) return; - this.switchingDeviceId = device.deviceId; - this.accountDevicesError = ''; - try { - await this.cloudSelectDevice(device); - } catch (err) { - this.accountDevicesError = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceSwitchFailed'); - } finally { - this.switchingDeviceId = ''; - } + ConnectAccountDevicePage({ + deviceId: this.deviceId, + controlTargetDeviceId: this.controlTargetDeviceId, + connectionState: this.connectionState, + cloudListDevices: this.cloudListDevices, + cloudSelectDevice: this.cloudSelectDevice, + onBack: () => { + this.stopInlineScan(); + this.onBack(); + }, + onOpenScanner: () => this.openScannerAfterPermission() }) } @@ -571,6 +325,27 @@ export struct ConnectView { .height(282) } + @Builder + ScanCorner(x: number, y: number, isLeft: boolean, isTop: boolean) { + Stack() { + Text('') + .width(42) + .height(4) + .borderRadius(2) + .backgroundColor(CONNECT_SCAN_ACCENT) + .position({ x: isLeft ? 0 : 22, y: isTop ? 0 : 60 }) + Text('') + .width(4) + .height(42) + .borderRadius(2) + .backgroundColor(CONNECT_SCAN_ACCENT) + .position({ x: isLeft ? 0 : 60, y: isTop ? 0 : 22 }) + } + .width(64) + .height(64) + .position({ x, y }) + } + @Builder PrimaryPairButton(text: string) { Button(text) @@ -622,373 +397,25 @@ export struct ConnectView { @Builder ManualPairingOverlay() { - Stack() { - Text('') - .width('100%') - .height('100%') - .backgroundColor(CONNECT_OVERLAY) - .onClick(() => { - this.stopInlineScan(); - this.showManualPairing = false; - this.resumeInlineScan(); - }) - - Column({ space: 20 }) { - Text(this.requiresAccountAuth ? RemoteI18n.t('connect.accountPairTitle') : RemoteI18n.t('connect.manualPair')) - .fontSize(24) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .width('100%') - Text(this.requiresAccountAuth ? RemoteI18n.t('connect.accountPairIntro') : RemoteI18n.t('connect.manualPairBody')) - .fontSize(17) - .lineHeight(24) - .fontColor(MUTED) - .width('100%') - TextInput({ placeholder: RemoteI18n.t('connect.pairCodePlaceholder'), text: this.remoteUrl }) - .height(62) - .fontSize(20) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(31) - .padding({ left: 20, right: 20 }) - .defaultFocus(true) - .onChange((value: string) => { - this.onRemoteUrlChange(value); - }) - if (this.requiresAccountAuth) { - TextInput({ placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), text: this.displayUserIdInput() }) - .height(56) - .fontSize(18) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(28) - .padding({ left: 20, right: 20 }) - .onChange((value: string) => { - this.onUserIdChange(value); - }) - TextInput({ placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), text: this.accountPassword }) - .height(56) - .fontSize(18) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(28) - .padding({ left: 20, right: 20 }) - .type(InputType.Password) - .onChange((value: string) => { - this.accountPassword = value; - }) - Text(RemoteI18n.t('connect.accountPairBody')) - .fontSize(13) - .lineHeight(18) - .fontColor(MUTED) - .width('100%') - } - Row({ space: 12 }) { - Button(RemoteI18n.t('common.cancel')) - .layoutWeight(1) - .height(58) - .fontSize(19) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(29) - .onClick(() => { - this.stopInlineScan(); - this.showManualPairing = false; - this.resumeInlineScan(); - }) - Button(RemoteI18n.t('connect.pair')) - .layoutWeight(1) - .height(58) - .fontSize(19) - .fontWeight(FontWeight.Bold) - .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) - .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) - .borderRadius(29) - .enabled(this.canConnect()) - .onClick(() => { - this.ensureUserId(); - this.stopInlineScan(); - this.showManualPairing = false; - this.onConnect(this.accountPassword); - }) - } - .width('100%') - } - .width('82%') - .padding({ left: 28, right: 28, top: 30, bottom: 28 }) - .backgroundColor(CARD) - .borderRadius(34) - .border({ width: 1, color: LINE }) - } - .width('100%') - .height('100%') - } - - @Builder - HelpCard() { - Column({ space: 6 }) { - Text(RemoteI18n.t('connect.stepsTitle')) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - Text(RemoteI18n.t('connect.stepsBody')) - .fontSize(12) - .lineHeight(18) - .fontColor(MUTED) - .width('100%') - } - .padding(14) - .backgroundColor(SOFT) - .borderRadius(14) - .border({ width: 1, color: LINE }) - .width('100%') - } - - @Builder - ScanCard() { - Column({ space: 12 }) { - Row() { - Blank() - Stack() { - Text('') - .width(72) - .height(72) - .borderRadius(22) - .backgroundColor(SOFT) - this.ScanCorner(12, 12, true, true) - this.ScanCorner(32, 12, false, true) - this.ScanCorner(12, 32, true, false) - this.ScanCorner(32, 32, false, false) - } - .width(72) - .height(72) - Blank() - } - .width('100%') - .height(92) - - Text(RemoteI18n.t('connect.scanTitle')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('connect.scanBody')) - .fontSize(13) - .lineHeight(20) - .fontColor(MUTED) - .width('100%') - .textAlign(TextAlign.Center) - } - .padding({ left: 18, right: 18, top: 26, bottom: 24 }) - .backgroundColor(CARD) - .borderRadius(16) - .width('100%') - .border({ width: 1, color: LINE }) - .onClick(() => { - this.onScanRemoteUrl(); - }) - } - - @Builder - ScanCorner(x: number, y: number, isLeft: boolean, isTop: boolean) { - Stack() { - Text('') - .width(42) - .height(4) - .borderRadius(2) - .backgroundColor(CONNECT_SCAN_YELLOW) - .position({ x: isLeft ? 0 : 22, y: isTop ? 0 : 60 }) - Text('') - .width(4) - .height(42) - .borderRadius(2) - .backgroundColor(CONNECT_SCAN_YELLOW) - .position({ x: isLeft ? 0 : 60, y: isTop ? 0 : 22 }) - } - .width(64) - .height(64) - .position({ x, y }) - } - - @Builder - RemoteUrlCard() { - Column({ space: 14 }) { - Row() { - Text(RemoteI18n.t('connect.userId')) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - Text(this.remoteUrl.trim().length > 0 ? RemoteI18n.t('connect.filled') : RemoteI18n.t('connect.remoteUrlShort')) - .fontSize(13) - .fontColor(MUTED) - .onClick(() => { - if (this.remoteUrl.trim().length > 0 || this.showRemoteUrlInput) { - this.onRemoteUrlInputVisibleChange(!this.showRemoteUrlInput); - } else { - this.onRemoteUrlInputVisibleChange(true); - this.onPasteRemoteUrl(); - } - }) - } - .width('100%') - - TextInput({ placeholder: RemoteI18n.t('connect.userPlaceholder'), text: this.displayUserIdInput() }) - .height(56) - .fontSize(15) - .backgroundColor(SOFT) - .borderRadius(14) - .padding({ left: 16, right: 16 }) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .onChange((value: string) => { - this.onUserIdChange(value); - }) - - if (this.showRemoteUrlInput) { - TextInput({ placeholder: RemoteI18n.t('connect.urlPlaceholder'), text: this.remoteUrl }) - .height(50) - .fontSize(13) - .backgroundColor(SOFT) - .borderRadius(14) - .padding(12) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .onChange((value: string) => { - this.onRemoteUrlChange(value); - }) - } - - Button(this.isBusy ? RemoteI18n.t('connect.connecting') : RemoteI18n.t('connect.connect')) - .width('100%') - .height(50) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) - .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) - .borderRadius(14) - .enabled(this.canConnect()) - .onClick(() => { - this.ensureUserId(); - this.onConnect(); - }) - } - .padding({ left: 18, right: 18, top: 18, bottom: 18 }) - .backgroundColor(CARD) - .borderRadius(16) - .width('100%') - .border({ width: 1, color: LINE }) - } - - @Builder - StatusCard() { - Column({ space: 8 }) { - Text(RemoteI18n.t('connect.statusTitle')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - .margin({ bottom: 6 }) - this.DesktopStatus() - if (this.isConnectError() && this.failureHint().length > 0) { - Divider().color(LINE) - this.FailureHint() - } - } - .width('100%') - .padding(16) - .backgroundColor(CARD) - .borderRadius(16) - .border({ width: 1, color: LINE }) - } - - @Builder - FailureHint() { - Text(this.failureHint()) - .fontSize(12) - .lineHeight(18) - .fontColor(INK) - .width('100%') - .padding(12) - .backgroundColor(SOFT) - .borderRadius(14) - .border({ width: 1, color: LINE }) - } - - @Builder - DesktopStatus() { - List() { - ListItem() { - this.DesktopStatusContent() - } - .height(74) - .swipeAction(this.statusSwipeAction()) - } - .width('100%') - .height(74) - .scrollBar(BarState.Off) - .divider(null) - } - - @Builder - DesktopStatusContent() { - Row() { - Text('●') - .fontSize(12) - .fontColor(this.statusDotColor()) - Column({ space: 6 }) { - Text(this.statusTitle()) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Text(this.statusDetail()) - .fontSize(12) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - if (this.remoteUrl.trim().length > 0) { - Text(this.desktopIdText()) - .fontSize(11) - .fontColor(SUBTLE) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - .margin({ left: 12 }) - if (this.isBusy) { - Blank() - Text('◌') - .fontSize(22) - .fontColor(INK) + ConnectManualPairingOverlay({ + remoteUrl: this.remoteUrl, + userIdInput: this.displayUserIdInput(), + password: this.accountPassword, + requiresAccountAuth: this.requiresAccountAuth, + canSubmit: this.canConnect(), + onRemoteUrlChange: this.onRemoteUrlChange, + onUserIdChange: this.onUserIdChange, + onPasswordChange: (value: string) => { this.accountPassword = value; }, + onCancel: () => this.closeManualPairing(), + onSubmit: () => { + this.ensureUserId(); + this.stopInlineScan(); + this.showManualPairing = false; + this.onConnect(this.accountPassword); } - } - .width('100%') - .height(74) - .backgroundColor(CARD) - .onClick(() => { - this.handleStatusClick(); }) } - @Builder - DeleteReveal() { - Text(RemoteI18n.t('connect.clear')) - .fontSize(13) - .fontColor(CARD) - .textAlign(TextAlign.Center) - .width(84) - .height(74) - .backgroundColor(RED) - .onClick(() => { - this.onClearPairing(); - }) - } - private statusDotColor(): ResourceColor { if (this.isConnected) { return GREEN; @@ -1002,13 +429,6 @@ export struct ConnectView { return SUBTLE; } - private statusTitle(): string { - if (this.remoteUrl.trim().length === 0) { - return RemoteI18n.t('connect.noDesktop'); - } - return this.desktopName || RemoteI18n.t('connect.targetDesktop'); - } - private statusDetail(): string { if (this.remoteUrl.trim().length === 0) { return RemoteI18n.t('connect.noDesktopDetail'); @@ -1028,13 +448,6 @@ export struct ConnectView { return RemoteI18n.t('connect.waitingDesktop'); } - private desktopIdText(): string { - if (this.desktopId.trim().length === 0) { - return RemoteI18n.t('connect.desktopIdUnavailable'); - } - return RemoteI18n.f('connect.desktopId', this.desktopId); - } - private displayUserIdInput(): string { if (this.requiresAccountAuth && this.accountUsername.length > 0 && this.userId.trim().length === 0) { return this.accountUsername; @@ -1045,24 +458,6 @@ export struct ConnectView { return this.userId; } - private statusSwipeAction(): SwipeActionOptions { - if (this.remoteUrl.trim().length === 0 || this.isBusy) { - return {}; - } - return { - end: { - builder: () => { - this.DeleteReveal(); - }, - actionAreaDistance: 84, - onAction: () => { - this.onClearPairing(); - } - }, - edgeEffect: SwipeEdgeEffect.None - }; - } - private handleStatusClick(): void { if (this.isBusy) { return; @@ -1093,6 +488,12 @@ export struct ConnectView { return this.displayUserIdInput().trim().length > 0 && this.accountPassword.length > 0; } + private closeManualPairing(): void { + this.stopInlineScan(); + this.showManualPairing = false; + this.resumeInlineScan(); + } + private currentStep(): string { if (this.pairingStep === 'account' && this.isAccountAuthenticated()) { return 'account'; @@ -1109,57 +510,6 @@ export struct ConnectView { return 'intro'; } - private async refreshAccountDevices(): Promise { - if (!this.isAccountAuthenticated() || this.accountDevicesBusy) return; - this.accountDevicesBusy = true; - this.accountDevicesError = ''; - try { - this.accountDevices = await this.cloudListDevices(); - } catch (err) { - this.accountDevicesError = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceLoadFailed'); - } finally { - this.accountDevicesBusy = false; - if (!this.hasOnlineDesktopDevice()) { - this.otherConnectionMethodsExpanded = true; - } - } - } - - private desktopDevices(): CloudAccountDevice[] { - return this.accountDevices.filter((device: CloudAccountDevice): boolean => - device.deviceId !== this.deviceId && device.deviceName !== 'HarmonyOS Phone'); - } - - 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; - } - - private accountDeviceStatus(device: CloudAccountDevice): string { - if (device.deviceId === this.switchingDeviceId) { - return RemoteI18n.t('remote.settings.deviceConnecting'); - } - const presence = device.online ? RemoteI18n.t('remote.settings.deviceOnline') : - RemoteI18n.t('remote.settings.deviceOffline'); - if (device.deviceId === this.controlTargetDeviceId && this.connectionState === 'connected') { - return `${RemoteI18n.t('remote.settings.deviceControlling')} · ${presence}`; - } - if (device.deviceId === this.controlTargetDeviceId) { - return `${RemoteI18n.t('connect.deviceLastUsed')} · ${presence}`; - } - return presence; - } - private isAccountAuthenticated(): boolean { return this.accountUserId.trim().length > 0; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets new file mode 100644 index 0000000000..b8c7593144 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets @@ -0,0 +1,58 @@ +import { LINE, SOFT } from './Theme'; + +@ComponentV2 +export struct ConversationLoadingState { + @Param maxContentWidth: number = 0; + + build() { + Row() { + Column({ space: 18 }) { + this.AssistantSkeleton(78, '72%') + this.UserSkeleton(42, '46%') + this.AssistantSkeleton(112, '84%') + } + .width('100%') + .constraintSize({ maxWidth: this.maxContentWidth > 0 ? this.maxContentWidth : '100%' }) + .padding({ left: 22, right: 22, top: 28, bottom: 28 }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Top) + } + + @Builder + private AssistantSkeleton(height: number, width: string) { + Row() { + Column({ space: 9 }) { + Text('').width('74%').height(10).backgroundColor(LINE).borderRadius(5) + Text('').width('92%').height(10).backgroundColor(LINE).borderRadius(5) + Text('').width('58%').height(10).backgroundColor(LINE).borderRadius(5) + } + .width(width) + .height(height) + .padding({ left: 14, right: 14, top: 14, bottom: 14 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Start) + .backgroundColor(SOFT) + .borderRadius(10) + Blank().layoutWeight(1) + } + .width('100%') + .height(height) + } + + @Builder + private UserSkeleton(height: number, width: string) { + Row() { + Blank().layoutWeight(1) + Text('') + .width(width) + .height(height) + .backgroundColor(SOFT) + .borderRadius(10) + } + .width('100%') + .height(height) + } +} 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 new file mode 100644 index 0000000000..6a82568798 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets @@ -0,0 +1,94 @@ +import { ConversationIntent } from '../actions/ConversationIntent'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { ComposerPresentation } from './ComposerBar'; +import { ConversationViewHost } from './ConversationViewHost'; +import { toConversationUiModelCatalog } from './ConversationUiModels'; +import { RemoteCreateSessionView } from './RemoteCreateSessionView'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { ConversationViewState } from '../state/ConversationViewState'; +import { PAGE_BG } from './Theme'; + +@ComponentV2 +export struct ConversationRouteSurface { + @Param route: AppRoute = AppRoute.ChatHome; + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param filePreviewState: FilePreviewState = new FilePreviewState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param showSidebarButton: boolean = true; + @Param showBackButton: boolean = false; + @Param showSidebarRestoreButton: boolean = false; + @Param useWidePresentation: boolean = false; + @Param contentHorizontalOffset: number = 0; + @Event onRestoreSidebar: () => void = () => {}; + + build() { + Column() { + if (this.route === AppRoute.RemoteHome) { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.CompactHome, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + onOpenSidebar: this.actions.onRemoteHome.openSidebar + }) + } else if (this.route === AppRoute.RemoteCreate) { + RemoteCreateSessionView({ + state: this.remoteCreateState, + presentation: this.useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Create, + isVoiceListening: this.remoteCreateState.isVoiceListening, + modelCatalog: toConversationUiModelCatalog(this.remotePageState.conversation.modelCatalog), + selectedModelId: this.remoteCreateState.selectedModelId, + showSidebarRestoreButton: this.showSidebarRestoreButton, + onRestoreSidebar: this.onRestoreSidebar, + onBack: this.actions.onRemoteCreate.back, + onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, + onToggleWorkspaceMenu: this.actions.onRemoteCreate.toggleWorkspaces, + onSelectDevice: this.actions.onRemoteCreate.selectDevice, + onSelectWorkspace: (workspace) => this.actions.onRemoteCreate.selectWorkspace(workspace?.path || ''), + onDraftChange: this.actions.onRemoteCreate.draftChanged, + onVoiceInput: this.actions.onRemoteCreate.voiceInput, + onSelectModel: this.actions.onRemoteCreate.selectModel, + onSend: this.actions.onRemoteCreate.send + }) + } else { + ConversationViewHost({ + viewState: ConversationViewState.project( + this.route, + this.remotePageState, + this.generalPageState, + this.actions.generalStatus() + ), + activeFilePreviewPath: this.route === AppRoute.RemoteChat && this.filePreviewState.visible ? + this.filePreviewState.target.remotePath : '', + activeFilePreviewLoading: this.route === AppRoute.RemoteChat && this.filePreviewState.visible && + this.filePreviewState.phase === FilePreviewPhase.Loading, + showSidebarButton: this.showSidebarButton, + showBackButton: this.showBackButton, + showSidebarRestoreButton: this.showSidebarRestoreButton, + composerPresentation: this.useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Compact, + contentHorizontalOffset: this.contentHorizontalOffset, + onRestoreSidebar: this.onRestoreSidebar, + onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(this.route, intent) + }) + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} 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 f940c851e5..1c66b5a892 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 @@ -2,10 +2,10 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConversationSource } from '../navigation/AppRouteContract'; import { CARD, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct ConversationSourceSwitcher { - @Prop activeSource: ConversationSource = ConversationSource.General; - onSelectSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; + @Param activeSource: ConversationSource = ConversationSource.General; + @Event onSelectSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; build() { Row({ space: 2 }) { 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 6dbb9e2694..db3f0fc7f1 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 @@ -6,6 +6,7 @@ import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from './C import { ChatSurface } from './ChatSurface'; import { ChatStatusBar } from './ChatStatusBar'; import { ChatTimeline } from './ChatTimeline'; +import { ConversationLoadingState } from './ConversationLoadingState'; import { ConversationViewContract } from './ConversationViewContract'; import { ConversationUiModelCatalog, @@ -34,6 +35,7 @@ export struct ConversationView { @Param connectionState: string = 'connected'; @Param composerCapabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; @Param isBusy: boolean = false; + @Param isLoadingConversation: boolean = false; @Param canStop: boolean = false; @Param hasMoreMessages: boolean = false; @Param timelineItems: ChatTimelineItem[] = []; @@ -110,7 +112,12 @@ export struct ConversationView { if (this.shouldShowStatusBar()) { this.ExecutionStatusBar() } - if (this.shouldShowSuggestions()) { + if (this.isLoadingConversation) { + ConversationLoadingState({ + maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 800 : 0 + }) + .layoutWeight(1) + } else if (this.shouldShowSuggestions()) { Blank().layoutWeight(1) if (!this.isVoiceListening) { this.PromptArea() @@ -179,6 +186,7 @@ export struct ConversationView { workspaceBranch: this.workspaceBranch, desktopName: this.desktopName, showBackButton: this.showBackButton, + showSidebarButton: this.showSidebarButton, showSidebarRestoreButton: this.showSidebarRestoreButton, showActionsMenu: this.showHeaderActions, actionsMenu: () => { @@ -187,6 +195,9 @@ export struct ConversationView { onBack: () => { this.onBack(); }, + onOpenSidebar: () => { + this.onOpenSidebar(); + }, onRestoreSidebar: () => { this.onRestoreSidebar(); }, @@ -224,7 +235,7 @@ export struct ConversationView { timelineItems: this.visibleTimelineItems(), timelineRevision: this.timelineRevision, hasMoreMessages: this.hasMoreMessages, - isBusy: this.isBusy, + isBusy: this.isBusy || this.isLoadingConversation, connectionState: this.connectionState, statusText: this.statusText, downloadingFilePath: this.downloadingFilePath, @@ -675,7 +686,7 @@ export struct ConversationView { } private shouldShowStatusBar(): boolean { - return this.surface === ChatSurface.Remote && this.connectionState !== 'connected'; + return !this.isLoadingConversation && this.surface === ChatSurface.Remote && this.connectionState !== 'connected'; } private connectionColor(): ResourceColor { 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 1c6bae8410..045054b74a 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 @@ -4,7 +4,7 @@ import { ConversationIntent, ConversationIntents, ConversationIntentType -} from './ConversationIntent'; +} from '../actions/ConversationIntent'; import { ConversationUiQuestionAnswer } from './ConversationUiModels'; import { ComposerPresentation } from './ComposerBar'; @@ -32,6 +32,7 @@ export struct ConversationViewHost { connectionState: this.viewState.connectionState, composerCapabilities: this.viewState.composerCapabilities, isBusy: this.viewState.isBusy, + isLoadingConversation: this.viewState.isLoadingConversation, canStop: this.viewState.canStop, hasMoreMessages: this.viewState.hasMoreMessages, timelineItems: this.viewState.timelineItems, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets index d965d5b08e..f3e7176df9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets @@ -1,7 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; import { RemoteLogger } from '../../services/RemoteLogger'; -import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; +import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; @ComponentV2 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 index 7ad51ec5d1..a00d0ca784 100644 --- 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 @@ -1,17 +1,19 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SOFT, SUBTLE } from './Theme'; -@Component +@ComponentV2 export struct CreateSessionSheet { - @Prop createAgentType: string = 'code'; - @Prop workspaceName: string = ''; - @Prop workspaceBranch: string = ''; - @Prop isBusy: boolean = false; - @Link sessionTitle: string; - @Link instruction: string; - onClose: () => void = () => {}; - onChooseWorkspace: () => void = () => {}; - onStart: () => void = () => {}; + @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() { @@ -123,7 +125,7 @@ export struct CreateSessionSheet { .border({ width: 1, color: LINE }) .defaultFocus(false) .onChange((value: string) => { - this.sessionTitle = value; + this.onSessionTitleChange(value); }) } .width('100%') @@ -146,7 +148,7 @@ export struct CreateSessionSheet { .border({ width: 1, color: LINE }) .defaultFocus(false) .onChange((value: string) => { - this.instruction = value; + this.onInstructionChange(value); }) } .width('100%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets index 72fb3c48cc..a4f939b8ca 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets @@ -1,8 +1,8 @@ import { MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct DefaultAccountAvatar { - @Prop avatarSize: number = 34; + @Param avatarSize: number = 34; build() { Stack({ alignContent: Alignment.Center }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets index 06f477b898..139edbac82 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets @@ -1,17 +1,17 @@ import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct FileReferenceCard { - @Prop path: string = ''; - @Prop label: string = ''; - @Prop status: string = ''; - @Prop previewLabel: string = ''; - @Prop buttonLabel: string = ''; - @Prop disabled: boolean = false; - @Prop selected: boolean = false; - @Prop previewLoading: boolean = false; - onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; - onDownload: (path: string) => void = (_path: string) => {}; + @Param path: string = ''; + @Param label: string = ''; + @Param status: string = ''; + @Param previewLabel: string = ''; + @Param buttonLabel: string = ''; + @Param disabled: boolean = false; + @Param selected: boolean = false; + @Param previewLoading: boolean = false; + @Event onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onDownload: (path: string) => void = (_path: string) => {}; build() { Row({ space: 10 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets index a088f54d88..0382f2db61 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets @@ -1,11 +1,13 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, LINE, PAGE_BG } from './Theme'; +import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { CompactMenuButton } from './CompactMenuButton'; 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; @@ -21,23 +23,42 @@ export struct GeneralChatHeader { 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(17) + .fontSize(this.hasSubtitle() ? 18 : 17) .fontWeight(FontWeight.Medium) .fontColor(INK) - .layoutWeight(1) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .textAlign(TextAlign.Center) - - this.TrailingControl() + if (this.hasSubtitle()) { + Text(this.subtitle) + .fontSize(14) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.Center) + } } - .width('100%') - .height(64) - .alignItems(VerticalAlign.Center) - .padding({ left: 16, right: 16, top: 8, bottom: 8 }) - .backgroundColor(PAGE_BG) + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + + private hasSubtitle(): boolean { + return this.subtitle.length > 0; } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets index 486aad6a67..5b7e8d76a8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets @@ -7,12 +7,12 @@ import { } from '../../services/MarkdownParser'; import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct MarkdownContent { private readonly parseCache: MarkdownParseCache = new MarkdownParseCache(); - @Prop text: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; + @Param text: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; build() { Column({ space: 5 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets index 3c92d19156..07996fbb89 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets @@ -4,24 +4,24 @@ import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels' import { GENERAL_CHAT_LOCAL_MODEL_ID } from '../../services/general-chat/GeneralChatConfigStore'; import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; -@Component +@ComponentV2 export struct ModelServiceSettingsPanel { private readonly contentScroller: Scroller = new Scroller(); private focusScrollTimerId: number = 0; private blurResetTimerId: number = 0; private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; - @Prop apiUrl: string = ''; - @Prop modelName: string = ''; - @Prop hasApiKey: boolean = false; - @Prop modelCatalog: RemoteModelCatalog = { + @Param apiUrl: string = ''; + @Param modelName: string = ''; + @Param hasApiKey: boolean = false; + @Param modelCatalog: RemoteModelCatalog = { version: 0, models: [], default_models: {} }; - @Prop selectedModelId: string = ''; - onClose: () => void = () => {}; - onSaved: (apiUrl: string, modelName: string, hasApiKey: boolean) => void = () => {}; - onTest: ( + @Param selectedModelId: string = ''; + @Event onClose: () => void = () => {}; + @Event onSaved: (apiUrl: string, modelName: string, hasApiKey: boolean) => void = () => {}; + @Event onTest: ( apiUrl: string, apiKey: string, modelName: string, @@ -32,7 +32,7 @@ export struct ModelServiceSettingsPanel { _modelName: string, _clearApiKey: boolean ) => ''; - onSave: ( + @Event onSave: ( apiUrl: string, apiKey: string, modelName: string, @@ -43,16 +43,16 @@ export struct ModelServiceSettingsPanel { _modelName: string, _clearApiKey: boolean ) => ''; - @State draftApiUrl: string = ''; - @State draftApiKey: string = ''; - @State draftModelName: string = ''; - @State clearApiKey: boolean = false; - @State isSaving: boolean = false; - @State isTesting: boolean = false; - @State feedbackText: string = ''; - @State feedbackIsError: boolean = false; - @State focusedFieldKind: string = ''; - @State showLocalEditor: boolean = false; + @Local draftApiUrl: string = ''; + @Local draftApiKey: string = ''; + @Local draftModelName: string = ''; + @Local clearApiKey: boolean = false; + @Local isSaving: boolean = false; + @Local isTesting: boolean = false; + @Local feedbackText: string = ''; + @Local feedbackIsError: boolean = false; + @Local focusedFieldKind: string = ''; + @Local showLocalEditor: boolean = false; aboutToAppear(): void { this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets index 67b5b38130..539ea93aaa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets @@ -1,6 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConversationUiSession } from './ConversationUiModels'; import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION_TEXT, SOFT } from './Theme'; +import { CompactMenuButton } from './CompactMenuButton'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 @@ -14,10 +15,12 @@ export struct RemoteChatHeader { @Param workspaceBranch: string = ''; @Param desktopName: string = ''; @Param showBackButton: boolean = true; + @Param showSidebarButton: 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 onOpenActions: () => void = () => {}; @Event onActionsMenuStateChange: (visible: boolean) => void = (_visible: boolean) => {}; @@ -100,6 +103,13 @@ export struct RemoteChatHeader { .onClick(() => { this.onBack(); }) + } else if (this.showSidebarButton) { + CompactMenuButton({ + controlSize: 44, + onOpen: () => { + this.onOpenSidebar(); + } + }) } else { Blank().width(44).height(44) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets index 9ed4245319..3396da0e91 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 @@ -5,45 +5,45 @@ import { RemotePermissionMode } from '../../model/RemoteModels'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; import { BitFunAccountLoginPage } from './BitFunAccountLoginPage'; -@Component +@ComponentV2 export struct RemoteControlSettingsSheet { - @Prop desktopName: string = ''; - @Prop desktopId: string = ''; - @Prop userId: string = ''; - @Prop accountUsername: string = ''; - @Prop @Watch('handleAccountUserChanged') accountUserId: string = ''; - @Prop deviceId: string = ''; - @Prop controlTargetType: string = 'none'; - @Prop controlTargetDeviceId: string = ''; - @Prop connectionState: string = 'idle'; - @Prop statusText: string = ''; - @Prop isBusy: boolean = false; - @Prop openAccountOnAppear: boolean = false; - onClose: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onAddConnection: () => void = () => {}; - cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; - cloudSync: () => Promise = async (): Promise => '0'; - cloudLogout: () => Promise = async (): Promise => {}; - cloudListDevices: () => Promise = async (): Promise => []; - getPermissionMode: () => Promise = async (): Promise => 'ask'; - setPermissionMode: (mode: RemotePermissionMode) => Promise = + @Param desktopName: string = ''; + @Param desktopId: string = ''; + @Param userId: string = ''; + @Param accountUsername: string = ''; + @Param accountUserId: string = ''; + @Param deviceId: string = ''; + @Param controlTargetType: string = 'none'; + @Param controlTargetDeviceId: string = ''; + @Param connectionState: string = 'idle'; + @Param statusText: string = ''; + @Param isBusy: boolean = false; + @Param openAccountOnAppear: boolean = false; + @Event onClose: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onAddConnection: () => void = () => {}; + @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; + @Event cloudSync: () => Promise = async (): Promise => '0'; + @Event cloudLogout: () => Promise = async (): Promise => {}; + @Event cloudListDevices: () => Promise = async (): Promise => []; + @Event getPermissionMode: () => Promise = async (): Promise => 'ask'; + @Event setPermissionMode: (mode: RemotePermissionMode) => Promise = async (mode: RemotePermissionMode): Promise => mode; - onDisconnect: () => void = () => {}; - onReconnect: () => void = () => {}; - @State showProfile: boolean = false; - @State showLogin: boolean = false; - @State cloudSyncBusy: boolean = false; - @State cloudSyncStatus: string = ''; - @State accountDevices: CloudAccountDevice[] = []; - @State accountDevicesBusy: boolean = false; - @State accountDevicesError: string = ''; - @State permissionMode: RemotePermissionMode = 'ask'; - @State permissionModeBusy: boolean = false; - @State permissionModeLoaded: boolean = false; - @State permissionModeError: string = ''; - @State confirmFullAccess: boolean = false; - @State logoutBusy: boolean = false; + @Event onDisconnect: () => void = () => {}; + @Event onReconnect: () => void = () => {}; + @Local showProfile: boolean = false; + @Local showLogin: boolean = false; + @Local cloudSyncBusy: boolean = false; + @Local cloudSyncStatus: string = ''; + @Local accountDevices: CloudAccountDevice[] = []; + @Local accountDevicesBusy: boolean = false; + @Local accountDevicesError: string = ''; + @Local permissionMode: RemotePermissionMode = 'ask'; + @Local permissionModeBusy: boolean = false; + @Local permissionModeLoaded: boolean = false; + @Local permissionModeError: string = ''; + @Local confirmFullAccess: boolean = false; + @Local logoutBusy: boolean = false; aboutToAppear(): void { this.showProfile = this.openAccountOnAppear && this.isAccountAuthenticated(); @@ -855,6 +855,7 @@ export struct RemoteControlSettingsSheet { return this.accountUserId.trim().length > 0; } + @Monitor('accountUserId') private handleAccountUserChanged(): void { if (this.isAccountAuthenticated() && this.accountDevices.length === 0) { this.refreshAccountDevices(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index d1d6b2d8c5..bfca15020c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -3,9 +3,9 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { TimeFormat } from '../../services/TimeFormat'; import { CARD, INK, MUTED, SOFT } from './Theme'; import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; -import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../state/SessionActionPolicy'; +import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy'; import { SessionDetailsView } from './SessionDetailsView'; -import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; +import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; @ComponentV2 export struct RemoteSessionList { @@ -46,12 +46,18 @@ export struct RemoteSessionList { @Local showSessionActionSheet: boolean = false; @Local detailsSessionId: string = ''; @Local showSessionDetails: boolean = false; + @Local optimisticSelectedSessionId: string = ''; @Monitor('isBusy', 'workspacePath') onWorkspaceContextChanged(): void { this.createMenuPath = ''; } + @Monitor('selectedSessionId') + onSelectedSessionChanged(): void { + this.optimisticSelectedSessionId = ''; + } + build() { Column() { Scroll() { @@ -566,7 +572,7 @@ export struct RemoteSessionList { Text(item.title || RemoteI18n.t('sidebar.untitled')) .width('100%') .fontSize(15) - .fontWeight(this.selectedSessionId === item.id ? FontWeight.Medium : FontWeight.Regular) + .fontWeight(this.isSessionSelected(item.id) ? FontWeight.Medium : FontWeight.Regular) .fontColor(INK) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) @@ -587,9 +593,23 @@ export struct RemoteSessionList { .height(this.metadataText(item).length > 0 ? 56 : 46) .padding({ left: nested ? 0 : 10, right: 4 }) .alignItems(VerticalAlign.Center) - .backgroundColor(this.selectedSessionId === item.id ? SOFT : '#00000000') + .backgroundColor(this.isSessionSelected(item.id) ? SOFT : '#00000000') .borderRadius(10) + .onTouch((event: TouchEvent) => { + if (this.isBusy) { + return; + } + if (event.type === TouchType.Down) { + this.optimisticSelectedSessionId = item.id; + } else if (event.type === TouchType.Cancel) { + this.optimisticSelectedSessionId = ''; + } + }) .onClick(() => { + if (this.isBusy) { + return; + } + this.optimisticSelectedSessionId = item.id; this.onOpenSession(item); }) .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(item))) @@ -610,6 +630,12 @@ export struct RemoteSessionList { }) } + private isSessionSelected(sessionId: string): boolean { + const selectedSessionId = this.optimisticSelectedSessionId.length > 0 ? + this.optimisticSelectedSessionId : this.selectedSessionId; + return selectedSessionId === sessionId; + } + @Builder private SessionMoreButton(item: RemoteSession) { Stack({ alignContent: Alignment.Center }) { 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 a90edecec2..0a3cb71b96 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 @@ -4,23 +4,23 @@ import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { ModelServiceSettingsPanel } from './ModelServiceSettingsPanel'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; -@Component +@ComponentV2 export struct SettingsSheet { - @Prop generalChatApiUrl: string = ''; - @Prop generalChatModelName: string = ''; - @Prop hasGeneralChatApiKey: boolean = false; - @Prop generalChatModelCatalog: RemoteModelCatalog = { + @Param generalChatApiUrl: string = ''; + @Param generalChatModelName: string = ''; + @Param hasGeneralChatApiKey: boolean = false; + @Param generalChatModelCatalog: RemoteModelCatalog = { version: 0, models: [], default_models: {} }; - @Prop selectedGeneralChatModelId: string = ''; - @Prop accountUsername: string = ''; - @Prop authenticatedUserId: string = ''; - @Prop deviceId: string = ''; - onClose: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onSaveGeneralChatConfig: ( + @Param selectedGeneralChatModelId: string = ''; + @Param accountUsername: string = ''; + @Param authenticatedUserId: string = ''; + @Param deviceId: string = ''; + @Event onClose: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onSaveGeneralChatConfig: ( apiUrl: string, apiKey: string, modelName: string, @@ -31,7 +31,7 @@ export struct SettingsSheet { _modelName: string, _clearApiKey: boolean ) => ''; - onTestGeneralChatConfig: ( + @Event onTestGeneralChatConfig: ( apiUrl: string, apiKey: string, modelName: string, @@ -42,10 +42,10 @@ export struct SettingsSheet { _modelName: string, _clearApiKey: boolean ) => ''; - @State showModelService: boolean = false; - @State savedGeneralChatApiUrl: string = ''; - @State savedGeneralChatModelName: string = ''; - @State savedGeneralChatHasApiKey: boolean = false; + @Local showModelService: boolean = false; + @Local savedGeneralChatApiUrl: string = ''; + @Local savedGeneralChatModelName: string = ''; + @Local savedGeneralChatHasApiKey: boolean = false; aboutToAppear(): void { this.showModelService = false; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets new file mode 100644 index 0000000000..af5a2504d6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets @@ -0,0 +1,151 @@ +import { CARD, GREEN, INK, MUTED } from './Theme'; + +@ComponentV2 +export struct SidebarGlyph { + @Param kind: string = ''; + @Param connectionState: string = ''; + + build() { + if (this.kind === 'session_more') { + this.MoreDots() + } else if (this.kind === 'remote') { + this.Remote() + } else if (this.kind === 'search') { + this.Search() + } else if (this.kind === 'notebook') { + this.Notebook() + } else if (this.kind === 'clock') { + this.Clock() + } else if (this.kind === 'apps') { + this.Apps() + } else if (this.kind === 'code_flower') { + this.CodeFlower() + } else if (this.kind === 'more') { + this.More() + } else if (this.kind === 'edit') { + this.Edit() + } else if (this.kind === 'settings') { + this.Settings() + } + } + + @Builder + private MoreDots() { + Row({ space: 3 }) { + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + } + .height(8) + .alignItems(VerticalAlign.Center) + } + + @Builder + private Remote() { + Stack({ alignContent: Alignment.Center }) { + if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { + Image($r('app.media.remote_ref_sidebar_connected')) + .width(35).height(34).objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template).foregroundColor(INK) + Text('').width(8).height(8).backgroundColor(GREEN).borderRadius(4) + .position({ x: 24, y: 22 }) + } else { + Image($r('app.media.remote_logo')) + .width(34).height(34).objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template).foregroundColor(MUTED) + } + } + .width(35).height(34) + } + + @Builder + private Search() { + SymbolGlyph($r('sys.symbol.magnifyingglass')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } + + @Builder + private Notebook() { + Stack() { + Text('').width(22).height(24).borderRadius(5).border({ width: 1.5, color: INK }) + .position({ x: 8, y: 5 }) + Text('').width(4).height(4).borderRadius(2).backgroundColor(INK) + .position({ x: 5, y: 11 }) + Text('').width(4).height(4).borderRadius(2).backgroundColor(INK) + .position({ x: 5, y: 20 }) + } + .width(34).height(34) + } + + @Builder + private Clock() { + Stack() { + Text('').width(26).height(26).borderRadius(13).border({ width: 1.5, color: INK }) + .position({ x: 4, y: 4 }) + Text('').width(1.5).height(9).backgroundColor(INK).borderRadius(2) + .position({ x: 18, y: 10 }) + Text('').width(9).height(1.5).backgroundColor(INK).borderRadius(2) + .position({ x: 18, y: 20 }) + } + .width(34).height(34) + } + + @Builder + private Apps() { + Column({ space: 8 }) { + Row({ space: 8 }) { this.AppDot(); this.AppDot(); } + Row({ space: 8 }) { this.AppDot(); this.AppDot(); } + } + .width(24).height(24) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + private AppDot() { + Text('').width(8).height(8).borderRadius(4).backgroundColor(INK) + } + + @Builder + private CodeFlower() { + Stack() { + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 9, y: 1 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 17, y: 9 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 9, y: 17 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 1, y: 9 }) + Text('').width(14).height(14).borderRadius(7).backgroundColor(CARD) + .position({ x: 10, y: 10 }) + } + .width(34).height(34) + } + + @Builder + private More() { + Row({ space: 5 }) { this.Dot(); this.Dot(); this.Dot(); } + .width(30).height(22) + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + } + + @Builder + private Dot() { + Text('').width(5).height(5).borderRadius(3).backgroundColor(INK) + } + + @Builder + private Edit() { + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } + + @Builder + private Settings() { + SymbolGlyph($r('sys.symbol.gearshape')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets index 227cccd2a2..283e2698a0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets @@ -2,14 +2,14 @@ import { MarkdownContent } from './MarkdownContent'; const STREAMING_MARKDOWN_CACHE: Map = new Map(); -@Component +@ComponentV2 export struct StreamingMarkdownContent { - @Prop @Watch('handleTextChanged') text: string = ''; - @Prop @Watch('handleTextChanged') active: boolean = false; - @Prop @Watch('handleTextChanged') streamKey: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; - @State renderedText: string = ''; + @Param text: string = ''; + @Param active: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; + @Local renderedText: string = ''; private targetText: string = ''; private timerId: number = 0; private frameIntervalMs: number = 40; @@ -42,6 +42,7 @@ export struct StreamingMarkdownContent { }) } + @Monitor('text', 'active', 'streamKey') private handleTextChanged(): void { if (!this.active) { this.clearTimer(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets index 6adc633b73..854c234a46 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets @@ -12,6 +12,8 @@ export const CONNECT_HERO_BG: ResourceColor = $r('app.color.connect_hero_bg'); export const CONNECT_HERO_ACCENT: ResourceColor = $r('app.color.connect_hero_accent'); export const CONNECT_HERO_SECONDARY: ResourceColor = $r('app.color.connect_hero_secondary'); export const CONNECT_HERO_SURFACE: ResourceColor = $r('app.color.connect_hero_surface'); +export const CONNECT_SCAN_ACCENT: ResourceColor = $r('app.color.connect_scan_accent'); +export const MODAL_SCRIM: ResourceColor = $r('app.color.modal_scrim'); export const SOFT: ResourceColor = $r('app.color.soft'); export const FLOATING_PANEL_BG: ResourceColor = $r('app.color.floating_panel_bg'); export const GREEN: ResourceColor = $r('app.color.green'); 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 89bacf2425..42a9a75af5 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,14 +1,14 @@ import { MUTED } from './Theme'; -@Component +@ComponentV2 export struct ThinkingBlock { - @Prop text: string = ''; - @Prop status: string = ''; - @Prop keepExpandedWhenDone: boolean = false; - @Prop streaming: boolean = false; - @Prop streamKey: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - @State dotPhase: number = 0; + @Param text: string = ''; + @Param status: string = ''; + @Param keepExpandedWhenDone: boolean = false; + @Param streaming: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Local dotPhase: number = 0; private dotTimerId: number = 0; aboutToAppear(): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets new file mode 100644 index 0000000000..cc029ab416 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets @@ -0,0 +1,40 @@ +import { MUTED } from './Theme'; + +@ComponentV2 +export struct ToolGlyph { + @Param kind: string = 'tool'; + @Param color: ResourceColor = MUTED; + + build() { + SymbolGlyph(this.symbol()) + .fontSize(this.isChevron() ? 18 : 14) + .fontColor([this.color]) + .width(this.isChevron() ? 14 : 15) + .height(this.isChevron() ? 14 : 15) + } + + private isChevron(): boolean { + return this.kind.indexOf('chevron_') === 0; + } + + private symbol(): Resource { + if (this.kind === 'search') return $r('sys.symbol.magnifyingglass'); + if (this.kind === 'document') return $r('sys.symbol.doc_text'); + if (this.kind === 'stack') return $r('sys.symbol.rectangle_stack'); + if (this.kind === 'question') return $r('sys.symbol.questionmark_circle'); + if (this.kind === 'todo') return $r('sys.symbol.list_checkmark'); + if (this.kind === 'task') return $r('sys.symbol.robot'); + if (this.kind === 'git') return $r('sys.symbol.arrow_triangle_merge'); + if (this.kind === 'delete') return $r('sys.symbol.trash'); + if (this.kind === 'diff') return $r('sys.symbol.doc_text_badge_magnifyingglass'); + if (this.kind === 'patch' || this.kind === 'command') return $r('sys.symbol.code_square'); + if (this.kind === 'create') return $r('sys.symbol.doc_text_badge_arrow_up'); + if (this.kind === 'mutate') return $r('sys.symbol.square_and_pencil'); + if (this.kind === 'folder') return $r('sys.symbol.folder'); + if (this.kind === 'web') return $r('sys.symbol.link'); + if (this.kind === 'chevron_right') return $r('sys.symbol.chevron_right'); + if (this.kind === 'chevron_up') return $r('sys.symbol.chevron_up'); + if (this.kind === 'chevron_down') return $r('sys.symbol.chevron_down'); + return $r('sys.symbol.wrench_and_screwdriver'); + } +} 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 new file mode 100644 index 0000000000..9af587e2af --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets @@ -0,0 +1,171 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConversationUiQuestionAnswer } from './ConversationUiModels'; +import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ToolConfirmationPanel { + @Param toolId: string = ''; + @Param defaultInputText: string = ''; + @Param hasEditableInput: boolean = false; + @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = + (_toolId: string, _updatedInput?: Object) => {}; + @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; + @Local inputText: string = ''; + @Local inputError: string = ''; + + aboutToAppear(): void { + this.inputText = this.defaultInputText; + } + + build() { + Column({ space: 8 }) { + if (this.hasEditableInput) { + this.InputEditor() + } + Row({ space: 8 }) { + Text(RemoteI18n.t('chat.approve')) + .fontSize(12) + .fontColor(PRIMARY_ACTION_TEXT) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(ACCENT) + .borderRadius(16) + .onClick(() => this.approve()) + Text(RemoteI18n.t('chat.reject')) + .fontSize(12) + .fontColor(INK) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(SOFT) + .borderRadius(16) + .border({ width: 1, color: LINE }) + .onClick(() => this.onRejectTool(this.toolId)) + } + .width('100%') + } + .width('100%') + .padding({ left: 30 }) + } + + @Builder + private InputEditor() { + Column({ space: 6 }) { + Row() { + Text(RemoteI18n.t('chat.toolInput')).fontSize(11).fontColor(MUTED) + Blank() + Text(RemoteI18n.t('chat.reset')) + .fontSize(11) + .fontColor(MUTED) + .onClick(() => { + this.inputText = this.defaultInputText; + this.inputError = ''; + }) + } + .width('100%') + TextArea({ placeholder: RemoteI18n.t('chat.editJsonInput'), text: this.inputText }) + .height(96) + .fontSize(12) + .fontColor(INK) + .lineHeight(17) + .backgroundColor(SOFT) + .borderRadius(14) + .padding(10) + .border({ width: 1, color: this.inputError.length > 0 ? RED : LINE }) + .defaultFocus(false) + .enabled(true) + .onChange((value: string) => { + this.inputText = value; + this.inputError = ''; + }) + if (this.inputError.length > 0) { + Text(this.inputError).fontSize(11).fontColor(RED) + } + } + .width('100%') + } + + private approve(): void { + if (this.toolId.length === 0) { + return; + } + if (!this.hasEditableInput) { + this.onApproveTool(this.toolId); + return; + } + const rawInput = this.inputText.trim(); + if (rawInput.length === 0) { + this.inputError = RemoteI18n.t('chat.jsonObjectRequired'); + return; + } + try { + const parsed = JSON.parse(rawInput) as Object; + if (parsed === null || Array.isArray(parsed)) { + this.inputError = RemoteI18n.t('chat.jsonObjectRequired'); + return; + } + this.inputError = ''; + this.onApproveTool(this.toolId, parsed); + } catch (_err) { + this.inputError = RemoteI18n.t('chat.jsonInvalid'); + } + } +} + +@ComponentV2 +export struct ToolQuestionAnswerPanel { + @Param toolId: string = ''; + @Param prompt: string = ''; + @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = + (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Local answerText: string = ''; + + build() { + Column({ space: 8 }) { + Text(this.prompt) + .fontSize(12) + .lineHeight(17) + .fontColor(INK) + .width('100%') + TextArea({ placeholder: RemoteI18n.t('chat.answerPlaceholder'), text: this.answerText }) + .height(78) + .fontSize(13) + .backgroundColor(CARD) + .borderRadius(14) + .padding(12) + .border({ width: 1, color: LINE }) + .defaultFocus(false) + .enabled(true) + .onChange((value: string) => { this.answerText = value; }) + Row() { + Text(RemoteI18n.t('chat.submitAnswer')) + .fontSize(12) + .fontColor(this.canSubmit() ? PRIMARY_ACTION_TEXT : MUTED) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(this.canSubmit() ? ACCENT : SOFT) + .borderRadius(16) + .onClick(() => this.submit()) + } + .width('100%') + } + .width('100%') + .padding({ left: 30 }) + } + + private canSubmit(): boolean { + return this.toolId.length > 0 && this.answerText.trim().length > 0; + } + + private submit(): void { + if (!this.canSubmit()) { + return; + } + const answer = this.answerText.trim(); + const answers: ConversationUiQuestionAnswer = { answer, '0': answer }; + this.onAnswerQuestion(this.toolId, answers); + this.answerText = ''; + } +} 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 bd1316fb2c..bb8c2ed7e9 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,7 +1,9 @@ import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ToolFileReference, ToolFileReferenceResolver } from '../../services/ToolFileReferenceResolver'; -import { ACCENT, CARD, FILE_LINK, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; +import { CARD, FILE_LINK, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; +import { ToolGlyph } from './ToolGlyphs'; +import { ToolConfirmationPanel, ToolQuestionAnswerPanel } from './ToolInteractionPanels'; interface QuestionPreview { header?: string; @@ -61,11 +63,6 @@ export struct ToolStatusList { (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; - @Local questionAnswerToolId: string = ''; - @Local questionAnswerText: string = ''; - @Local toolInputEditToolId: string = ''; - @Local toolInputEditText: string = ''; - @Local toolInputEditError: string = ''; @Local expanded: boolean = false; @Local expandedToolKey: string = ''; @@ -138,39 +135,20 @@ export struct ToolStatusList { } if (this.isPendingConfirmation(tool)) { - if (this.hasEditableToolInput(tool)) { - this.ToolInputEditor(tool) - } - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.approve')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(ACCENT) - .borderRadius(16) - .onClick(() => { - this.approveToolWithInput(tool); - }) - Text(RemoteI18n.t('chat.reject')) - .fontSize(12) - .fontColor(INK) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(SOFT) - .borderRadius(16) - .border({ width: 1, color: LINE }) - .onClick(() => { - this.onRejectTool(tool.id || ''); - }) - } - .width('100%') - .padding({ left: 30 }) + ToolConfirmationPanel({ + toolId: tool.id || '', + defaultInputText: this.defaultToolInputText(tool), + hasEditableInput: this.hasEditableToolInput(tool), + onApproveTool: this.onApproveTool, + onRejectTool: this.onRejectTool + }) } if (this.isQuestionTool(tool)) { - this.QuestionAnswer(tool) + ToolQuestionAnswerPanel({ + toolId: tool.id || '', + prompt: this.questionPrompt(tool), + onAnswerQuestion: this.onAnswerQuestion + }) } if (this.isRunningTool(tool)) { Row() { @@ -260,122 +238,12 @@ export struct ToolStatusList { @Builder SummaryTypeSymbol(entry: ToolRenderEntry) { - if (entry.searchCount > 0 && entry.readCount === 0) { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } else if (entry.readCount > 0 && entry.searchCount === 0) { - SymbolGlyph($r('sys.symbol.doc_text')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.rectangle_stack')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } + ToolGlyph({ kind: this.summaryGlyphKind(entry), color: this.summaryTypeColor(entry) }) } @Builder ToolTypeSymbol(tool: ConversationUiToolStatus) { - if (this.isQuestionLikeTool(tool)) { - SymbolGlyph($r('sys.symbol.questionmark_circle')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isTodoTool(tool)) { - SymbolGlyph($r('sys.symbol.list_checkmark')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isTaskTool(tool)) { - SymbolGlyph($r('sys.symbol.robot')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isGitTool(tool)) { - SymbolGlyph($r('sys.symbol.arrow_triangle_merge')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isDeleteTool(tool)) { - SymbolGlyph($r('sys.symbol.trash')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isDiffTool(tool)) { - SymbolGlyph($r('sys.symbol.doc_text_badge_magnifyingglass')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isPatchTool(tool)) { - SymbolGlyph($r('sys.symbol.code_square')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileCreateTool(tool)) { - SymbolGlyph($r('sys.symbol.doc_text_badge_arrow_up')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileMutationTool(tool)) { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileReadTool(tool)) { - if (this.isDirectoryListTool(tool)) { - SymbolGlyph($r('sys.symbol.folder')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.doc_text')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } - } else if (this.isSearchTool(tool)) { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isWebTool(tool)) { - SymbolGlyph($r('sys.symbol.link')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isCommandTool(tool)) { - SymbolGlyph($r('sys.symbol.code_square')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.wrench_and_screwdriver')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } + ToolGlyph({ kind: this.toolGlyphKind(tool), color: this.toolTypeColor(tool) }) } @Builder @@ -392,77 +260,9 @@ export struct ToolStatusList { .border({ width: 1, color: CARD }) } - @Builder - RunningDotsIcon() { - Row({ space: 2 }) { - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - } - .width(16) - .height(16) - .justifyContent(FlexAlign.Center) - } - - @Builder - AlertCircleIcon(color: string, mark: string) { - Text(mark) - .width(16) - .height(16) - .fontSize(10) - .fontColor(color) - .textAlign(TextAlign.Center) - .border({ width: 1.5, color }) - .borderRadius(8) - } - - @Builder - NeutralDotIcon() { - Stack() { - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(MUTED) - .position({ x: 6, y: 6 }) - } - .width(16) - .height(16) - } - @Builder ChevronIcon(direction: string) { - if (direction === 'right') { - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } else if (direction === 'up') { - SymbolGlyph($r('sys.symbol.chevron_up')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } else { - SymbolGlyph($r('sys.symbol.chevron_down')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } + ToolGlyph({ kind: `chevron_${direction}`, color: MUTED }) } @Builder @@ -483,99 +283,6 @@ export struct ToolStatusList { .padding({ left: 30, top: 2 }) } - @Builder - ToolInputEditor(tool: ConversationUiToolStatus) { - Column({ space: 6 }) { - Row() { - Text(RemoteI18n.t('chat.toolInput')) - .fontSize(11) - .fontColor(MUTED) - Blank() - Text(RemoteI18n.t('chat.reset')) - .fontSize(11) - .fontColor(MUTED) - .onClick(() => { - this.toolInputEditToolId = tool.id || ''; - this.toolInputEditText = this.defaultToolInputText(tool); - this.toolInputEditError = ''; - }) - } - .width('100%') - TextArea({ placeholder: RemoteI18n.t('chat.editJsonInput'), text: this.toolInputTextForTool(tool) }) - .height(96) - .fontSize(12) - .fontColor(INK) - .lineHeight(17) - .backgroundColor(SOFT) - .borderRadius(14) - .padding(10) - .border({ width: 1, color: this.toolInputErrorForTool(tool.id || '').length > 0 ? RED : LINE }) - .defaultFocus(false) - .enabled(true) - .onChange((value: string) => { - this.toolInputEditToolId = tool.id || ''; - this.toolInputEditText = value; - this.toolInputEditError = ''; - }) - if (this.toolInputErrorForTool(tool.id || '').length > 0) { - Text(this.toolInputErrorForTool(tool.id || '')) - .fontSize(11) - .fontColor(RED) - } - } - .width('100%') - .padding({ left: 30 }) - } - - @Builder - QuestionAnswer(tool: ConversationUiToolStatus) { - Column({ space: 8 }) { - Text(this.questionPrompt(tool)) - .fontSize(12) - .lineHeight(17) - .fontColor(INK) - .width('100%') - TextArea({ placeholder: RemoteI18n.t('chat.answerPlaceholder'), text: this.answerTextForTool(tool.id || '') }) - .height(78) - .fontSize(13) - .backgroundColor(CARD) - .borderRadius(14) - .padding(12) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .enabled(true) - .onChange((value: string) => { - this.questionAnswerToolId = tool.id || ''; - this.questionAnswerText = value; - }) - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.submitAnswer')) - .fontSize(12) - .fontColor(this.canSubmitQuestion(tool.id || '') ? PRIMARY_ACTION_TEXT : MUTED) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(this.canSubmitQuestion(tool.id || '') ? ACCENT : SOFT) - .borderRadius(16) - .onClick(() => { - if (this.canSubmitQuestion(tool.id || '')) { - const answer = this.questionAnswerText.trim(); - const answers: ConversationUiQuestionAnswer = { - answer, - '0': answer - }; - this.onAnswerQuestion(tool.id || '', answers); - this.questionAnswerText = ''; - this.questionAnswerToolId = ''; - } - }) - } - .width('100%') - } - .width('100%') - .padding({ left: 30 }) - } - private displayStatus(status: string): string { const normalized = (status || '').toLowerCase(); if (normalized === 'running' || normalized === 'active') { @@ -758,18 +465,6 @@ export struct ToolStatusList { }; } - private hasCollapsibleTools(): boolean { - let runLength = 0; - return this.tools.some((tool: ConversationUiToolStatus) => { - if (this.shouldCollapseExploreTool(tool)) { - runLength += 1; - return runLength >= 2; - } - runLength = 0; - return false; - }); - } - private shouldCollapseExploreTool(tool: ConversationUiToolStatus): boolean { if (this.hasToolError(tool) || this.isPendingConfirmation(tool) || this.isQuestionTool(tool) || this.isRunningTool(tool)) { @@ -823,6 +518,29 @@ export struct ToolStatusList { return (tool.name || 'Tool').replace(/[\s-]/g, '_').toLowerCase(); } + private summaryGlyphKind(entry: ToolRenderEntry): string { + if (entry.searchCount > 0 && entry.readCount === 0) return 'search'; + if (entry.readCount > 0 && entry.searchCount === 0) return 'document'; + return 'stack'; + } + + private toolGlyphKind(tool: ConversationUiToolStatus): string { + if (this.isQuestionLikeTool(tool)) return 'question'; + if (this.isTodoTool(tool)) return 'todo'; + if (this.isTaskTool(tool)) return 'task'; + if (this.isGitTool(tool)) return 'git'; + if (this.isDeleteTool(tool)) return 'delete'; + if (this.isDiffTool(tool)) return 'diff'; + if (this.isPatchTool(tool)) return 'patch'; + if (this.isFileCreateTool(tool)) return 'create'; + if (this.isFileMutationTool(tool)) return 'mutate'; + if (this.isFileReadTool(tool)) return this.isDirectoryListTool(tool) ? 'folder' : 'document'; + if (this.isSearchTool(tool)) return 'search'; + if (this.isWebTool(tool)) return 'web'; + if (this.isCommandTool(tool)) return 'command'; + return 'tool'; + } + private isQuestionLikeTool(tool: ConversationUiToolStatus): boolean { const normalized = this.normalizedToolName(tool); return this.isQuestionTool(tool) || normalized === 'askuserquestion' || normalized === 'ask_user_question'; @@ -1280,50 +998,6 @@ export struct ToolStatusList { } } - private toolInputTextForTool(tool: ConversationUiToolStatus): string { - const toolId = tool.id || ''; - if (this.toolInputEditToolId === toolId) { - return this.toolInputEditText; - } - return this.defaultToolInputText(tool); - } - - private toolInputErrorForTool(toolId: string): string { - return this.toolInputEditToolId === toolId ? this.toolInputEditError : ''; - } - - private approveToolWithInput(tool: ConversationUiToolStatus): void { - const toolId = tool.id || ''; - if (toolId.length === 0) { - return; - } - if (!this.hasEditableToolInput(tool)) { - this.onApproveTool(toolId); - return; - } - - const rawInput = this.toolInputTextForTool(tool).trim(); - if (rawInput.length === 0) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonObjectRequired'); - return; - } - - try { - const parsed = JSON.parse(rawInput) as Object; - if (parsed === null || Array.isArray(parsed)) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonObjectRequired'); - return; - } - this.toolInputEditError = ''; - this.onApproveTool(toolId, parsed); - } catch (_err) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonInvalid'); - } - } - private isRunningTool(tool: ConversationUiToolStatus): boolean { const status = (tool.status || '').toLowerCase(); return (status === 'running' || status === 'active') && (tool.id || '').length > 0; @@ -1387,16 +1061,6 @@ export struct ToolStatusList { return ''; } - private answerTextForTool(toolId: string): string { - return this.questionAnswerToolId === toolId ? this.questionAnswerText : ''; - } - - private canSubmitQuestion(toolId: string): boolean { - return toolId.length > 0 && - this.questionAnswerToolId === toolId && - this.questionAnswerText.trim().length > 0; - } - private toolKey(tool: ConversationUiToolStatus, index: number): string { const signature = this.toolSignature(tool); if (tool.id && tool.id.length > 0) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets new file mode 100644 index 0000000000..8db93d9fac --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -0,0 +1,319 @@ +import { RemoteUiState } from '../../services/RemoteUiState'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { WideLayoutGeometry } from '../layout/WideLayoutGeometry'; +import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; +import { FilePreviewLayout, FilePreviewPlacement } from '../policy/FilePreviewPlacementPolicy'; +import { AppShellState } from '../state/AppShellState'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppSidebar } from './AppSidebar'; +import { ConversationRouteSurface } from './ConversationRouteSurface'; +import { FilePreviewSurface } from './FilePreviewSurface'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { SidebarToggleButton } from './SidebarToggleButton'; +import { FLOATING_PANEL_BG, LINE, PAGE_BG } from './Theme'; + +const WIDE_DETAIL_CONTENT_MAX_WIDTH: number = 920; + +@ComponentV2 +export struct WideConversationHost { + @Param route: AppRoute = AppRoute.ChatHome; + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param filePreviewState: FilePreviewState = new FilePreviewState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param filePreviewLayout: FilePreviewLayout = new FilePreviewLayout(FilePreviewPlacement.Hidden); + @Param wideMasterPaneWidth: number = 0; + @Param wideMasterDetailGap: number = 0; + @Param wideDetailContentOffset: number = 0; + @Param wideDetailContentWidth: number = 0; + @Param wideCollapsedDetailContentOffset: number = 0; + @Param wideCollapsedDetailContentWidth: number = 0; + @Param wideMasterPaneCollapsed: boolean = false; + @Param wideMasterPaneMotionActive: boolean = false; + @Event onCollapseMasterPane: () => void = () => {}; + @Event onRestoreMasterPane: () => void = () => {}; + @Event onOpenRemoteViewSettings: () => void = () => {}; + + build() { + if (this.route === AppRoute.ChatHome || this.route === AppRoute.GeneralChat) { + this.GeneralChatContent(); + } else if (this.showsRemoteConversation() && + this.filePreviewLayout.placement === FilePreviewPlacement.WideFocusSplit) { + this.RemotePreviewFocusContent(); + } else if (this.showsRemoteConversation()) { + this.RemoteChatContent(); + } else if (this.route === AppRoute.RemoteHome) { + this.RemoteHomeContent(); + } else { + this.RemoteCreateContent(); + } + } + + @Builder + private GeneralChatContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.General, false) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private RemoteHomeContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, false) + this.MasterDetailGap() + } + Column() { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Placeholder, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + wideMasterPaneCollapsed: this.wideMasterPaneCollapsed, + onRestoreSidebar: this.onRestoreMasterPane + }) + } + .layoutWeight(1).height('100%').backgroundColor(PAGE_BG) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private RemoteCreateContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, false) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private MasterPane(source: ConversationSource, showSelectedSession: boolean) { + Column() { + Column() { + AppSidebar({ + sessions: source === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), + pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: source === ConversationSource.Remote ? '' : + this.generalPageState.conversation.activeSession.sessionId, + connectionState: this.remotePageState.connectionState, + accountUserId: this.remotePageState.accountUserId, + activeSection: source === ConversationSource.Remote ? 'remote' : 'chat', + showConversationSourceSwitcher: true, + showCollapseButton: true, + showViewSettingsButton: source === ConversationSource.Remote, + showCustomContent: source === ConversationSource.Remote, + conversationSource: source, + contentSlot: () => { + this.RemoteMasterContent(showSelectedSession) + }, + onClose: this.actions.onSidebar.close, + onNewChat: source === ConversationSource.Remote ? + this.actions.onRemoteHome.createAssistant : this.actions.onSidebar.newChat, + onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), + onConversationSource: this.actions.onWideConversationSource, + onCollapse: this.onCollapseMasterPane, + onOpenViewSettings: this.onOpenRemoteViewSettings, + onSearchQueryChange: (query: string) => { + if (source === ConversationSource.Remote) this.actions.onRemoteHome.queryChanged(query); + }, + onOpenSettings: source === ConversationSource.Remote ? + this.actions.onRemoteHome.openSettings : this.actions.onSidebar.settings, + onOpenAccount: this.actions.onSidebar.openAccount, + onOpenSession: this.actions.onSidebar.openSession, + onArchiveSession: this.actions.onSidebar.archive, + onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession + }) + } + .width('100%').height('100%').backgroundColor(FLOATING_PANEL_BG) + .borderRadius(18).clip(true) + .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) + } + .width(WideLayoutGeometry.masterPaneWidth(this.filePreviewLayout, this.wideMasterPaneWidth)) + .height('100%').padding({ left: 10, right: 6, top: 10, bottom: 10 }).backgroundColor(PAGE_BG) + .transition(this.wideMasterPaneMotionActive ? + TransitionEffect.translate({ x: -28, y: 0 }).combine(TransitionEffect.opacity(0)) + .animation({ duration: 220, curve: Curve.EaseInOut }) : TransitionEffect.opacity(1)) + } + + @Builder + private RemoteMasterContent(showSelectedSession: boolean) { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Master, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + showSelectedSession, + compact: false + }) + } + + @Builder + private RemoteChatContent() { + if (this.filePreviewLayout.placement === FilePreviewPlacement.WideTriplePane) { + Row() { + this.MasterPane(ConversationSource.Remote, true) + this.PaneGap(this.filePreviewLayout.masterConversationGap) + this.ConversationDetail(false, this.filePreviewLayout.conversationPaneWidth) + this.PaneGap(this.filePreviewLayout.conversationPreviewGap) + this.FilePreviewPane(this.filePreviewLayout.previewPaneWidth) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } else { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, true) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + } + + @Builder + private RemotePreviewFocusContent() { + Row() { + this.ConversationDetail(false, this.filePreviewLayout.conversationPaneWidth) + this.PaneGap(this.filePreviewLayout.conversationPreviewGap) + this.FilePreviewPane(this.filePreviewLayout.previewPaneWidth) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private FilePreviewPane(paneWidth: number) { + Column() { + FilePreviewSurface({ + state: this.filePreviewState, + remoteAvailable: RemoteUiState.canUseRemote(this.remotePageState.connectionState), + downloadPath: this.remotePageState.downloadingFilePath, + downloadedPath: this.remotePageState.downloadedFilePath, + downloadStatus: this.remotePageState.fileDownloadStatus, + onClose: this.actions.onFilePreview.close, + onRefresh: this.actions.onFilePreview.refresh, + onDownload: this.actions.onFilePreview.download, + onOpenLink: this.actions.onFilePreview.openLink + }) + } + .width(paneWidth).height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private ConversationDetail(showBackButton: boolean, paneWidth: number = 0) { + if (paneWidth > 0) { + Column() { + this.RouteSurface(showBackButton) + } + .width(paneWidth).height('100%').constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .backgroundColor(PAGE_BG) + } else { + Stack({ alignContent: Alignment.TopStart }) { + Row() { + if (this.currentDetailOffset() > 0) Blank().width(this.currentDetailOffset()) + Row() { + Column() { this.RouteSurface(showBackButton) } + .width('100%').height('100%').constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .backgroundColor(PAGE_BG) + } + .width(this.currentDetailWidth() > 0 ? this.currentDetailWidth() : '100%') + .height('100%').justifyContent(FlexAlign.Center) + if (this.currentDetailOffset() > 0) Blank().layoutWeight(1) + } + .width('100%').height('100%').justifyContent(FlexAlign.Center).backgroundColor(PAGE_BG) + + if (this.wideMasterPaneCollapsed) { + SidebarToggleButton({ restore: true, controlSize: 44, onToggle: this.onRestoreMasterPane }) + .position({ x: this.currentDetailOffset() + 12, y: 12 }).zIndex(2) + .transition(TransitionEffect.scale({ x: 0.9, y: 0.9 }).combine(TransitionEffect.opacity(0)) + .animation({ duration: 180, curve: Curve.EaseOut })) + } + } + .layoutWeight(1).height('100%').backgroundColor(PAGE_BG) + } + } + + @Builder + private RouteSurface(showBackButton: boolean) { + ConversationRouteSurface({ + route: this.route, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + showSidebarButton: false, + showBackButton, + useWidePresentation: true, + contentHorizontalOffset: this.collapsedDetailVisualBias(), + onRestoreSidebar: this.onRestoreMasterPane + }) + } + + @Builder + private MasterDetailGap() { + if (this.wideMasterDetailGap > 0) { + Row() {}.width(this.wideMasterDetailGap).height('100%').backgroundColor(LINE) + } + } + + @Builder + private PaneGap(width: number) { + if (width > 0) { + Row() {}.width(width).height('100%').backgroundColor(LINE) + } + } + + private showsRemoteConversation(): boolean { + return this.route === AppRoute.RemoteChat; + } + + private currentDetailOffset(): number { + return WideLayoutGeometry.detailOffset( + this.wideMasterPaneCollapsed, + this.wideDetailContentOffset, + this.wideCollapsedDetailContentOffset + ); + } + + private currentDetailWidth(): number { + return WideLayoutGeometry.detailWidth( + this.wideMasterPaneCollapsed, + this.wideDetailContentWidth, + this.wideCollapsedDetailContentWidth + ); + } + + private collapsedDetailVisualBias(): number { + return WideLayoutGeometry.collapsedVisualBias( + this.wideMasterPaneCollapsed, + this.wideCollapsedDetailContentOffset, + this.wideCollapsedDetailContentWidth, + WIDE_DETAIL_CONTENT_MAX_WIDTH, + 72 + ); + } +} 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 new file mode 100644 index 0000000000..11f1f41e09 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -0,0 +1,368 @@ +import { RemoteI18n } from '../../../i18n/RemoteI18n'; +import { RemoteSession } from '../../../model/RemoteModels'; +import { RemotePageState } from '../../state/RemotePageState'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../../actions/AppRootPresentationActions'; +import { ConversationViewSettings } from '../ConversationViewSettings'; +import { GeneralChatHeader } from '../GeneralChatHeader'; +import { RemoteSessionList } from '../RemoteSessionList'; +import { RemoteSessionLoadingView } from '../RemoteSessionLoadingView'; +import { SidebarToggleButton } from '../SidebarToggleButton'; +import { SessionActionPresentation } from '../SessionActionSurface'; +import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED } from '../Theme'; + +export enum RemoteSurfaceMode { + Master = 'master', + CompactHome = 'compact_home', + Placeholder = 'placeholder', + Settings = 'settings' +} + +/** Shared presentation state for compact and wide Remote surfaces. */ +@ObservedV2 +export class RemoteSurfaceState { + @Trace sortMode: string = 'project'; + @Trace workspaceFilter: string = ''; + @Trace agentFilter: string = ''; + @Trace statusFilter: string = ''; + @Trace showWorkspaceMetadata: boolean = false; + @Trace showUpdatedMetadata: boolean = false; + @Trace showStatusMetadata: boolean = false; + + setSortMode(value: string): void { this.sortMode = value; } + setWorkspaceFilter(value: string): void { this.workspaceFilter = value; } + setAgentFilter(value: string): void { this.agentFilter = value; } + setStatusFilter(value: string): void { this.statusFilter = value; } + setWorkspaceMetadata(value: boolean): void { this.showWorkspaceMetadata = value; } + setUpdatedMetadata(value: boolean): void { this.showUpdatedMetadata = value; } + setStatusMetadata(value: boolean): void { this.showStatusMetadata = value; } +} + +@ComponentV2 +export struct RemoteSurfaceHost { + @Param mode: RemoteSurfaceMode = RemoteSurfaceMode.Master; + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param presentationState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param showSelectedSession: boolean = false; + @Param compact: boolean = false; + @Param wideMasterPaneCollapsed: boolean = false; + @Event onOpenSidebar: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; + @Event onCloseSettings: () => void = () => {}; + + build() { + if (this.mode === RemoteSurfaceMode.Master) { + this.MasterContent(); + } else if (this.mode === RemoteSurfaceMode.CompactHome) { + this.CompactHomeContent(); + } else if (this.mode === RemoteSurfaceMode.Placeholder) { + this.FlowPlaceholder(); + } else { + this.SettingsContent(); + } + } + + @Builder + private MasterContent() { + Column() { + this.StatusRow() + if (this.isInitialLoading()) { + RemoteSessionLoadingView() + } else if (this.canShowSessionList()) { + RemoteSessionList({ + sessions: this.remotePageState.visibleSessions(), + query: this.remotePageState.sessionQuery, + sortMode: this.presentationState.sortMode, + workspaceFilter: this.presentationState.workspaceFilter, + agentFilter: this.presentationState.agentFilter, + statusFilter: this.presentationState.statusFilter, + workspaceName: this.remotePageState.workspaceName, + workspacePath: this.remotePageState.workspacePath, + workspaceKind: this.remotePageState.workspaceKind, + recentWorkspaces: this.remotePageState.recentWorkspaces, + actionPresentation: SessionActionPresentation.Popover, + showWorkspaceMetadata: this.presentationState.showWorkspaceMetadata, + showUpdatedMetadata: this.presentationState.showUpdatedMetadata, + showStatusMetadata: this.presentationState.showStatusMetadata, + hasMoreSessions: this.remotePageState.hasMoreSessions, + isBusy: this.remotePageState.conversation.isBusy || this.remotePageState.isLoadingSessions, + selectedSessionId: this.remotePageState.pendingSessionId.length > 0 ? + this.remotePageState.pendingSessionId : + (this.showSelectedSession ? this.remotePageState.conversation.activeSession.sessionId : ''), + onCreate: () => this.createSession('code'), + onCreateAssistantSession: () => this.createAssistantSession(), + onCreateInWorkspace: (path: string, agentType: string) => this.createSessionInWorkspace(path, agentType), + onSelectWorkspace: (path: string) => this.actions.onRemoteHome.selectWorkspace(path), + onOpenSession: (session: RemoteSession) => this.openSession(session), + onDeleteSession: (session: RemoteSession) => this.actions.onRemoteHome.deleteSession(session), + onLoadMore: () => this.actions.onRemoteHome.loadMore() + }) + } else { + this.DisconnectedState() + } + } + .width('100%') + .height('100%') + .alignItems(HorizontalAlign.Start) + .padding({ bottom: 84 }) + } + + @Builder + private StatusRow() { + Row({ space: 6 }) { + this.StatusIndicator() + Text(this.statusText()) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .width('100%') + .margin({ top: 16, bottom: 6 }) + .alignItems(VerticalAlign.Center) + } + + @Builder + private StatusIndicator() { + if (this.isInitialLoading()) { + LoadingProgress().width(14).height(14).color(MUTED) + } else { + Stack() { + Text('') + } + .width(7) + .height(7) + .backgroundColor(this.statusColor()) + .borderRadius(4) + } + } + + @Builder + private DisconnectedState() { + Column({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.desktop')).fontSize(42).fontColor([INK]) + } + .width(74) + .height(74) + .backgroundColor(CARD) + .borderRadius(24) + .border({ width: 1, color: LINE }) + Text(RemoteI18n.t('remote.connectTitle')) + .fontSize(18).fontWeight(FontWeight.Bold).fontColor(INK).textAlign(TextAlign.Center) + Text(RemoteI18n.t('remote.connectText')) + .fontSize(13).lineHeight(20).fontColor(MUTED).textAlign(TextAlign.Center) + Text(RemoteI18n.t('connect.connect')) + .width(136).height(44).fontSize(15).fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION).textAlign(TextAlign.Center).borderRadius(22) + .onClick(() => this.actions.onRemoteHome.connectWorkspace()) + } + .layoutWeight(1) + .width('100%') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .padding({ left: 20, right: 20, bottom: 48 }) + } + + @Builder + private CompactHomeContent() { + Column() { + GeneralChatHeader({ + title: RemoteI18n.t('remote.title'), + subtitle: this.compactHeaderContext(), + showSidebarButton: true, + onOpenSidebar: this.onOpenSidebar + }) + if (this.canShowSessionList()) { + this.CompactEmptyState() + } else { + this.DisconnectedState() + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + private CompactEmptyState() { + Column({ space: 10 }) { + if (this.isInitialLoading()) { + LoadingProgress().width(28).height(28).color(MUTED).margin({ bottom: 8 }) + } + Text(this.compactTitle()) + .fontSize(20).fontWeight(FontWeight.Bold).fontColor(INK).textAlign(TextAlign.Center) + Text(this.compactText()) + .fontSize(14).lineHeight(21).fontColor(MUTED).maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }).textAlign(TextAlign.Center) + .constraintSize({ maxWidth: 280 }) + Text(RemoteI18n.t('remote.startSession')) + .width(148).height(46).fontSize(15).fontWeight(FontWeight.Medium) + .fontColor(PRIMARY_ACTION_TEXT).backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) + .onClick(() => this.actions.onRemoteHome.createAssistant()) + } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center).padding({ left: 24, right: 24, bottom: 56 }) + } + + @Builder + private FlowPlaceholder() { + Column() { + Row({ space: 8 }) { + if (this.wideMasterPaneCollapsed) { + SidebarToggleButton({ restore: true, controlSize: 48, onToggle: this.onRestoreSidebar }) + } else { + Blank().width(48).height(48) + } + Column({ space: 4 }) { + Text(RemoteI18n.t('remote.chats')).fontSize(20).fontWeight(FontWeight.Bold).fontColor(INK) + Text(this.desktopName()).fontSize(13).fontColor(MUTED).maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1).alignItems(HorizontalAlign.Center) + Blank().width(48).height(48) + } + .width('100%').height(76).padding({ left: 16, right: 16, top: 14, bottom: 12 }) + .border({ width: { bottom: 1 }, color: LINE }) + + Column({ space: 8 }) { + if (this.isInitialLoading()) { + LoadingProgress().width(28).height(28).color(MUTED).margin({ bottom: 8 }) + } + Text(this.placeholderTitle()).fontSize(22).fontWeight(FontWeight.Bold).fontColor(INK) + Text(this.statusText()).fontSize(14).fontColor(MUTED).maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center).padding({ left: 24, right: 24, bottom: 48 }) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private SettingsContent() { + ConversationViewSettings({ + sessions: this.remotePageState.visibleSessions(), + workspaceName: this.remotePageState.workspaceName, + workspacePath: this.remotePageState.workspacePath, + workspaceKind: this.remotePageState.workspaceKind, + recentWorkspaces: this.remotePageState.recentWorkspaces, + sortMode: this.presentationState.sortMode, + workspaceFilter: this.presentationState.workspaceFilter, + agentFilter: this.presentationState.agentFilter, + statusFilter: this.presentationState.statusFilter, + showWorkspaceMetadata: this.presentationState.showWorkspaceMetadata, + showUpdatedMetadata: this.presentationState.showUpdatedMetadata, + showStatusMetadata: this.presentationState.showStatusMetadata, + onSortModeChange: (value: string) => this.presentationState.setSortMode(value), + onWorkspaceFilterChange: (value: string) => this.presentationState.setWorkspaceFilter(value), + onAgentFilterChange: (value: string) => this.presentationState.setAgentFilter(value), + onStatusFilterChange: (value: string) => this.presentationState.setStatusFilter(value), + onWorkspaceMetadataChange: (value: boolean) => this.presentationState.setWorkspaceMetadata(value), + onUpdatedMetadataChange: (value: boolean) => this.presentationState.setUpdatedMetadata(value), + onStatusMetadataChange: (value: boolean) => this.presentationState.setStatusMetadata(value), + onClose: this.onCloseSettings + }) + } + + private openSession(session: RemoteSession): void { + if (this.compact) { + this.actions.onSidebar.openSession(session); + } else { + this.actions.onRemoteHome.openSessionInPlace(session); + } + } + + private createSession(agentType: string): void { + if (this.compact) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.create(agentType); + } else { + this.actions.onRemoteHome.createInPlace(agentType); + } + } + + private createSessionInWorkspace(path: string, agentType: string): void { + if (this.compact) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.createInWorkspace(path, agentType); + } else { + this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); + } + } + + private createAssistantSession(): void { + if (this.compact) { + this.actions.onSidebar.close(); + } + this.actions.onRemoteHome.createAssistant(); + } + + private canShowSessionList(): boolean { + return this.remotePageState.connectionState === 'connected' || + this.remotePageState.visibleSessions().length > 0 || + this.remotePageState.isLoadingHome || this.remotePageState.isLoadingSessions; + } + + private isInitialLoading(): boolean { + return this.remotePageState.isLoadingHome || this.isConnecting(); + } + + private isConnecting(): boolean { + return this.remotePageState.connectionState === 'parsing' || + this.remotePageState.connectionState === 'pairing' || + this.remotePageState.connectionState === 'reconnecting'; + } + + private statusText(): string { + if (this.remotePageState.conversation.statusText.length > 0) { + return this.remotePageState.conversation.statusText; + } + return this.desktopName(); + } + + private statusColor(): ResourceColor { + if (this.remotePageState.connectionState === 'connected') return GREEN; + if (this.remotePageState.connectionState === 'failed' || this.remotePageState.connectionState === 'disconnected') { + return RED; + } + return MUTED; + } + + /** + * Compact Remote Home names the bound desktop under the title, the same + * context the conversation header carries. Stays empty while disconnected so + * the connect state does not advertise a stale desktop. + */ + private compactHeaderContext(): string { + return this.canShowSessionList() ? this.remotePageState.desktopName : ''; + } + + private desktopName(): string { + return this.remotePageState.desktopName.length > 0 ? this.remotePageState.desktopName : + RemoteI18n.t('remote.settings.noDesktop'); + } + + private compactTitle(): string { + if (this.isInitialLoading()) return RemoteI18n.t('common.loading'); + return this.remotePageState.visibleSessions().length > 0 ? + RemoteI18n.t('remote.pickSession') : RemoteI18n.t('remote.emptyTitle'); + } + + private compactText(): string { + if (this.isInitialLoading()) return this.statusText(); + return this.remotePageState.visibleSessions().length > 0 ? + RemoteI18n.t('remote.pickSessionText') : RemoteI18n.t('remote.emptyText'); + } + + private placeholderTitle(): string { + if (this.isInitialLoading()) return RemoteI18n.t('common.loading'); + return this.remotePageState.visibleSessions().length > 0 ? '选择会话' : RemoteI18n.t('remote.emptyTitle'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets new file mode 100644 index 0000000000..4b3de88883 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets @@ -0,0 +1,35 @@ +import { FilePreviewLayout, FilePreviewPlacement } from '../policy/FilePreviewPlacementPolicy'; + +/** Pure geometry helpers shared by wide conversation presentation paths. */ +export class WideLayoutGeometry { + static masterPaneWidth(layout: FilePreviewLayout, fallback: number): number { + return layout.placement === FilePreviewPlacement.WideTriplePane ? layout.masterPaneWidth : fallback; + } + + static detailOffset(collapsed: boolean, expandedOffset: number, collapsedOffset: number): number { + return collapsed ? collapsedOffset : expandedOffset; + } + + static detailWidth(collapsed: boolean, expandedWidth: number, collapsedWidth: number): number { + return collapsed ? collapsedWidth : expandedWidth; + } + + static collapsedVisualBias( + collapsed: boolean, + collapsedOffset: number, + collapsedWidth: number, + maxContentWidth: number, + maximumBias: number + ): number { + if (!collapsed || collapsedOffset > 0) { + return 0; + } + const availableMargin = (collapsedWidth - maxContentWidth) / 2; + return Math.min(maximumBias, Math.max(0, availableMargin)); + } + + static areaLength(value: Object): number { + const parsed = Number.parseFloat(`${value}`); + return Number.isNaN(parsed) ? 0 : parsed; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets similarity index 78% rename from src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets index 417e97869e..58ca1bf03e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets @@ -1,8 +1,8 @@ -import { SelectedImageAttachment } from '../model/RemoteModels'; -import { AppRoute, AppRouteContract } from '../pages/navigation/AppRouteContract'; -import { GeneralChatPageState } from '../pages/state/GeneralChatPageState'; -import { RemotePageState } from '../pages/state/RemotePageState'; -import { VoiceInputRouteSnapshot } from './VoiceInputLifecycleController'; +import { SelectedImageAttachment } from '../../model/RemoteModels'; +import { VoiceInputRouteSnapshot } from '../../services/VoiceInputLifecycleController'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppRoute, AppRouteContract } from './AppRouteContract'; /** Keeps route-dependent composer state mapping out of the root component. */ export class AppRootRouteState { @@ -11,16 +11,19 @@ export class AppRootRouteState { } static chatInput(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): string { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.chatInput : remote.chatInput; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.chatInput : remote.conversation.chatInput; } static selectedImages(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): SelectedImageAttachment[] { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.selectedImages : remote.selectedImages; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.selectedImages : remote.conversation.selectedImages; } static voiceListening(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): boolean { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.isVoiceListening : remote.isVoiceListening; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.isVoiceListening : remote.conversation.isVoiceListening; } static setChatInput(route: AppRoute, value: string, general: GeneralChatPageState, remote: RemotePageState): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets index 0ca41eeb88..32a712b546 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets @@ -85,6 +85,10 @@ export class AppRouteContract { return new ChatRouteParam(sessionId); } + static remoteSessionDestination(sessionId: string): AppNavigationPathSpec { + return new AppNavigationPathSpec(AppRoute.RemoteChat, sessionId); + } + static pathSpec(currentRoute: AppRoute, route: AppRoute, sessionId: string = ''): AppNavigationPathSpec | undefined { if (currentRoute === route) { return undefined; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionActionPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionActionPolicy.ets 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 new file mode 100644 index 0000000000..7d34cede75 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -0,0 +1,390 @@ +import { RemoteSession } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { AppRootHostPort } from '../host/AppRootHostAdapter'; +import { + AppNavigationBackAction, + AppRoute, + AppRouteContract, + ConversationSource +} from '../navigation/AppRouteContract'; +import { + AppRootRuntimeComposition, + ConnectionState +} from './AppRootRuntimeComposition'; + +export class AppRootRuntime extends AppRootRuntimeComposition { + constructor(host: AppRootHostPort) { + super(host); + } + + async aboutToAppear(): Promise { + this.syncRemotePageSummary(); + await this.generalChatBootstrapController.restore(this.host.context()); + await this.settingsController.initializeCloudAccount(this.host.context()); + await this.settingsController.refreshModelCatalog(); + await this.restoreIdentity(); + } + + onPageShow(): void { + RemoteLogger.info(`page show state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`); + this.remoteActivityViewModel.resume(); + } + + onPageHide(): void { + RemoteLogger.info(`page hide state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`); + this.remoteActivityViewModel.invalidate(); + this.remoteConnectionCoordinator.invalidate(); + this.remotePageState.setBusy(false); + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + } + + aboutToDisappear(): void { + this.remoteActivityViewModel.invalidate(); + this.remoteConnectionCoordinator.invalidate(); + this.remotePageState.setBusy(false); + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + this.generalChatConversationViewModel.stop(true, 'failed'); + this.generalChatDraftLifecycleController.cancel(); + this.remoteFileDownloadController.cancel(); + this.filePreviewController.close(); + this.voiceInputLifecycleController.cancel(`${this.appShellViewModel.currentRoute()}`, () => { + this.conversationController.clearAllVoiceListening(); + }); + } + + isRemoteConversationContext(sessionId: string): boolean { + if (sessionId.length === 0 || this.remotePageState.activeSession.sessionId !== sessionId) { + return false; + } + return this.appShellViewModel.isRoute(AppRoute.RemoteChat) || this.appShellViewModel.isRoute(AppRoute.RemoteHome); + } + + handleNavigationBack(route: AppRoute): boolean { + if (this.filePreviewState.visible) { + this.filePreviewController.close(); + return true; + } + const action = this.appShellViewModel.backAction(route); + if (action === AppNavigationBackAction.CloseSidebar) { + this.closeAppSidebar(); + return true; + } + if (action === AppNavigationBackAction.CloseActiveChat) { + this.exitActiveChat(); + return true; + } + if (action === AppNavigationBackAction.PopRemoteHome) { + this.appShellViewModel.popRoute(AppRoute.ChatHome); + return true; + } + return false; + } + + handleRootBack(): boolean { + if (!this.filePreviewState.visible) { + return false; + } + this.filePreviewController.close(); + return true; + } + + + async restoreIdentity(): Promise { + if (this.remotePageState.controlTargetType === 'account_device') { + return; + } + await this.remoteConnectionController.restore(this.host.context()); + } + + async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { + await this.remoteConnectionController.connect(autoReconnect, accountPassword); + await this.settingsController.persistDelegatedAccountSession(); + } + + async reconnect(): Promise { + if (this.remotePageState.controlTargetType === 'account_device') { + await this.settingsController.restoreCloudTarget( + this.remotePageState.controlTargetDeviceId, + this.remotePageState.controlTargetDeviceName + ); + return; + } + await this.remoteConnectionController.reconnect(); + } + + async disconnect(clearPairing: boolean): Promise { + this.filePreviewController.invalidate(); + await this.remoteConnectionController.disconnect(clearPairing); + } + + syncRemotePageSummary(): void { + if (this.remotePageState.statusText.length === 0) { + this.remotePageState.setStatusText(RemoteI18n.t('status.waitingConnection')); + } + if (this.remotePageState.workspaceName.length === 0) { + this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + } + } + + failRemoteConnection(err: Object): void { + this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(err)); + this.remotePageState.setConnectionState(ConnectionState.Failed); + this.remoteActivityViewModel.stopHeartbeat(); + } + + async selectWorkspace(path: string): Promise { + this.filePreviewController.close(); + await this.remoteWorkspaceViewModel.selectWorkspace(path); + } + + async selectAssistant(path: string): Promise { + this.filePreviewController.close(); + await this.remoteWorkspaceViewModel.selectAssistant(path); + } + + openAppSidebar(): void { + this.host.animate(230, () => { + this.appShellState.setSidebarVisible(true); + }); + } + + closeAppSidebar(): void { + this.host.animate(210, () => { + this.appShellState.setSidebarVisible(false); + }); + } + + 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; + } + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + if (source === ConversationSource.General) { + this.remoteChatPollingLifecycleController.stop(); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); + return; + } + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + const activeRemoteSessionId = this.remotePageState.isConversationDismissed ? '' : + (this.remotePageState.activeSession.sessionId || ''); + const target = AppRouteContract.routeForConversationSource( + source, + RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)), + activeRemoteSessionId + ); + this.appShellViewModel.replaceRouteWithoutAnimation( + target.name, + target.hasSessionParam() ? target.routeParam().sessionId : '' + ); + if (target.name === AppRoute.RemoteChat) { + this.conversationController.startRemotePolling(); + await this.conversationController.loadRemoteMessages(); + } + } + + /** + * Compact counterpart of switchWideConversationSource. Switching source is a + * change of context, not a command to start something: it resumes the session + * the user was last in, and otherwise rests on the Remote landing surface + * rather than opening the create composer for them. + */ + async switchCompactConversationSource(source: ConversationSource): Promise { + this.closeAppSidebar(); + if (AppRouteContract.conversationSource(this.appShellViewModel.currentRoute()) === source) { + return; + } + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + if (source === ConversationSource.General) { + this.remoteChatPollingLifecycleController.stop(); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); + return; + } + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + const activeRemoteSessionId = RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)) && + !this.remotePageState.isConversationDismissed ? + (this.remotePageState.activeSession.sessionId || '') : ''; + if (activeRemoteSessionId.length === 0) { + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + return; + } + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteChat, activeRemoteSessionId); + this.conversationController.startRemotePolling(); + await this.conversationController.loadRemoteMessages(); + } + + enterCompactLayout(): void { + const sessionId = this.remotePageState.activeSession.sessionId || ''; + if (this.appShellViewModel.isRoute(AppRoute.RemoteHome) && + !this.remotePageState.isConversationDismissed && sessionId.length > 0) { + this.appShellViewModel.pushRoute(AppRoute.RemoteChat, sessionId, false); + } + } + + /** + * Exit control for an open conversation. Leaving a remote conversation on a + * compact layout lands on Remote Home with no visible session list, so reveal + * the drawer that owns navigation there. Compact chats have no back button of + * their own, so this runs for the system back gesture. + */ + exitActiveChat(): void { + const revealSidebar = !this.appShellState.wideLayout && + this.appShellViewModel.isRoute(AppRoute.RemoteChat); + this.conversationController.closeActiveChat(); + if (revealSidebar) { + this.openAppSidebar(); + } + } + + openRemoteControlSettings(): void { + setTimeout(() => { + this.appShellState.openSettings('remote'); + }, 180); + } + + openAddConnectionFromSettings(): void { + this.appShellState.setSettingsVisible(false); + setTimeout(() => { + this.appShellState.setConnectSheetVisible(true); + }, 220); + } + + applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void { + this.remoteWorkspaceSessions = all; + const current = this.remotePageState.sessions; + const extras = all.filter((item: RemoteSession) => item.workspacePath !== this.remotePageState.workspacePath); + this.remotePageState.setSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); + } + + mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[] { + const merged = primary.slice(); + extras.forEach((item: RemoteSession) => { + if (!merged.some((existing: RemoteSession) => existing.id === item.id)) { + merged.push(item); + } + }); + return merged; + } + + async toggleVoiceInput(): Promise { + const route = this.appShellViewModel.currentRoute(); + await this.voiceInputLifecycleController.toggle( + this.host.context(), this.conversationController.voiceInputSnapshot(route) + ); + } + + async stopVoiceInput(showStatus: boolean): Promise { + const route = this.appShellViewModel.currentRoute(); + await this.voiceInputLifecycleController.stop( + this.conversationController.voiceInputSnapshot(route), showStatus + ); + } + + showVoiceInputError(message: string): void { + const text = message.length > 0 ? message : RemoteI18n.t('errors.voiceInputUnavailable'); + this.conversationController.setVisibleStatusText(text); + this.host.showToast(text, 2600); + } + + async pickImages(): Promise { + if (this.conversationController.visibleBusy()) { + return; + } + const route = this.appShellViewModel.currentRoute(); + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + try { + this.conversationController.setVisibleStatusText(RemoteI18n.t('status.pickImage')); + const picked = await this.imagePickerService.pickImages( + 3, + this.conversationController.visibleSelectedImages().length + ); + if (picked.length === 0) { + this.conversationController.setVisibleStatusText(RemoteI18n.t('status.noImageSelected')); + return; + } + this.conversationController.addSelectedImages(route, picked); + this.conversationController.setVisibleStatusText(RemoteI18n.f( + 'status.imagesSelected', + `${this.conversationController.visibleSelectedImages().length}` + )); + } catch (err) { + this.conversationController.setVisibleStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + currentActiveTurnId(): string { + if (!this.appShellViewModel.isGeneralChatVisible()) { + return this.conversationController.remoteActiveTurnId(); + } + const activeTurnMessage = this.generalChatPageState.activeTurnMessage; + if (activeTurnMessage.turnId && activeTurnMessage.turnId.length > 0) { + return activeTurnMessage.turnId; + } + const activePrefix = 'active-'; + if (activeTurnMessage.id.indexOf(activePrefix) === 0) { + return activeTurnMessage.id.slice(activePrefix.length); + } + return ''; + } + + hasRemoteBindingForResume(): boolean { + if (this.remotePageState.controlTargetType === 'account_device') { + return this.remotePageState.accountUserId.trim().length > 0 && + this.remotePageState.controlTargetDeviceId.trim().length > 0 && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Idle && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Disconnected; + } + return this.remotePageState.remoteUrl.trim().length > 0 && + this.remotePageState.userId.trim().length > 0 && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Idle && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Parsing && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Pairing && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Disconnected; + } + + async reconnectActiveRemote(): Promise { + if (this.remotePageState.controlTargetType !== 'account_device') { + await this.connect(true); + return; + } + const targetId = this.remotePageState.controlTargetDeviceId; + const device = (await this.settingsController.listCloudAccountDevices()) + .find((item: CloudAccountDevice): boolean => item.deviceId === targetId); + if (!device) { + throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); + } + await this.settingsController.selectCloudAccountDevice(device); + } + + hasRemoteBindingForCodeHome(): boolean { + return this.remotePageState.remoteUrl.trim().length > 0 && + this.remotePageState.userId.trim().length > 0 && + ((this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Reconnecting || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Pairing || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Parsing); + } + +} 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 new file mode 100644 index 0000000000..c2d87b233f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -0,0 +1,803 @@ +import { + ChatMessage, + RemoteModelCatalog, + RemotePermissionMode, + RemoteQuestionAnswerPayload, + RemoteSession, + SelectedImageAttachment, + SessionSummary, + WorkspaceInfo +} from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ClipboardService } from '../../services/ClipboardService'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { ImagePickerService } from '../../services/ImagePickerService'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore +} from '../../services/general-chat/GeneralChatConfigStore'; +import { GeneralChatBootstrapController } from '../../services/general-chat/GeneralChatBootstrapController'; +import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; +import { GeneralChatController } from '../../services/general-chat/GeneralChatController'; +import { GeneralChatDraftController } from '../../services/general-chat/GeneralChatDraftController'; +import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; +import { + GeneralChatSendResult, + GeneralChatStreamCallbacks +} from '../../services/general-chat/GeneralChatPort'; +import { MobileIdentityStore } from '../../services/MobileIdentityStore'; +import { CloudAccountClient, CloudAccountDevice } from '../../services/CloudAccountClient'; +import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; +import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; +import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { RemoteChatPollingLifecycleController, RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; +import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { FilePreviewController } from '../viewmodel/FilePreviewController'; +import { SettingsController } from '../viewmodel/SettingsController'; +import { ConversationController } from '../viewmodel/ConversationController'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; +import { RemoteSessionController } from '../../services/RemoteSessionController'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemoteWorkspaceRepository } from '../../services/RemoteWorkspaceRepository'; +import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; +import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; +import { RemoteToolActionController } from '../../services/RemoteToolActionController'; +import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; +import { QrScanService } from '../../services/QrScanService'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { VoiceInputLifecycleController } from '../../services/VoiceInputLifecycleController'; +import { VoiceInputService } from '../../services/VoiceInputService'; +import { ConversationIntent } from '../actions/ConversationIntent'; +import { AppRootHostPort } from '../host/AppRootHostAdapter'; +import { AppRootPresentationActions } from '../actions/AppRootPresentationActions'; +import { + AppNavigationBackAction, + AppRoute, + AppRouteContract, + ConversationSource +} from '../navigation/AppRouteContract'; +import { AppShellState } from '../state/AppShellState'; +import { AppShellViewModel } from '../viewmodel/AppShellViewModel'; +import { RemoteActivityViewModel } from '../viewmodel/RemoteActivityViewModel'; +import { + RemoteConnectionController +} from '../viewmodel/RemoteConnectionController'; +import { ConversationIntentDispatcher } from '../actions/ConversationIntentDispatcher'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { ConversationViewModel } from '../viewmodel/ConversationViewModel'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; +import { RemoteWorkspaceViewModel } from '../viewmodel/RemoteWorkspaceViewModel'; +import { RemoteSessionViewModel } from '../viewmodel/RemoteSessionViewModel'; +import { GeneralChatConversationViewModel } from '../viewmodel/GeneralChatConversationViewModel'; +import { ModelProviderGeneralChatAdapter } from '../../services/general-chat/ModelProviderGeneralChatAdapter'; + +export enum ConnectionState { + Idle = 'idle', + Parsing = 'parsing', + Pairing = 'pairing', + Connected = 'connected', + Reconnecting = 'reconnecting', + Failed = 'failed', + Disconnected = 'disconnected' +} + +const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; +const GENERAL_CHAT_DRAFT_SAVE_DELAY_MS: number = 250; + +export abstract class AppRootRuntimeComposition { + readonly host: AppRootHostPort; + + constructor(host: AppRootHostPort) { + this.host = host; + } + + abstract applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void; + abstract closeAppSidebar(): void; + 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; + abstract handleNavigationBack(route: AppRoute): boolean; + abstract hasRemoteBindingForResume(): boolean; + abstract isRemoteConversationContext(sessionId: string): boolean; + abstract mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[]; + abstract openAddConnectionFromSettings(): void; + abstract openAppSidebar(): void; + abstract openRemoteControlSettings(): void; + abstract pickImages(): Promise; + abstract reconnect(): Promise; + abstract reconnectActiveRemote(): Promise; + abstract selectAssistant(path: string): Promise; + abstract selectWorkspace(path: string): Promise; + abstract showVoiceInputError(message: string): void; + abstract stopVoiceInput(showStatus: boolean): Promise; + abstract switchCompactConversationSource(source: ConversationSource): Promise; + abstract switchWideConversationSource(source: ConversationSource): Promise; + abstract toggleVoiceInput(): Promise; + + readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); + readonly workspaceRepository: RemoteWorkspaceRepository = + new RemoteWorkspaceRepository(this.sessionManager); + readonly workspaceCoordinator: RemoteWorkspaceCoordinator = + new RemoteWorkspaceCoordinator(this.workspaceRepository); + readonly remoteResumeGate: AsyncLifecycleGate = new AsyncLifecycleGate(); + readonly remoteConnectionGate: AsyncLifecycleGate = new AsyncLifecycleGate(); + readonly filePreviewState: FilePreviewState = new FilePreviewState(); + readonly identityStore: MobileIdentityStore = new MobileIdentityStore(); + readonly clipboardService: ClipboardService = new ClipboardService(); + readonly qrScanService: QrScanService = new QrScanService(); + readonly imagePickerService: ImagePickerService = new ImagePickerService(); + readonly remotePairingPolicy: RemotePairingPolicy = new RemotePairingPolicy(); + readonly remoteConnectionCoordinator: RemoteConnectionCoordinator = + new RemoteConnectionCoordinator( + this.sessionManager, + this.identityStore, + this.remotePairingPolicy, + this.remoteConnectionGate + ); + readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); + readonly remotePageState: RemotePageState = new RemotePageState(); + readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); + readonly generalChatController: GeneralChatController = + GeneralChatController.createDefault(this.generalChatConfigStore); + readonly generalChatDraftController: GeneralChatDraftController = + new GeneralChatDraftController( + this.generalChatController, + GENERAL_CHAT_DRAFT_SAVE_DELAY_MS, + (err: Error) => { + RemoteLogger.warn(`general chat draft operation failed: ${ConnectionErrorPolicy.errorText(err)}`); + } + ); + readonly generalChatDraftLifecycleController: GeneralChatDraftLifecycleController = + new GeneralChatDraftLifecycleController( + this.generalChatDraftController, + GENERAL_CHAT_HOME_DRAFT_ID, + (): string => this.conversationController.visibleGeneralChatDraftId() + ); + readonly chatTimelineStore: ConversationViewModel = new ConversationViewModel(); + readonly generalChatCommandController: GeneralChatCommandController = + new GeneralChatCommandController( + this.generalChatController, + { + onSessions: (sessions: RemoteSession[]) => { + this.generalChatPageState.setSessions(sessions); + }, + onSessionPrepared: (sessionId: string) => { + this.conversationController.resetGeneralTimeline(sessionId); + this.remoteModelController.clearCatalog(); + }, + onActiveSession: (session: SessionSummary) => { + this.generalChatPageState.setActiveSession(session); + }, + onMessagesLoaded: (messages: ChatMessage[]) => { + this.chatTimelineStore.setPersistedMessages(messages); + this.conversationController.syncGeneralTimeline(); + }, + onClearComposer: () => { + this.generalChatPageState.clearComposer(); + }, + onChatInput: (text: string) => { + this.generalChatPageState.setChatInput(text); + }, + onStatusText: (statusText: string) => { + this.generalChatPageState.setStatus(statusText); + }, + onBusy: (isBusy: boolean) => { + this.generalChatPageState.setBusy(isBusy); + }, + onToast: (statusText: string) => { + this.conversationController.showHomeToast(statusText); + } + } + ); + readonly generalChatBootstrapController: GeneralChatBootstrapController = + new GeneralChatBootstrapController( + this.generalChatConfigStore, + this.generalChatCommandController, + this.generalChatDraftLifecycleController, + { + onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { + this.settingsController.apply(snapshot); + }, + onHomeDraftRestored: (text: string) => { + this.generalChatPageState.setChatInput(text); + }, + onStatusText: (statusText: string) => { + this.generalChatPageState.setStatus(statusText); + } + } + ); + readonly voiceInputService: VoiceInputService = new VoiceInputService(); + readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = + new RemoteActivityLifecycleController(() => { + this.remoteActivityViewModel.checkConnectionHealth(); + }); + readonly remoteActivityViewModel: RemoteActivityViewModel = + new RemoteActivityViewModel( + this.remoteActivityLifecycleController, + this.remoteConnectionCoordinator, + this.remoteResumeGate, + { + isConnected: (): boolean => (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected, + isBusy: (): boolean => this.remotePageState.isBusy, + hasRemoteBinding: (): boolean => this.hasRemoteBindingForResume(), + isRemoteChat: (): boolean => this.appShellViewModel.isRoute(AppRoute.RemoteChat), + activeSession: (): SessionSummary => this.remotePageState.activeSession, + onConnectionState: (state: string): void => this.remotePageState.setConnectionState(state as ConnectionState), + onStatus: (status: string): void => this.remotePageState.setStatusText(status), + onConnectionError: async (err: Object): Promise => this.settingsController.handleRemoteConnectionError(err), + onStopHeartbeat: (): void => this.remoteActivityViewModel.stopHeartbeat(), + onStartPolling: (): void => this.conversationController.startRemotePolling(), + onStopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + onPoll: async (): Promise => { + await this.remoteChatPollingLifecycleController.pollNow(); + }, + onReconnect: async (): Promise => { + await this.reconnectActiveRemote(); + }, + onRestoreSession: async (session: SessionSummary): Promise => { + this.conversationController.applyRemoteActiveSession(session); + await this.conversationController.loadRemoteMessages(); + } + } + ); + readonly generalChatStreamLifecycleController: GeneralChatStreamLifecycleController = + new GeneralChatStreamLifecycleController(); + readonly remoteWorkspaceViewModel: RemoteWorkspaceViewModel = + new RemoteWorkspaceViewModel( + this.remotePageState, + this.workspaceCoordinator, + { + isRemoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + isBusy: (): boolean => this.remotePageState.isBusy, + onBusy: (isBusy: boolean): void => { + this.remotePageState.setBusy(isBusy); + }, + onStatus: (statusText: string): void => { + this.remotePageState.setStatusText(statusText); + }, + onWorkspaceSelected: (workspace: WorkspaceInfo): void => { + this.remoteConnectionController.applyWorkspace(workspace); + this.remoteSessionController.clearSessions(); + }, + onSessionsDiscovered: (sessions: RemoteSession[]): void => { + this.applyDiscoveredWorkspaceSessions(sessions); + }, + onRefreshSessions: async (): Promise => { + await this.remoteSessionViewModel.refreshSessions(); + }, + onConnectionFailure: (error: Object): void => { + this.failRemoteConnection(error); + } + } + ); + remoteWorkspaceSessions: RemoteSession[] = []; + readonly appShellViewModel: AppShellViewModel = new AppShellViewModel(); + readonly appShellState: AppShellState = this.appShellViewModel.state; + readonly voiceInputLifecycleController: VoiceInputLifecycleController = + new VoiceInputLifecycleController( + this.voiceInputService, + { + currentInputText: (): string => this.conversationController.visibleChatInput(), + currentStatusText: (): string => this.conversationController.visibleStatusText(), + onInputText: (routeId: string, text: string) => { + this.conversationController.setChatInput(routeId as AppRoute, text); + }, + onListening: (routeId: string, isListening: boolean) => { + this.conversationController.setVoiceListening(routeId as AppRoute, isListening); + }, + onStatusText: (statusText: string) => { + this.conversationController.setVisibleStatusText(statusText); + }, + onError: (message: string) => { + this.showVoiceInputError(message); + } + } + ); + readonly remoteSessionController: RemoteSessionController = + new RemoteSessionController( + this.sessionManager, + 8, + { + onSessions: (sessions: RemoteSession[], hasMore: boolean) => { + const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession) => { + return item.workspacePath !== this.remotePageState.workspacePath; + }); + this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); + }, + onActiveSession: (session: SessionSummary) => { + this.conversationController.applyRemoteActiveSession(session); + }, + onStatusText: (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + onBusy: (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + onLoading: (isLoading: boolean) => { + this.remotePageState.setLoading(isLoading); + }, + onSessionError: (errorText: string) => { + this.remotePageState.setError(errorText); + }, + onReconnecting: () => { + this.remotePageState.setConnectionState(ConnectionState.Reconnecting); + }, + onConnected: () => { + this.remotePageState.setConnectionState(ConnectionState.Connected); + }, + onConnectionFailed: (err: Object) => { + this.failRemoteConnection(err); + }, + onStartHeartbeat: () => { + this.remoteActivityViewModel.startHeartbeat(); + } + } + ); + readonly remoteChatCommandController: RemoteChatCommandController = + new RemoteChatCommandController( + this.sessionManager, + { + onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { + this.chatTimelineStore.setPersistedMessages(messages); + this.remotePageState.setHasMoreMessages(hasMoreMessages); + this.conversationController.syncRemoteTimeline(); + }, + onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { + this.conversationController.updateKnownMessageCount(pollVersion, knownMessageCount); + }, + onSendSucceeded: (turnId: string, pendingActiveId: string) => { + if (turnId.length > 0) { + this.chatTimelineStore.setLocalActiveTurn(turnId); + this.conversationController.syncRemoteTimeline(); + } else if (pendingActiveId.length > 0) { + this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); + this.conversationController.syncRemoteTimeline(); + } + this.remoteChatPollingLifecycleController.nudge(); + }, + onSendFailed: ( + rawText: string, + images: SelectedImageAttachment[], + localMessageId: string, + pendingActiveId: string + ) => { + this.remotePageState.setChatInput(rawText); + this.remotePageState.setSelectedImages(images); + this.chatTimelineStore.markOptimisticMessageFailed(localMessageId); + if (pendingActiveId.length > 0) { + this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); + } + this.conversationController.syncRemoteTimeline(); + }, + onActiveSession: (session: SessionSummary) => { + this.conversationController.applyRemoteActiveSession(session); + }, + onSessionTitleChanged: (sessionId: string, title: string) => { + this.remoteSessionController.updateSessionTitle(sessionId, title); + }, + onStatusText: (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + onBusy: (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + onPollRequested: () => { + this.remoteChatPollingLifecycleController.pollNow(); + } + } + ); + readonly remoteFileDownloadController: RemoteFileDownloadController = + new RemoteFileDownloadController( + this.sessionManager, + (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { + this.remotePageState.setDownloadStatus(downloadingFilePath, downloadedFilePath, fileDownloadStatus); + }, + () => { + this.remotePageState.clearDownloadingFilePath(); + }, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + } + ); + readonly filePreviewController: FilePreviewController = + new FilePreviewController( + this.sessionManager, + this.filePreviewState, + { + remoteAvailable: (): boolean => RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)), + activeSession: (): SessionSummary => this.remotePageState.conversation.activeSession, + workspacePath: (): string => this.remotePageState.workspacePath, + openExternalLink: async (reference: string): Promise => + this.host.openExternalLink ? await this.host.openExternalLink(reference) : false, + onGeneralStatus: (statusText: string): void => this.generalChatPageState.setStatus(statusText), + onRemoteStatus: (statusText: string): void => this.remotePageState.setStatusText(statusText) + } + ); + readonly remoteToolActionController: RemoteToolActionController = + new RemoteToolActionController( + this.sessionManager, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + () => { + this.remoteChatPollingLifecycleController.pollNow(); + } + ); + readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = + new RemoteChatPollingLifecycleController( + this.sessionManager, + { + canPoll: (sessionId: string) => { + return this.remotePageState.activeSession.sessionId === sessionId && + this.isRemoteConversationContext(sessionId) && + this.remoteConnectionController.ensureAvailable(); + }, + onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { + this.conversationController.applyRemoteSnapshot(snapshot); + }, + onError: (error: Object) => { + this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(error)); + } + } + ); + readonly remoteModelController: RemoteModelController = + new RemoteModelController( + this.sessionManager, + this.identityStore, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + this.conversationController.updateKnownModelCatalogVersion(knownModelCatalogVersion); + this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); + }, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + this.conversationController.updateKnownModelCatalogVersion(knownModelCatalogVersion); + this.chatTimelineStore.setModelCatalog(modelCatalog, selectedModelId); + this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); + }, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + } + ); + readonly remoteSessionViewModel: RemoteSessionViewModel = + new RemoteSessionViewModel( + this.remotePageState, + this.remoteSessionController, + this.remoteChatCommandController, + this.remoteModelController, + this.remoteFileDownloadController, + { + remoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + isConnected: (): boolean => (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected, + isBusy: (): boolean => this.remotePageState.isBusy, + onBusy: (busy: boolean): void => this.remotePageState.setBusy(busy), + onRouteChat: (sessionId: string): void => this.conversationController.routeCreatedRemoteSession(sessionId), + onRouteHome: (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), + onStopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + onStartPolling: (): void => this.conversationController.startRemotePolling(), + onResetTimeline: (sessionId: string): void => this.conversationController.resetRemoteTimeline(sessionId), + onClearRemoteFiles: (): void => this.remoteFileDownloadController.clear(), + onKnownStateReset: (): void => this.conversationController.resetKnownRemoteState(), + onLoadModelCatalog: async (sessionId: string): Promise => { + await this.conversationController.loadRemoteModelCatalog(sessionId); + }, + onLoadActiveMessages: async (): Promise => { + await this.conversationController.loadRemoteMessages(); + }, + onRefreshSessions: async (): Promise => { + await this.remoteSessionController.refresh( + this.remotePageState.sessionQuery, + this.remotePageState.sessionFilter, + this.remoteConnectionController.ensureAvailable(), + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected + ); + }, + onSelectWorkspace: async (path: string): Promise => { + await this.selectWorkspace(path); + } + } + ); + readonly generalChatConversationViewModel: GeneralChatConversationViewModel = + new GeneralChatConversationViewModel( + this.generalChatPageState, + this.generalChatCommandController, + this.generalChatDraftLifecycleController, + this.generalChatStreamLifecycleController, + this.chatTimelineStore, + { + isVisible: (sessionId: string): boolean => this.generalChatPageState.activeSession.sessionId === sessionId && + this.appShellViewModel.isGeneralChatVisible(), + currentActiveTurnId: (): string => this.currentActiveTurnId(), + latestUserMessageText: (): string => this.conversationController.latestUserMessageText(), + syncTimeline: (): void => this.conversationController.syncGeneralTimeline(), + refreshSessions: (): void => this.generalChatCommandController.refreshSessions() + } + ); + readonly remoteConnectionController: RemoteConnectionController = + new RemoteConnectionController( + this.remotePageState, + this.identityStore, + this.remotePairingPolicy, + this.remoteConnectionCoordinator, + this.remoteSessionController, + this.remoteModelController, + this.remoteFileDownloadController, + this.clipboardService, + this.qrScanService, + (sessionId: string): void => this.conversationController.resetRemoteTimeline(sessionId), + (): void => this.conversationController.resetKnownRemoteState(), + (): void => this.remoteActivityViewModel.startHeartbeat(), + (): void => this.remoteActivityViewModel.stopHeartbeat(), + (): void => this.remoteChatPollingLifecycleController.stop(), + async (): Promise => { + await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + }, + (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), + (): void => this.appShellState.setConnectSheetVisible(false), + (): void => this.appShellState.setConnectSheetVisible(true) + ); + readonly settingsController: SettingsController = + new SettingsController( + this.generalChatConfigStore, + this.generalChatPageState, + { + probeConfiguration: async (apiUrl: string, apiKey: string, modelName: string): Promise => { + await ModelProviderGeneralChatAdapter.probeConfiguration(apiUrl, apiKey, modelName); + } + }, + { + client: new CloudAccountClient(), + sessionStore: new CloudAccountSessionStore(), + sessionManager: this.sessionManager, + remoteState: this.remotePageState, + hooks: { + deviceId: (): string => this.remoteConnectionController.getDeviceId(), + remoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + invalidatePreview: (): void => this.filePreviewController.invalidate(), + invalidateRemoteActivity: (): void => this.remoteActivityViewModel.invalidate(), + invalidateRemoteConnection: (): void => this.remoteConnectionCoordinator.invalidate(), + stopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + stopHeartbeat: (): void => this.remoteActivityViewModel.stopHeartbeat(), + startHeartbeat: (): void => this.remoteActivityViewModel.startHeartbeat(), + resetTimeline: (): void => this.conversationController.resetRemoteTimeline(''), + resetKnownRemoteState: (): void => this.conversationController.resetKnownRemoteState(), + closeSettings: (): void => this.appShellState.setSettingsVisible(false), + closeConnectSheet: (): void => this.appShellState.setConnectSheetVisible(false), + navigateRemoteHome: (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), + loadRecentWorkspaces: async (): Promise => { + await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + } + } + } + ); + readonly conversationController: ConversationController = + new ConversationController( + this.generalChatPageState, + this.remotePageState, + this.remoteCreateState, + { currentRoute: (): AppRoute => this.appShellViewModel.currentRoute() }, + { + timeline: this.chatTimelineStore, + chat: this.remoteChatCommandController, + polling: this.remoteChatPollingLifecycleController, + models: this.remoteModelController, + files: this.remoteFileDownloadController, + tools: this.remoteToolActionController, + connection: this.remoteConnectionController, + imagePicker: this.imagePickerService, + clipboard: this.clipboardService, + sessions: this.remoteSessionViewModel, + sessionManager: this.sessionManager, + workspace: this.workspaceCoordinator, + settings: this.settingsController, + appShell: this.appShellViewModel, + filePreview: this.filePreviewController, + generalCommands: this.generalChatCommandController, + generalConversation: this.generalChatConversationViewModel, + generalDrafts: this.generalChatDraftLifecycleController, + hooks: { + isConversationContext: (sessionId: string): boolean => this.isRemoteConversationContext(sessionId), + isFilePreviewVisible: (): boolean => this.filePreviewState.visible, + stopVoiceInput: async (): Promise => this.stopVoiceInput(false), + showToast: (message: string): boolean => this.host.showToast(message, 2600), + selectAssistantWorkspace: async (path: string): Promise => { + await this.selectAssistant(path); + } + } + } + ); + readonly conversationIntentDispatcher: ConversationIntentDispatcher = + new ConversationIntentDispatcher({ + openSidebar: (): void => this.openAppSidebar(), + back: (): void => this.exitActiveChat(), + newRemoteSession: (): void => { this.conversationController.createRemoteSession('code'); }, + newGeneralSession: (): void => this.conversationController.prepareNewGeneralChat(), + activeGeneralSession: (): RemoteSession => this.conversationController.activeGeneralChatAsRemoteSession(), + activeGeneralSessionId: (): string => this.generalChatPageState.activeSession.sessionId, + isGeneralBusy: (): boolean => this.generalChatPageState.isBusy, + isPinned: (sessionId: string): boolean => this.generalChatPageState.pinnedSessionId() === sessionId, + pin: async (session: RemoteSession, pinned: boolean, busy: boolean): Promise => { + await this.generalChatCommandController.pinSession(session, pinned, busy); + }, + archive: async (session: RemoteSession): Promise => { + await this.conversationController.archiveHomeSession(session, true); + }, + delete: async (session: RemoteSession): Promise => { + await this.conversationController.deleteHomeSession(session); + this.conversationController.prepareNewGeneralChat(); + }, + showToast: (text: string): void => this.conversationController.showHomeToast(text), + uploadedFileCount: (): number => this.conversationController.activeGeneralUploadedFileCount(), + stop: async (): Promise => { await this.conversationController.stopVisibleTask(); }, + loadOlder: async (): Promise => { await this.conversationController.loadOlderRemoteMessages(); }, + approve: async (id: string, input?: Object): Promise => { + await this.conversationController.approveRemoteTool(id, input); + }, + reject: async (id: string): Promise => { await this.conversationController.rejectRemoteTool(id); }, + cancel: async (id: string): Promise => { await this.conversationController.cancelRemoteTool(id); }, + answer: async (id: string, answers: RemoteQuestionAnswerPayload): Promise => { + await this.conversationController.answerRemoteQuestion(id, answers); + }, + rename: async (title: string): Promise => { + await this.conversationController.renameVisibleSession(title); + }, + copy: async (text: string): Promise => { await this.conversationController.copyRemoteMessage(text); }, + retry: async (text: string): Promise => { await this.conversationController.retryVisibleMessage(text); }, + selectModel: async (id: string): Promise => { await this.conversationController.selectVisibleModel(id); }, + pickImages: async (): Promise => { await this.pickImages(); }, + removeImage: (id: string): void => this.conversationController.removeSelectedImage(this.appShellViewModel.currentRoute(), id), + openFilePreview: (route: AppRoute, request: FilePreviewRequest): void => + this.filePreviewController.open(route, request), + downloadFile: (path: string): void => this.conversationController.downloadVisibleFile(path), + send: async (): Promise => { await this.conversationController.sendVisibleMessage(); }, + voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, + inputChanged: (route: AppRoute, value: string): void => + this.conversationController.onVisibleChatInputChange(route, value) + }); + readonly presentationActions: AppRootPresentationActions = { + onNavigationBack: (route: AppRoute): boolean => this.handleNavigationBack(route), + onConversationIntent: (route: AppRoute, intent: ConversationIntent): void => + this.conversationIntentDispatcher.dispatch(route, intent), + onCloseSidebar: (): void => this.closeAppSidebar(), + onWideConversationSource: (source: ConversationSource): void => { + this.switchWideConversationSource(source); + }, + onCompactConversationSource: (source: ConversationSource): void => { + this.switchCompactConversationSource(source); + }, + onCompactLayoutEntered: (): void => this.enterCompactLayout(), + onLayoutModeChanged: (wideLayout: boolean): void => this.appShellState.setWideLayout(wideLayout), + onRemoteHome: { + openSidebar: (): void => this.openAppSidebar(), + connectWorkspace: (): void => this.enterCodeEntry(), + addConnection: (): void => this.appShellState.setConnectSheetVisible(true), + openSettings: (): void => this.openRemoteControlSettings(), + refresh: (): void => { this.remoteSessionViewModel.refreshSessions(); }, + showWorkspaces: (): void => { this.remoteWorkspaceViewModel.toggleRecentWorkspaces(); }, + showAssistants: (): void => { this.remoteWorkspaceViewModel.toggleAssistants(); }, + selectWorkspace: (path: string): void => { this.selectWorkspace(path); }, + selectAssistant: (path: string): void => { this.selectAssistant(path); }, + cancelWorkspace: (): void => this.remotePageState.setWorkspacePickerVisible(false), + cancelAssistant: (): void => this.remotePageState.setAssistantPickerVisible(false), + queryChanged: (query: string): void => this.remotePageState.setQuery(query), + search: (): void => { this.remoteSessionViewModel.refreshSessions(); }, + loadMore: (): void => { this.remoteSessionViewModel.loadMoreSessions(); }, + reconnect: (): void => { this.reconnect(); }, + disconnect: (): void => { this.disconnect(false); }, + clearPairing: (): void => { this.disconnect(true); }, + create: (agentType: string): void => { this.conversationController.createRemoteSession(agentType); }, + createInPlace: (agentType: string): void => { + this.conversationController.createRemoteSession(agentType, true); + }, + createAssistant: (): void => { this.conversationController.openRemoteCreateSession(); }, + createInWorkspace: (path: string, agentType: string): void => { + this.conversationController.createRemoteSessionInWorkspace(path, agentType); + }, + createInWorkspaceInPlace: (path: string, agentType: string): void => { + this.conversationController.createRemoteSessionInWorkspace(path, agentType, true); + }, + openSession: (session: RemoteSession): void => this.conversationController.openHomeSession(session), + openSessionInPlace: (session: RemoteSession): void => this.conversationController.openHomeSession(session, true), + deleteSession: (session: RemoteSession): void => { this.conversationController.deleteHomeSession(session); } + }, + onRemoteCreate: { + back: (): void => this.conversationController.closeRemoteCreateSession(), + toggleDevices: (): void => { this.conversationController.toggleRemoteCreateDevices(); }, + toggleWorkspaces: (): void => { this.conversationController.toggleRemoteCreateWorkspaces(); }, + selectDevice: (device: CloudAccountDevice): void => { + this.conversationController.selectRemoteCreateDevice(device); + }, + selectWorkspace: (path: string): void => this.conversationController.selectRemoteCreateWorkspace(path), + draftChanged: (value: string): void => this.remoteCreateState.setDraft(value), + voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, + selectModel: (modelId: string): void => this.remoteCreateState.setSelectedModelId(modelId), + send: (): void => { this.conversationController.submitRemoteCreateSession(); } + }, + onSidebar: { + close: (): void => this.closeAppSidebar(), + newChat: (): void => { this.closeAppSidebar(); this.conversationController.prepareNewGeneralChat(); }, + enterCode: (): void => { this.closeAppSidebar(); this.enterCodeEntry(); }, + settings: (): void => { this.closeAppSidebar(); this.appShellState.openSettings('general'); }, + openAccount: (): void => { + this.closeAppSidebar(); + setTimeout(() => this.appShellState.openSettings('account'), 180); + }, + openSession: (session: RemoteSession): void => { + this.closeAppSidebar(); + this.conversationController.openHomeSession(session); + }, + archive: (session: RemoteSession, archived: boolean): void => { + this.conversationController.archiveHomeSession(session, archived); + }, + exportSession: (session: RemoteSession): void => { this.conversationController.exportHomeSession(session); }, + deleteSession: (session: RemoteSession): void => { this.conversationController.deleteHomeSession(session); } + }, + onSettings: { + close: (): void => this.appShellState.leaveSettings(), + addConnection: (): void => this.openAddConnectionFromSettings(), + disconnect: (): void => { this.disconnect(false); }, + reconnect: (): void => { this.reconnect(); }, + openAccount: (): void => { this.appShellState.openSettings('account'); }, + cloudLogin: (relayUrl: string, username: string, password: string): Promise => + this.settingsController.loginCloudAccount(relayUrl, username, password), + cloudSync: (): Promise => this.settingsController.syncCloudAccount(), + cloudLogout: (): Promise => this.settingsController.logoutCloudAccount(), + cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), + getPermissionMode: (): Promise => this.settingsController.getRemotePermissionMode(), + setPermissionMode: (mode: RemotePermissionMode): Promise => + this.settingsController.setRemotePermissionMode(mode), + testGeneral: async (url: string, key: string, model: string, clear: boolean): Promise => + this.settingsController.test(url, key, model, clear), + saveGeneral: async (url: string, key: string, model: string, clear: boolean): Promise => + this.settingsController.save(url, key, model, clear) + }, + 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), + inputVisible: (visible: boolean): void => this.remotePageState.setRemoteUrlInputVisible(visible), + paste: (): void => { this.remoteConnectionController.paste(); }, + scan: (): void => { this.remoteConnectionController.scan(this.host.context()); }, + cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), + cloudSelectDevice: (device: CloudAccountDevice): Promise => + this.settingsController.selectCloudAccountDevice(device) + }, + onFilePreview: { + close: (): void => this.filePreviewController.close(), + refresh: (): void => this.filePreviewController.refresh(), + download: (path: string): void => this.conversationController.downloadVisibleFile(path), + openLink: (reference: string, label: string): void => this.filePreviewController.openLink(reference, label) + }, + generalStatus: (): string => this.conversationController.generalChatHomeStatusText() + }; + readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; + + +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets deleted file mode 100644 index b4b50b623b..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets +++ /dev/null @@ -1,2608 +0,0 @@ -import { - ChatMessage, - RecentWorkspaceEntry, - RemoteModelCatalog, - RemotePermissionMode, - RemoteImageContext, - RemoteQuestionAnswerPayload, - RemoteSession, - SelectedImageAttachment, - SessionSummary, - WorkspaceInfo -} from '../../model/RemoteModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ClipboardService } from '../../services/ClipboardService'; -import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; -import { ChatTimelineState } from '../../services/ChatTimelineStore'; -import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; -import { ImagePickerService } from '../../services/ImagePickerService'; -import { - GeneralChatConfigSnapshot, - GeneralChatConfigStore, - GeneralChatConfigUpdate, - GeneralChatConfigValidator, - GeneralChatModelSelectionPolicy -} from '../../services/general-chat/GeneralChatConfigStore'; -import { GeneralChatBootstrapController } from '../../services/general-chat/GeneralChatBootstrapController'; -import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; -import { GeneralChatController } from '../../services/general-chat/GeneralChatController'; -import { GeneralChatDraftController } from '../../services/general-chat/GeneralChatDraftController'; -import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; -import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/GeneralChatCloudConfigPolicy'; -import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; -import { - GeneralChatServiceState, - GeneralChatServiceStatus -} from '../../services/general-chat/GeneralChatServiceState'; -import { - GeneralChatSendResult, - GeneralChatStreamCallbacks -} from '../../services/general-chat/GeneralChatPort'; -import { MobileIdentityStore } from '../../services/MobileIdentityStore'; -import { CloudAccountClient, CloudAccountDevice, CloudAccountRequestError, CloudAccountSession } from '../../services/CloudAccountClient'; -import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; -import { Encoding } from '../../services/Encoding'; -import { AppRootRouteState } from '../../services/AppRootRouteState'; -import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; -import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; -import { - RemoteChatPollingCursor, - RemoteChatPollingLifecycleController, - RemoteChatPollingSnapshot -} from '../../services/RemoteChatPollingLifecycleController'; -import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; -import { FileReferenceKind, FileTargetResolver } from '../../services/FileTargetResolver'; -import { RemoteFilePreviewController } from '../../services/RemoteFilePreviewController'; -import { RemoteLogger } from '../../services/RemoteLogger'; -import { RemoteModelController } from '../../services/RemoteModelController'; -import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; -import { RemoteSessionController } from '../../services/RemoteSessionController'; -import { RemoteSessionManager } from '../../services/RemoteSessionManager'; -import { RemoteWorkspaceRepository } from '../../services/RemoteWorkspaceRepository'; -import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; -import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; -import { RemoteToolActionController } from '../../services/RemoteToolActionController'; -import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; -import { QrScanService } from '../../services/QrScanService'; -import { RemoteUiState } from '../../services/RemoteUiState'; -import { - VoiceInputLifecycleController, - VoiceInputRouteSnapshot -} from '../../services/VoiceInputLifecycleController'; -import { VoiceInputService } from '../../services/VoiceInputService'; -import { ConversationIntent } from '../components/ConversationIntent'; -import { AppRootHostPort } from '../host/AppRootHostAdapter'; -import { - AppRootPresentation, - AppRootPresentationActions, - ConnectPresentationActions, - FilePreviewPresentationActions, - RemoteCreatePresentationActions, - RemoteHomePresentationActions, - SettingsPresentationActions, - SidebarPresentationActions -} from '../components/AppRootPresentation'; -import { - AppNavigationBackAction, - AppRoute, - AppRouteContract, - ConversationSource -} from '../navigation/AppRouteContract'; -import { AppShellState } from './AppShellState'; -import { AppShellViewModel } from './AppShellViewModel'; -import { - RemoteActivityViewModel, - RemoteActivityViewModelHooks -} from './RemoteActivityViewModel'; -import { - RemoteConnectionViewModel -} from './RemoteConnectionViewModel'; -import { - ConversationIntentDispatcher, - ConversationIntentDispatcherHooks -} from './ConversationIntentDispatcher'; -import { GeneralChatPageState } from './GeneralChatPageState'; -import { RemotePageState } from './RemotePageState'; -import { RemoteCreateSessionState } from './RemoteCreateSessionState'; -import { ConversationViewModel } from './ConversationViewModel'; -import { FilePreviewState } from './FilePreviewState'; -import { FilePreviewRequest, FilePreviewTargetContext } from './FilePreviewTarget'; -import { - RemoteWorkspaceViewModel, - RemoteWorkspaceViewModelHooks -} from './RemoteWorkspaceViewModel'; -import { - RemoteSessionViewModel, - RemoteSessionViewModelHooks -} from './RemoteSessionViewModel'; -import { - GeneralChatConversationViewModel, - GeneralChatConversationViewModelHooks -} from './GeneralChatConversationViewModel'; -import { ModelProviderGeneralChatAdapter } from '../../services/general-chat/ModelProviderGeneralChatAdapter'; - -enum ConnectionState { - Idle = 'idle', - Parsing = 'parsing', - Pairing = 'pairing', - Connected = 'connected', - Reconnecting = 'reconnecting', - Failed = 'failed', - Disconnected = 'disconnected' -} - -const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; -const GENERAL_CHAT_DRAFT_SAVE_DELAY_MS: number = 250; - -export class AppRootRuntime { - readonly host: AppRootHostPort; - - constructor(host: AppRootHostPort) { - this.host = host; - } - - readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); - readonly workspaceRepository: RemoteWorkspaceRepository = - new RemoteWorkspaceRepository(this.sessionManager); - readonly workspaceCoordinator: RemoteWorkspaceCoordinator = - new RemoteWorkspaceCoordinator(this.workspaceRepository); - readonly remoteResumeGate: AsyncLifecycleGate = new AsyncLifecycleGate(); - readonly remoteConnectionGate: AsyncLifecycleGate = new AsyncLifecycleGate(); - readonly filePreviewState: FilePreviewState = new FilePreviewState(); - private controlTargetEpoch: number = 1; - private remoteCreateWorkspaceLoadVersion: number = 0; - readonly identityStore: MobileIdentityStore = new MobileIdentityStore(); - readonly cloudAccountClient: CloudAccountClient = new CloudAccountClient(); - readonly cloudAccountSessionStore: CloudAccountSessionStore = new CloudAccountSessionStore(); - private cloudAccountSession?: CloudAccountSession; - private cloudAccountRelayUrl: string = ''; - readonly clipboardService: ClipboardService = new ClipboardService(); - readonly qrScanService: QrScanService = new QrScanService(); - readonly imagePickerService: ImagePickerService = new ImagePickerService(); - readonly remotePairingPolicy: RemotePairingPolicy = new RemotePairingPolicy(); - readonly remoteConnectionCoordinator: RemoteConnectionCoordinator = - new RemoteConnectionCoordinator( - this.sessionManager, - this.identityStore, - this.remotePairingPolicy, - this.remoteConnectionGate - ); - readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); - readonly generalChatController: GeneralChatController = - GeneralChatController.createDefault(this.generalChatConfigStore); - readonly generalChatDraftController: GeneralChatDraftController = - new GeneralChatDraftController( - this.generalChatController, - GENERAL_CHAT_DRAFT_SAVE_DELAY_MS, - (err: Error) => { - RemoteLogger.warn(`general chat draft operation failed: ${ConnectionErrorPolicy.errorText(err)}`); - } - ); - readonly generalChatDraftLifecycleController: GeneralChatDraftLifecycleController = - new GeneralChatDraftLifecycleController( - this.generalChatDraftController, - GENERAL_CHAT_HOME_DRAFT_ID, - (): string => this.visibleGeneralChatDraftId() - ); - readonly chatTimelineStore: ConversationViewModel = new ConversationViewModel(); - readonly generalChatCommandController: GeneralChatCommandController = - new GeneralChatCommandController( - this.generalChatController, - { - onSessions: (sessions: RemoteSession[]) => { - this.generalChatPageState.setSessions(sessions); - }, - onSessionPrepared: (sessionId: string) => { - this.resetGeneralChatTimeline(sessionId); - this.remoteModelController.clearCatalog(); - }, - onActiveSession: (session: SessionSummary) => { - this.generalChatPageState.setActiveSession(session); - }, - onMessagesLoaded: (messages: ChatMessage[]) => { - this.chatTimelineStore.setPersistedMessages(messages); - this.syncGeneralChatTimelineFromStore(); - }, - onClearComposer: () => { - this.generalChatPageState.clearComposer(); - }, - onChatInput: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - }, - onBusy: (isBusy: boolean) => { - this.generalChatPageState.setBusy(isBusy); - }, - onToast: (statusText: string) => { - this.showHomeToast(statusText); - } - } - ); - readonly generalChatBootstrapController: GeneralChatBootstrapController = - new GeneralChatBootstrapController( - this.generalChatConfigStore, - this.generalChatCommandController, - this.generalChatDraftLifecycleController, - { - onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { - this.applyGeneralChatConfig(snapshot); - }, - onHomeDraftRestored: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - } - } - ); - readonly voiceInputService: VoiceInputService = new VoiceInputService(); - readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = - new RemoteActivityLifecycleController(() => { - this.checkConnectionHealth(); - }); - readonly remoteActivityViewModel: RemoteActivityViewModel = - new RemoteActivityViewModel( - this.remoteActivityLifecycleController, - this.remoteConnectionCoordinator, - this.remoteResumeGate, - new RemoteActivityViewModelHooks( - (): boolean => this.connectionState === ConnectionState.Connected, - (): boolean => this.isBusy, - (): boolean => this.hasRemoteBindingForResume(), - (): boolean => this.isRoute(AppRoute.RemoteChat), - (): SessionSummary => this.activeSession, - (state: string): void => this.setRemoteConnectionState(state as ConnectionState), - (status: string): void => this.setRemoteStatusText(status), - async (err: Object): Promise => this.handleRemoteConnectionError(err), - (): void => this.stopHeartbeat(), - (): void => this.startPolling(), - (): void => this.stopPolling(), - async (): Promise => { - await this.pollActiveSession(); - }, - async (): Promise => { - await this.reconnectActiveRemote(); - }, - async (session: SessionSummary): Promise => { - this.applyRemoteActiveSession(session); - await this.loadActiveMessages(); - } - ) - ); - isSyncingAfterTurn: boolean = false; - knownPollVersion: number = 0; - knownModelCatalogVersion: number = 0; - knownRemoteMessageCount: number = 0; - readonly generalChatStreamLifecycleController: GeneralChatStreamLifecycleController = - new GeneralChatStreamLifecycleController(); - readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); - readonly remotePageState: RemotePageState = new RemotePageState(); - readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); - readonly remoteWorkspaceViewModel: RemoteWorkspaceViewModel = - new RemoteWorkspaceViewModel( - this.remotePageState, - this.workspaceCoordinator, - new RemoteWorkspaceViewModelHooks( - (): boolean => this.ensureRemoteAvailable(), - (): boolean => this.isBusy, - (isBusy: boolean): void => { - this.setRemoteBusy(isBusy); - }, - (statusText: string): void => { - this.setRemoteStatusText(statusText); - }, - (workspace: WorkspaceInfo): void => { - this.applyWorkspace(workspace); - this.remoteSessionController.clearSessions(); - }, - (sessions: RemoteSession[]): void => { - this.applyDiscoveredWorkspaceSessions(sessions); - }, - async (): Promise => { - await this.refreshSessions(); - }, - (error: Object): void => { - this.failRemoteConnection(error); - } - ) - ); - remoteWorkspaceSessions: RemoteSession[] = []; - readonly appShellViewModel: AppShellViewModel = new AppShellViewModel(); - readonly appShellState: AppShellState = this.appShellViewModel.state; - readonly voiceInputLifecycleController: VoiceInputLifecycleController = - new VoiceInputLifecycleController( - this.voiceInputService, - { - currentInputText: (): string => this.visibleChatInput(), - currentStatusText: (): string => this.visibleStatusText(), - onInputText: (routeId: string, text: string) => { - this.setChatInputForRoute(routeId as AppRoute, text); - }, - onListening: (routeId: string, isListening: boolean) => { - this.setVoiceListeningForRoute(routeId as AppRoute, isListening); - }, - onStatusText: (statusText: string) => { - this.setVisibleStatusText(statusText); - }, - onError: (message: string) => { - this.showVoiceInputError(message); - } - } - ); - readonly remoteSessionController: RemoteSessionController = - new RemoteSessionController( - this.sessionManager, - 8, - { - onSessions: (sessions: RemoteSession[], hasMore: boolean) => { - const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession) => { - return item.workspacePath !== this.workspacePath; - }); - this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); - }, - onActiveSession: (session: SessionSummary) => { - this.applyRemoteActiveSession(session); - }, - onStatusText: (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - onBusy: (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - onLoading: (isLoading: boolean) => { - this.remotePageState.setLoading(isLoading); - }, - onSessionError: (errorText: string) => { - this.remotePageState.setError(errorText); - }, - onReconnecting: () => { - this.setRemoteConnectionState(ConnectionState.Reconnecting); - }, - onConnected: () => { - this.setRemoteConnectionState(ConnectionState.Connected); - }, - onConnectionFailed: (err: Object) => { - this.failRemoteConnection(err); - }, - onStartHeartbeat: () => { - this.startHeartbeat(); - } - } - ); - readonly remoteChatCommandController: RemoteChatCommandController = - new RemoteChatCommandController( - this.sessionManager, - { - onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { - this.chatTimelineStore.setPersistedMessages(messages); - this.remotePageState.setHasMoreMessages(hasMoreMessages); - this.syncChatTimelineFromStore(); - }, - onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { - this.knownRemoteMessageCount = knownMessageCount; - this.updateChatPollingCursor(pollVersion, knownMessageCount); - }, - onSendSucceeded: (turnId: string, pendingActiveId: string) => { - if (turnId.length > 0) { - this.chatTimelineStore.setLocalActiveTurn(turnId); - this.syncChatTimelineFromStore(); - } else if (pendingActiveId.length > 0) { - this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); - this.syncChatTimelineFromStore(); - } - this.nudgeChatPolling(); - }, - onSendFailed: ( - rawText: string, - images: SelectedImageAttachment[], - localMessageId: string, - pendingActiveId: string - ) => { - this.remotePageState.setChatInput(rawText); - this.remotePageState.setSelectedImages(images); - this.chatTimelineStore.markOptimisticMessageFailed(localMessageId); - if (pendingActiveId.length > 0) { - this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); - } - this.syncChatTimelineFromStore(); - }, - onActiveSession: (session: SessionSummary) => { - this.applyRemoteActiveSession(session); - }, - onSessionTitleChanged: (sessionId: string, title: string) => { - this.remoteSessionController.updateSessionTitle(sessionId, title); - }, - onStatusText: (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - onBusy: (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - onPollRequested: () => { - this.pollActiveSession(); - } - } - ); - readonly remoteFileDownloadController: RemoteFileDownloadController = - new RemoteFileDownloadController( - this.sessionManager, - (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { - this.remotePageState.setDownloadStatus(downloadingFilePath, downloadedFilePath, fileDownloadStatus); - }, - () => { - this.remotePageState.clearDownloadingFilePath(); - }, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - } - ); - readonly remoteFilePreviewController: RemoteFilePreviewController = - new RemoteFilePreviewController( - this.sessionManager, - this.filePreviewState, - (): boolean => RemoteUiState.canUseRemote(this.connectionState), - (): number => this.controlTargetEpoch - ); - readonly remoteToolActionController: RemoteToolActionController = - new RemoteToolActionController( - this.sessionManager, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - () => { - this.pollActiveSession(); - } - ); - readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = - new RemoteChatPollingLifecycleController( - this.sessionManager, - { - canPoll: (sessionId: string) => { - return this.activeSession.sessionId === sessionId && - this.isRemoteConversationContext(sessionId) && - this.ensureRemoteAvailable(); - }, - onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { - this.applyChatSessionSnapshot(snapshot); - }, - onError: (error: Object) => { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(error)); - } - } - ); - readonly remoteModelController: RemoteModelController = - new RemoteModelController( - this.sessionManager, - this.identityStore, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - this.knownModelCatalogVersion = knownModelCatalogVersion; - this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); - this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); - }, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - this.knownModelCatalogVersion = knownModelCatalogVersion; - this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); - this.chatTimelineStore.setModelCatalog(modelCatalog, selectedModelId); - this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); - }, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - } - ); - readonly remoteSessionViewModel: RemoteSessionViewModel = - new RemoteSessionViewModel( - this.remotePageState, - this.remoteSessionController, - this.remoteChatCommandController, - this.remoteModelController, - this.remoteFileDownloadController, - new RemoteSessionViewModelHooks( - (): boolean => this.ensureRemoteAvailable(), - (): boolean => this.connectionState === ConnectionState.Connected, - (): boolean => this.isBusy, - (busy: boolean): void => this.setRemoteBusy(busy), - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId), - (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), - (): void => this.stopPolling(), - (): void => this.startPolling(), - (sessionId: string): void => this.resetChatTimeline(sessionId), - (): void => this.remoteFileDownloadController.clear(), - (): void => { - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - }, - async (sessionId: string): Promise => { - await this.remoteModelController.loadCatalog( - sessionId, - this.ensureRemoteAvailable(), - (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - } - ); - }, - async (): Promise => { - const sessionId = this.activeSession.sessionId || ''; - await this.remoteChatCommandController.loadMessages( - sessionId, - (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - } - ); - }, - async (): Promise => { - await this.remoteSessionController.refresh( - this.remotePageState.sessionQuery, - this.remotePageState.sessionFilter, - this.ensureRemoteAvailable(), - this.connectionState === ConnectionState.Connected - ); - }, - async (path: string): Promise => { - await this.selectWorkspace(path); - } - ) - ); - readonly generalChatConversationViewModel: GeneralChatConversationViewModel = - new GeneralChatConversationViewModel( - this.generalChatPageState, - this.generalChatCommandController, - this.generalChatDraftLifecycleController, - this.generalChatStreamLifecycleController, - this.chatTimelineStore, - new GeneralChatConversationViewModelHooks( - (sessionId: string): boolean => this.generalChatPageState.activeSession.sessionId === sessionId && - this.isGeneralChatVisible(), - (): string => this.currentActiveTurnId(), - (): string => this.latestUserMessageText(), - (): void => this.syncGeneralChatTimelineFromStore(), - (): void => this.generalChatCommandController.refreshSessions() - ) - ); - readonly remoteConnectionViewModel: RemoteConnectionViewModel = - new RemoteConnectionViewModel( - this.remotePageState, - this.identityStore, - this.remotePairingPolicy, - this.remoteConnectionCoordinator, - this.remoteSessionController, - this.remoteModelController, - this.remoteFileDownloadController, - this.clipboardService, - this.qrScanService, - (sessionId: string): void => this.resetChatTimeline(sessionId), - (): void => { - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - }, - (): void => this.startHeartbeat(), - (): void => this.stopHeartbeat(), - (): void => this.stopPolling(), - async (): Promise => { - await this.loadRecentWorkspacesInBackground(); - }, - (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), - (): void => this.appShellState.setConnectSheetVisible(false), - (): void => this.appShellState.setConnectSheetVisible(true) - ); - readonly conversationIntentDispatcher: ConversationIntentDispatcher = - new ConversationIntentDispatcher(new ConversationIntentDispatcherHooks( - (): void => this.openAppSidebar(), - (): void => this.closeActiveChat(), - (): void => { this.createSession('code'); }, - (): void => this.prepareNewGeneralChat(), - (): RemoteSession => this.activeGeneralChatAsRemoteSession(), - (): string => this.generalChatPageState.activeSession.sessionId, - (): boolean => this.generalChatPageState.isBusy, - (sessionId: string): boolean => this.generalChatPageState.pinnedSessionId() === sessionId, - async (session: RemoteSession, pinned: boolean, busy: boolean): Promise => { - await this.generalChatCommandController.pinSession(session, pinned, busy); - }, - async (session: RemoteSession): Promise => { await this.archiveHomeSession(session, true); }, - async (session: RemoteSession): Promise => { - await this.deleteHomeSession(session); - this.prepareNewGeneralChat(); - }, - (text: string): void => this.showHomeToast(text), - (): number => this.activeGeneralUploadedFileCount(), - async (): Promise => { await this.stopActiveChatTask(); }, - async (): Promise => { await this.loadOlderMessages(); }, - async (id: string, input?: Object): Promise => { await this.approveTool(id, input); }, - async (id: string): Promise => { await this.rejectTool(id); }, - async (id: string): Promise => { await this.cancelTool(id); }, - async (id: string, answers: RemoteQuestionAnswerPayload): Promise => { - await this.answerQuestion(id, answers); - }, - async (title: string): Promise => { await this.renameVisibleSession(title); }, - async (text: string): Promise => { await this.copyMessage(text); }, - async (text: string): Promise => { await this.retryVisibleMessage(text); }, - async (id: string): Promise => { await this.selectModel(id); }, - async (): Promise => { await this.pickImages(); }, - (id: string): void => this.removeSelectedImage(id), - (route: AppRoute, request: FilePreviewRequest): void => this.openFilePreview(route, request), - (path: string): void => this.downloadVisibleFile(path), - async (): Promise => { await this.sendVisibleChatMessage(); }, - async (): Promise => { await this.toggleVoiceInput(); }, - (route: AppRoute, value: string): void => this.onVisibleChatInputChange(route, value) - )); - readonly presentationActions: AppRootPresentationActions = new AppRootPresentationActions( - (route: AppRoute): boolean => this.handleNavigationBack(route), - (route: AppRoute, intent: ConversationIntent): void => this.handleConversationIntent(route, intent), - (): void => this.closeAppSidebar(), - (source: ConversationSource): void => { this.switchWideConversationSource(source); }, - (source: ConversationSource): void => { this.switchCompactConversationSource(source); }, - (): void => this.enterCompactLayout(), - new RemoteHomePresentationActions( - (): void => this.openAppSidebar(), (): void => this.enterCodeEntry(), (): void => this.openAddConnection(), - (): void => this.openRemoteControlSettings(), (): void => { this.refreshSessions(); }, - (): void => { this.showRecentWorkspaces(); }, (): void => { this.showAssistants(); }, - (path: string): void => { this.selectWorkspace(path); }, (path: string): void => { this.selectAssistant(path); }, - (): void => this.remotePageState.setWorkspacePickerVisible(false), - (): void => this.remotePageState.setAssistantPickerVisible(false), - (query: string): void => this.remotePageState.setQuery(query), (): void => { this.refreshSessions(); }, - (): void => { this.loadMoreSessions(); }, (): void => { this.reconnect(); }, - (): void => { this.disconnect(false); }, (): void => { this.disconnect(true); }, - (agentType: string): void => { this.createSession(agentType); }, - (agentType: string): void => { this.createSession(agentType, true); }, - (): void => { this.openRemoteCreateSession(); }, - (path: string, agentType: string): void => { this.createSessionInWorkspace(path, agentType); }, - (path: string, agentType: string): void => { this.createSessionInWorkspace(path, agentType, true); }, - (session: RemoteSession): void => this.openHomeSession(session), - (session: RemoteSession): void => this.openHomeSessionInPlace(session), - (session: RemoteSession): void => { this.deleteHomeSession(session); } - ), - new RemoteCreatePresentationActions( - (): void => this.closeRemoteCreateSession(), - (): void => { this.toggleRemoteCreateDevices(); }, - (): void => { this.toggleRemoteCreateWorkspaces(); }, - (device: CloudAccountDevice): void => { this.selectRemoteCreateDevice(device); }, - (path: string): void => this.selectRemoteCreateWorkspace(path), - (value: string): void => this.remoteCreateState.setDraft(value), - async (): Promise => { await this.toggleVoiceInput(); }, - (modelId: string): void => this.selectRemoteCreateModel(modelId), - (): void => { this.submitRemoteCreateSession(); } - ), - new SidebarPresentationActions( - (): void => this.closeAppSidebar(), - (): void => { this.closeAppSidebar(); this.prepareNewGeneralChat(); }, - (): void => { this.closeAppSidebar(); this.enterCodeEntry(); }, - (): void => { this.closeAppSidebar(); this.appShellState.openSettings('general'); }, - (): void => { - this.closeAppSidebar(); - setTimeout(() => this.appShellState.openSettings('account'), 180); - }, - (session: RemoteSession): void => { this.closeAppSidebar(); this.openHomeSession(session); }, - (session: RemoteSession, archived: boolean): void => { this.archiveHomeSession(session, archived); }, - (session: RemoteSession): void => { this.exportHomeSession(session); }, - (session: RemoteSession): void => { this.deleteHomeSession(session); } - ), - new SettingsPresentationActions( - (): void => this.appShellState.leaveSettings(), - (): void => this.openAddConnectionFromSettings(), (): void => { this.disconnect(false); }, - (): void => { this.reconnect(); }, - (): void => { - this.appShellState.openSettings('account'); - }, - (relayUrl: string, username: string, password: string): Promise => - this.loginCloudAccount(relayUrl, username, password), - (): Promise => this.syncCloudAccount(), - (): Promise => this.logoutCloudAccount(), - (): Promise => this.listCloudAccountDevices(), - (): Promise => this.getRemotePermissionMode(), - (mode: RemotePermissionMode): Promise => this.setRemotePermissionMode(mode), - async (url: string, key: string, model: string, clear: boolean): Promise => - this.testGeneralChatConfig(url, key, model, clear), - async (url: string, key: string, model: string, clear: boolean): Promise => - this.saveGeneralChatConfig(url, key, model, clear) - ), - new ConnectPresentationActions( - (): void => this.appShellState.setConnectSheetVisible(false), - (password?: string): void => { - // Keep connection progress on the same RemoteHome surface as the - // connected state instead of showing a separate loading sheet. - this.appShellState.setConnectSheetVisible(false); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - this.connect(false, password || ''); - }, - (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, - (url: string): void => { this.setRemoteUrl(url); this.applyRemotePairingProjection(url); }, - (user: string): void => this.setRemoteUserId(user), - (url: string): boolean => this.handleDetectedRemoteUrl(url), - (visible: boolean): void => this.setRemoteUrlInputVisible(visible), - (): void => { this.pasteRemoteUrl(); }, (): void => { this.scanRemoteUrl(); }, - (): Promise => this.listCloudAccountDevices(), - (device: CloudAccountDevice): Promise => this.selectCloudAccountDevice(device) - ), - new FilePreviewPresentationActions( - (): void => this.closeFilePreview(), - (): void => this.refreshFilePreview(), - (path: string): void => this.downloadVisibleFile(path), - (reference: string, label: string): void => this.openFilePreviewLink(reference, label) - ), - (): string => this.generalChatHomeStatusText() - ); - readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; - - - get remoteUrl(): string { return this.remotePageState.remoteUrl; } - get userId(): string { return this.remotePageState.userId; } - get authenticatedUserId(): string { return this.remotePageState.authenticatedUserId; } - get statusText(): string { return this.remotePageState.statusText; } - get connectionState(): ConnectionState { return this.remotePageState.connectionState as ConnectionState; } - get connectionFailureKind(): string { return this.remotePageState.connectionFailureKind; } - get isBusy(): boolean { return this.remotePageState.isBusy; } - get showRemoteUrlInput(): boolean { return this.remotePageState.showRemoteUrlInput; } - get workspaceName(): string { return this.remotePageState.workspaceName; } - get workspacePath(): string { return this.remotePageState.workspacePath; } - get workspaceBranch(): string { return this.remotePageState.workspaceBranch; } - get workspaceKind(): string { return this.remotePageState.workspaceKind; } - get assistantId(): string { return this.remotePageState.assistantId; } - get desktopName(): string { return this.remotePageState.desktopName; } - get desktopId(): string { return this.remotePageState.desktopId; } - get activeSession(): SessionSummary { return this.remotePageState.activeSession; } - get messages(): ChatMessage[] { return this.remotePageState.persistedMessages; } - get pendingMessages(): ChatMessage[] { return this.remotePageState.optimisticMessages; } - get activeTurnMessage(): ChatMessage { return this.remotePageState.activeTurnMessage; } - get timelineItems(): ChatTimelineItem[] { return this.remotePageState.timelineItems; } - get hasMoreMessages(): boolean { return this.remotePageState.hasMoreMessages; } - - async aboutToAppear(): Promise { - this.syncRemotePageSummary(); - await this.generalChatBootstrapController.restore(this.host.context()); - await this.cloudAccountSessionStore.init(this.host.context()); - await this.restoreCloudAccountSession(); - await this.refreshGeneralChatModelCatalog(); - await this.restoreIdentity(); - } - - onPageShow(): void { - RemoteLogger.info(`page show state=${this.connectionState} route=${this.currentRoute()}`); - this.resumeRemoteActivity(); - } - - onPageHide(): void { - RemoteLogger.info(`page hide state=${this.connectionState} route=${this.currentRoute()}`); - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.setRemoteBusy(false); - this.persistVisibleGeneralChatDraft(); - } - - aboutToDisappear(): void { - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.setRemoteBusy(false); - this.persistVisibleGeneralChatDraft(); - this.stopGeneralChatStream(true, 'failed'); - this.generalChatDraftLifecycleController.cancel(); - this.remoteFileDownloadController.cancel(); - this.remoteFilePreviewController.close(); - this.voiceInputLifecycleController.cancel(`${this.currentRoute()}`, () => { - this.setAllVoiceListening(false); - }); - } - - currentRoute(): AppRoute { - return this.appShellViewModel.currentRoute(); - } - - isGeneralComposerRoute(route: AppRoute): boolean { - return AppRootRouteState.isGeneralComposerRoute(route); - } - - visibleChatInput(): string { - if (this.currentRoute() === AppRoute.RemoteCreate) { - return this.remoteCreateState.draft; - } - return AppRootRouteState.chatInput(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - visibleSelectedImages(): SelectedImageAttachment[] { - return AppRootRouteState.selectedImages(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - visibleVoiceListening(): boolean { - if (this.currentRoute() === AppRoute.RemoteCreate) { - return this.remoteCreateState.isVoiceListening; - } - return AppRootRouteState.voiceListening(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - setChatInputForRoute(route: AppRoute, value: string): void { - if (route === AppRoute.RemoteCreate) { - this.remoteCreateState.setDraft(value); - return; - } - AppRootRouteState.setChatInput(route, value, this.generalChatPageState, this.remotePageState); - } - - setSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { - AppRootRouteState.setSelectedImages(route, images, this.generalChatPageState, this.remotePageState); - } - - addSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { - AppRootRouteState.addSelectedImages(route, images, this.generalChatPageState, this.remotePageState); - } - - removeSelectedImageForRoute(route: AppRoute, imageId: string): void { - AppRootRouteState.removeSelectedImage(route, imageId, this.generalChatPageState, this.remotePageState); - } - - clearComposerForRoute(route: AppRoute): void { - AppRootRouteState.clearComposer(route, this.generalChatPageState, this.remotePageState); - } - - setVoiceListeningForRoute(route: AppRoute, isVoiceListening: boolean): void { - if (route === AppRoute.RemoteCreate) { - this.remoteCreateState.isVoiceListening = isVoiceListening; - return; - } - AppRootRouteState.setVoiceListening( - route, - isVoiceListening, - this.generalChatPageState, - this.remotePageState - ); - } - - setAllVoiceListening(isVoiceListening: boolean): void { - this.generalChatPageState.setVoiceListening(isVoiceListening); - this.remotePageState.setVoiceListening(isVoiceListening); - } - - voiceInputSnapshot(route: AppRoute = this.currentRoute()): VoiceInputRouteSnapshot { - if (route === AppRoute.RemoteCreate) { - return { - routeId: `${route}`, - isListening: this.remoteCreateState.isVoiceListening, - isBusy: this.remoteCreateState.isSubmitting, - inputText: this.remoteCreateState.draft, - selectedImageCount: 0 - }; - } - return AppRootRouteState.snapshot( - route, - this.visibleChatBusy(), - this.generalChatPageState, - this.remotePageState - ); - } - - isRoute(route: AppRoute): boolean { - return this.appShellViewModel.isRoute(route); - } - - isGeneralChatVisible(): boolean { - return this.appShellViewModel.isGeneralChatVisible(); - } - - pushRoute(route: AppRoute, sessionId: string = ''): void { - this.appShellViewModel.pushRoute(route, sessionId); - } - - replaceRoute(route: AppRoute, sessionId: string = ''): void { - this.appShellViewModel.replaceRoute(route, sessionId); - } - - popRoute(fallback: AppRoute): void { - this.appShellViewModel.popRoute(fallback); - } - - private routeCreatedRemoteSession(sessionId: string): void { - this.closeFilePreview(); - if (this.isRoute(AppRoute.RemoteCreate)) { - this.appShellViewModel.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); - return; - } - this.pushRoute(AppRoute.RemoteChat, sessionId); - } - - private routeRemoteSessionInPlace(_sessionId: string): void { - this.closeFilePreview(); - if (this.isRoute(AppRoute.RemoteHome) || this.isRoute(AppRoute.RemoteChat)) { - return; - } - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - - private isRemoteConversationContext(sessionId: string): boolean { - if (sessionId.length === 0 || this.activeSession.sessionId !== sessionId) { - return false; - } - return this.isRoute(AppRoute.RemoteChat) || this.isRoute(AppRoute.RemoteHome); - } - - handleNavigationBack(route: AppRoute): boolean { - if (this.filePreviewState.visible) { - this.closeFilePreview(); - return true; - } - const action = this.appShellViewModel.backAction(route); - if (action === AppNavigationBackAction.CloseSidebar) { - this.closeAppSidebar(); - return true; - } - if (action === AppNavigationBackAction.CloseActiveChat) { - this.closeActiveChat(); - return true; - } - if (action === AppNavigationBackAction.PopRemoteHome) { - this.popRoute(AppRoute.ChatHome); - return true; - } - return false; - } - - handleRootBack(): boolean { - if (!this.filePreviewState.visible) { - return false; - } - this.closeFilePreview(); - return true; - } - - - handleConversationIntent(route: AppRoute, intent: ConversationIntent): void { - this.conversationIntentDispatcher.dispatch(route, intent); - } - - - async saveGeneralChatConfig( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): Promise { - const update: GeneralChatConfigUpdate = { - apiUrl, - apiKey, - modelName, - clearApiKey - }; - try { - const validationError = await this.validateGeneralChatConfig(update); - if (validationError.length > 0) { - return validationError; - } - if (!update.clearApiKey) { - const probeError = await this.probeGeneralChatConfig(update); - if (probeError.length > 0) { - return probeError; - } - } - const catalogBeforeSave = await this.generalChatConfigStore.modelCatalog(); - const snapshot = await this.generalChatConfigStore.save(update); - if (GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel(catalogBeforeSave)) { - await this.generalChatConfigStore.selectLocalModel(); - } - this.applyGeneralChatConfig(snapshot); - await this.refreshGeneralChatModelCatalog(); - return ''; - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - async testGeneralChatConfig( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): Promise { - const update: GeneralChatConfigUpdate = { - apiUrl, - apiKey, - modelName, - clearApiKey - }; - try { - const validationError = await this.validateGeneralChatConfig(update); - if (validationError.length > 0) { - return validationError; - } - if (update.clearApiKey) { - return RemoteI18n.t('settings.modelService.testNeedsKey'); - } - return await this.probeGeneralChatConfig(update); - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - private async validateGeneralChatConfig(update: GeneralChatConfigUpdate): Promise { - const snapshot = await this.generalChatConfigStore.snapshot(); - return GeneralChatConfigValidator.validate(update, snapshot.hasApiKey); - } - - private async probeGeneralChatConfig(update: GeneralChatConfigUpdate): Promise { - const apiKey = await this.effectiveGeneralChatApiKey(update); - if (apiKey.length === 0) { - return RemoteI18n.t('settings.modelService.apiKeyRequired'); - } - try { - await ModelProviderGeneralChatAdapter.probeConfiguration(update.apiUrl, apiKey, update.modelName); - return ''; - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - private async effectiveGeneralChatApiKey(update: GeneralChatConfigUpdate): Promise { - const directKey = update.apiKey.trim(); - if (directKey.length > 0) { - return directKey; - } - if (update.clearApiKey) { - return ''; - } - return (await this.generalChatConfigStore.accessToken()).trim(); - } - - applyGeneralChatConfig(snapshot: GeneralChatConfigSnapshot): void { - this.generalChatPageState.setConfiguration( - snapshot.apiUrl, - snapshot.modelName, - snapshot.hasApiKey, - GeneralChatServiceStatus.fromConfiguration(snapshot.apiUrl, snapshot.modelName, snapshot.hasApiKey) - ); - } - - private async refreshGeneralChatModelCatalog(): Promise { - const catalog = await this.generalChatConfigStore.modelCatalog(); - const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; - this.generalChatPageState.setModelCatalog(catalog, selectedModelId); - const active = await this.generalChatConfigStore.activeSnapshot(); - this.generalChatPageState.setServiceState( - GeneralChatServiceStatus.fromConfiguration(active.apiUrl, active.modelName, active.hasApiKey) - ); - } - - async restoreIdentity(): Promise { - if (this.remotePageState.controlTargetType === 'account_device') { - return; - } - await this.remoteConnectionViewModel.restore(this.host.context()); - } - - async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { - await this.remoteConnectionViewModel.connect(autoReconnect, accountPassword); - await this.persistDelegatedAccountSession(); - } - - private async persistDelegatedAccountSession(): Promise { - if (this.cloudAccountSession) { - return; - } - const delegated = this.sessionManager.delegatedAccountSession(); - if (!delegated) { - return; - } - this.cloudAccountSession = delegated.session; - this.cloudAccountRelayUrl = delegated.relayUrl; - await this.cloudAccountSessionStore.save({ - relayUrl: delegated.relayUrl, - username: delegated.session.userId, - token: delegated.session.token, - userId: delegated.session.userId, - masterKey: Encoding.bytesToBase64(delegated.session.masterKey) - }); - this.remotePageState.setAccountUserId(delegated.session.userId); - this.remotePageState.setAccountUsername(delegated.session.userId); - RemoteLogger.info('delegated account session persisted after room pairing'); - } - - async reconnect(): Promise { - if (this.remotePageState.controlTargetType === 'account_device') { - await this.restoreCloudTarget( - this.remotePageState.controlTargetDeviceId, - this.remotePageState.controlTargetDeviceName - ); - return; - } - await this.remoteConnectionViewModel.reconnect(); - } - - async disconnect(clearPairing: boolean): Promise { - this.invalidateFilePreviewTarget(); - await this.remoteConnectionViewModel.disconnect(clearPairing); - } - - async pasteRemoteUrl(): Promise { - await this.remoteConnectionViewModel.paste(); - } - - async scanRemoteUrl(): Promise { - await this.remoteConnectionViewModel.scan(this.host.context()); - } - - handleDetectedRemoteUrl(remoteUrl: string): boolean { - return this.remoteConnectionViewModel.handleDetectedUrl(remoteUrl); - } - - applyWorkspace(workspace: WorkspaceInfo): void { - this.remoteConnectionViewModel.applyWorkspace(workspace); - } - - applyRemotePairingProjection(remoteUrl: string): void { - this.remoteConnectionViewModel.projectRemoteUrl(remoteUrl); - } - - ensureRemoteAvailable(): boolean { - return this.remoteConnectionViewModel.ensureAvailable(); - } - - setRemoteConnectionState(connectionState: ConnectionState): void { - this.remotePageState.setConnectionState(connectionState); - } - - setRemoteUrl(remoteUrl: string): void { - this.remotePageState.setRemoteUrl(remoteUrl); - } - - setRemoteUserId(userId: string): void { - this.remotePageState.setUserId(userId); - } - - setRemoteAuthenticatedUserId(authenticatedUserId: string): void { - this.remotePageState.setAuthenticatedUserId(authenticatedUserId); - } - - setRemoteStatusText(statusText: string): void { - this.remotePageState.setStatusText(statusText); - } - - setRemoteConnectionFailureKind(connectionFailureKind: string): void { - this.remotePageState.setConnectionFailureKind(connectionFailureKind); - } - - setRemoteBusy(isBusy: boolean): void { - this.remotePageState.setBusy(isBusy); - } - - setRemoteUrlInputVisible(visible: boolean): void { - this.remotePageState.setRemoteUrlInputVisible(visible); - } - - syncRemotePageSummary(): void { - if (this.remotePageState.statusText.length === 0) { - this.remotePageState.setStatusText(RemoteI18n.t('status.waitingConnection')); - } - if (this.remotePageState.workspaceName.length === 0) { - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - } - } - - failRemoteConnection(err: Object): void { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - this.setRemoteConnectionState(ConnectionState.Failed); - this.stopHeartbeat(); - } - - async showRecentWorkspaces(): Promise { - await this.remoteWorkspaceViewModel.toggleRecentWorkspaces(); - } - - async showAssistants(): Promise { - await this.remoteWorkspaceViewModel.toggleAssistants(); - } - - async selectWorkspace(path: string): Promise { - this.closeFilePreview(); - await this.remoteWorkspaceViewModel.selectWorkspace(path); - } - - async selectAssistant(path: string): Promise { - this.closeFilePreview(); - await this.remoteWorkspaceViewModel.selectAssistant(path); - } - - async refreshSessions(): Promise { - await this.remoteSessionViewModel.refreshSessions(); - } - - async loadMoreSessions(): Promise { - await this.remoteSessionViewModel.loadMoreSessions(); - } - - setSessionFilter(filter: string): void { - this.remoteSessionViewModel.setFilter(filter); - } - - visibleChatBusy(): boolean { - return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? - this.generalChatPageState.isBusy : this.remotePageState.isBusy; - } - - visibleStatusText(): string { - return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? - this.generalChatPageState.statusText : this.remotePageState.statusText; - } - - setVisibleStatusText(statusText: string): void { - if (this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat)) { - this.generalChatPageState.setStatus(statusText); - return; - } - this.setRemoteStatusText(statusText); - } - - openAppSidebar(): void { - this.host.animate(230, () => { - this.appShellState.setSidebarVisible(true); - }); - } - - closeAppSidebar(): void { - this.host.animate(210, () => { - this.appShellState.setSidebarVisible(false); - }); - } - - enterCodeEntry(): void { - if (this.cloudAccountSession && this.remotePageState.accountUserId.trim().length > 0) { - this.appShellState.setConnectSheetVisible(true); - return; - } - if (RemoteUiState.canUseRemote(this.connectionState)) { - this.appShellState.setConnectSheetVisible(false); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - return; - } - this.appShellState.setConnectSheetVisible(true); - } - - async switchWideConversationSource(source: ConversationSource): Promise { - if (AppRouteContract.conversationSource(this.currentRoute()) === source) { - return; - } - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - if (source === ConversationSource.General) { - this.stopPolling(); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - return; - } - this.persistVisibleGeneralChatDraft(); - const activeRemoteSessionId = this.remotePageState.activeSession.sessionId || ''; - const target = AppRouteContract.routeForConversationSource( - source, - RemoteUiState.canUseRemote(this.connectionState), - activeRemoteSessionId - ); - const hasActiveRemoteConversation = target.name === AppRoute.RemoteChat; - this.appShellViewModel.replaceRouteWithoutAnimation( - hasActiveRemoteConversation ? AppRoute.RemoteHome : target.name - ); - if (hasActiveRemoteConversation) { - this.startPolling(); - await this.loadActiveMessages(); - } - } - - /** - * Compact counterpart of switchWideConversationSource. Switching source is a - * change of context, not a command to start something: it resumes the session - * the user was last in, and otherwise rests on the Remote landing surface - * rather than opening the create composer for them. - */ - async switchCompactConversationSource(source: ConversationSource): Promise { - this.closeAppSidebar(); - if (AppRouteContract.conversationSource(this.currentRoute()) === source) { - return; - } - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - if (source === ConversationSource.General) { - this.stopPolling(); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - return; - } - this.persistVisibleGeneralChatDraft(); - const activeRemoteSessionId = RemoteUiState.canUseRemote(this.connectionState) ? - (this.remotePageState.activeSession.sessionId || '') : ''; - if (activeRemoteSessionId.length === 0) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - return; - } - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteChat, activeRemoteSessionId); - this.startPolling(); - await this.loadActiveMessages(); - } - - private enterCompactLayout(): void { - const sessionId = this.remotePageState.activeSession.sessionId || ''; - if (this.isRoute(AppRoute.RemoteHome) && sessionId.length > 0) { - this.appShellViewModel.pushRoute(AppRoute.RemoteChat, sessionId, false); - } - } - - openAddConnection(): void { - this.appShellState.setConnectSheetVisible(true); - } - - openRemoteControlSettings(): void { - setTimeout(() => { - this.appShellState.openSettings('remote'); - }, 180); - } - - openAddConnectionFromSettings(): void { - this.appShellState.setSettingsVisible(false); - setTimeout(() => { - this.openAddConnection(); - }, 220); - } - - async loginCloudAccount(relayUrl: string, username: string, password: string): Promise { - RemoteLogger.info('cloud account UI login requested'); - const session = await this.cloudAccountClient.login(relayUrl, username, password, this.identityStoreSnapshotInstallId()); - this.applyCloudAccountSession(session, relayUrl, username); - await this.cloudAccountSessionStore.save({ - relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, - masterKey: Encoding.bytesToBase64(session.masterKey) - }); - await this.loadGeneralChatAccountModels(session, relayUrl); - RemoteLogger.info('cloud account credentials persisted, refreshing account devices'); - RemoteLogger.info(`cloud account login success user=${session.userId}`); - return session.userId; - } - - private async restoreCloudAccountSession(): Promise { - try { - const persisted = await this.cloudAccountSessionStore.load(); - if (!persisted) return; - const session: CloudAccountSession = { - token: persisted.token, - userId: persisted.userId, - masterKey: Encoding.base64ToBytes(persisted.masterKey) - }; - this.applyCloudAccountSession(session, persisted.relayUrl, persisted.username || session.userId); - await this.loadGeneralChatAccountModels(session, persisted.relayUrl); - } catch (err) { - RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); - await this.cloudAccountSessionStore.clear(); - } - } - - private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { - this.generalChatConfigStore.replaceAccountModels([]); - try { - const blob = await this.cloudAccountClient.fetchSettings(relayUrl, session); - if (!blob) { - this.generalChatConfigStore.replaceAccountModels([]); - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.info('cloud model catalog is empty'); - return; - } - const models = GeneralChatCloudConfigPolicy.models(blob.plaintext); - this.generalChatConfigStore.replaceAccountModels(models); - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.info(`cloud model catalog loaded count=${models.length} version=${blob.version}`); - } catch (err) { - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.warn(`cloud model catalog load failed: ${err instanceof Error ? err.message : 'unknown error'}`); - } - } - - async syncCloudAccount(): Promise { - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); - } - let bundles: Object[]; - try { - bundles = await this.cloudAccountClient.fetchSessions(this.cloudAccountRelayUrl, this.cloudAccountSession, 0); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - throw new Error(RemoteI18n.t('remote.settings.accountExpired')); - } - throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed')); - } - await this.loadGeneralChatAccountModels(this.cloudAccountSession, this.cloudAccountRelayUrl); - RemoteLogger.info(`cloud account backup sync completed count=${bundles.length}`); - return String(bundles.length); - } - - protected applyCloudAccountSession(session: CloudAccountSession, relayUrl: string, username: string): void { - this.cloudAccountSession = session; - this.cloudAccountRelayUrl = relayUrl.trim(); - this.remotePageState.setAccountUserId(session.userId); - this.remotePageState.setAccountUsername(username.trim()); - } - - async logoutCloudAccount(): Promise { - this.invalidateFilePreviewTarget(); - if (this.remotePageState.controlTargetType === 'account_device') { - this.remoteActivityViewModel.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - this.sessionManager.reset(); - this.remotePageState.clearActiveSession(); - this.remotePageState.setSessions([], false); - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - this.remotePageState.setConnectionState(ConnectionState.Disconnected); - this.remotePageState.setAuthenticatedUserId(''); - } - this.cloudAccountSession = undefined; - this.cloudAccountRelayUrl = ''; - this.generalChatConfigStore.replaceAccountModels([]); - await this.refreshGeneralChatModelCatalog(); - await this.cloudAccountSessionStore.clear(); - this.remotePageState.setAccountUserId(''); - this.remotePageState.setAccountUsername(''); - this.remotePageState.clearControlTarget(); - RemoteLogger.info('cloud account logout success'); - } - - async listCloudAccountDevices(): Promise { - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - return []; - } - try { - return await this.cloudAccountClient.listDevices(this.cloudAccountRelayUrl, this.cloudAccountSession); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - throw new Error(RemoteI18n.t('remote.settings.accountExpired')); - } - if (err instanceof CloudAccountRequestError && - (err.statusCode === 404 || err.statusCode === 503 || err.statusCode === 504)) { - throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); - } - throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.deviceLoadFailed')); - } - } - - async getRemotePermissionMode(): Promise { - if (!this.ensureRemoteAvailable()) { - throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); - } - return this.sessionManager.getPermissionMode(); - } - - async setRemotePermissionMode(mode: RemotePermissionMode): Promise { - if (!this.ensureRemoteAvailable()) { - throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); - } - return this.sessionManager.setPermissionMode(mode); - } - - private async restoreCloudTarget(targetDeviceId: string, targetDeviceName: string): Promise { - const targetId = targetDeviceId.trim(); - if (targetId.length === 0) { - return; - } - try { - const devices = await this.listCloudAccountDevices(); - const target = devices.find((device: CloudAccountDevice): boolean => device.deviceId === targetId); - if (!target || !target.online) { - const targetName = target?.deviceName || targetDeviceName || targetId; - this.remotePageState.setControlTarget('account_device', targetId, targetName); - this.remotePageState.setDesktopIdentity(targetName, targetId); - this.remotePageState.setConnectionState(ConnectionState.Failed); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.deviceUnavailable')); - return; - } - await this.selectCloudAccountDevice({ - deviceId: target.deviceId, - deviceName: target.deviceName || targetDeviceName || target.deviceId, - online: target.online, - lastSeenAt: target.lastSeenAt - }); - } catch (err) { - RemoteLogger.warn(`cloud target restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); - } - } - - private async expireCloudAccountSession(): Promise { - this.invalidateFilePreviewTarget(); - this.cloudAccountSession = undefined; - this.cloudAccountRelayUrl = ''; - await this.cloudAccountSessionStore.clear(); - this.remotePageState.setAccountUserId(''); - this.remotePageState.setAccountUsername(''); - if (this.remotePageState.controlTargetType === 'account_device') { - this.remoteActivityViewModel.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - this.sessionManager.reset(); - this.remotePageState.clearActiveSession(); - this.remotePageState.setSessions([], false); - this.remotePageState.clearControlTarget(); - this.remotePageState.setConnectionState(ConnectionState.Disconnected); - } - } - - private async handleRemoteConnectionError(err: Object): Promise { - if (this.remotePageState.controlTargetType !== 'account_device' || - !(err instanceof CloudAccountRequestError) || err.statusCode !== 401) { - return false; - } - await this.expireCloudAccountSession(); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.accountExpired')); - return true; - } - - async selectCloudAccountDevice(device: CloudAccountDevice, navigateHome: boolean = true): Promise { - if (!device.online) { - throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); - } - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); - } - const deviceId = device.deviceId.trim(); - if (deviceId.length === 0 || deviceId === this.remoteConnectionViewModel.getDeviceId()) { - return; - } - if (deviceId === this.remotePageState.controlTargetDeviceId && - this.connectionState === ConnectionState.Connected) { - this.appShellState.setConnectSheetVisible(false); - if (navigateHome) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - return; - } - this.invalidateFilePreviewTarget(); - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - // Do not keep presenting the previous device while the new account device - // is being handshaken. Clear its projection before the async connect. - this.remotePageState.setConnectionState(ConnectionState.Reconnecting); - this.remotePageState.setLoadingHome(true); - this.remotePageState.clearControlTarget(); - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - this.remotePageState.setBusy(true); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); - this.remotePageState.clearActiveSession(); - this.resetChatTimeline(''); - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - this.remotePageState.setSessions([], false); - try { - const initialSync = await this.sessionManager.connectAccountDevice( - this.cloudAccountClient, - this.cloudAccountRelayUrl, - this.cloudAccountSession, - deviceId - ); - this.remotePageState.setControlTarget('account_device', deviceId, device.deviceName); - this.remotePageState.setDesktopIdentity(device.deviceName, deviceId); - this.remotePageState.setWorkspace( - initialSync.workspace.name, - initialSync.workspace.path, - initialSync.workspace.assistantId || '', - initialSync.workspace.gitBranch, - initialSync.workspace.workspaceKind || 'normal' - ); - this.remotePageState.setSessions(initialSync.sessions, initialSync.hasMoreSessions); - this.remotePageState.setAuthenticatedUserId(initialSync.authenticatedUserId); - this.remotePageState.setConnectionState(ConnectionState.Connected); - this.remotePageState.setStatusText(RemoteI18n.t('connection.connected')); - this.appShellState.setSettingsVisible(false); - this.appShellState.setConnectSheetVisible(false); - if (navigateHome) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - await this.cloudAccountSessionStore.save({ - relayUrl: this.cloudAccountRelayUrl, - username: this.remotePageState.accountUsername, - token: this.cloudAccountSession.token, - userId: this.cloudAccountSession.userId, - masterKey: Encoding.bytesToBase64(this.cloudAccountSession.masterKey), - targetDeviceId: deviceId, - targetDeviceName: device.deviceName - }); - this.startHeartbeat(); - await this.loadRecentWorkspacesInBackground(); - this.remotePageState.setLoadingHome(false); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - } - this.remotePageState.clearControlTarget(); - this.remotePageState.setConnectionState(ConnectionState.Failed); - const message = ConnectionErrorPolicy.errorText(err); - this.remotePageState.setStatusText(message); - this.sessionManager.reset(); - throw new Error(message); - } finally { - this.remotePageState.setLoadingHome(false); - this.remotePageState.setBusy(false); - } - } - - private identityStoreSnapshotInstallId(): string { - return this.remoteConnectionViewModel.getDeviceId(); - } - - openHomeSession(session: RemoteSession): void { - this.closeFilePreview(); - if (session.agentType === 'chat') { - this.openGeneralSession(session); - return; - } - this.openSession(session); - } - - openHomeSessionInPlace(session: RemoteSession): void { - this.closeFilePreview(); - if (session.agentType === 'chat') { - this.openGeneralSession(session); - return; - } - this.openSession(session, true); - } - - async deleteHomeSession(session: RemoteSession): Promise { - if (session.agentType !== 'chat') { - await this.deleteSession(session); - return; - } - await this.generalChatCommandController.deleteSession(session, this.generalChatPageState.isBusy); - } - - activeGeneralChatAsRemoteSession(): RemoteSession { - const active = this.generalChatPageState.activeSession; - return { - id: active.sessionId, - title: active.title, - agentType: 'chat', - status: 'ready', - updatedAt: '', - createdAt: '', - messageCount: this.generalChatPageState.timelineItems.length, - workspacePath: active.workspacePath - }; - } - - activeGeneralUploadedFileCount(): number { - let count = 0; - this.generalChatPageState.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 this.generalChatCommandController.archiveSession(session, archived, this.generalChatPageState.isBusy); - } - - async exportHomeSession(session: RemoteSession): Promise { - await this.generalChatCommandController.exportSession( - session, - this.generalChatPageState.isBusy, - async (text: string): Promise => { - await this.clipboardService.writeText(text); - } - ); - } - - async openGeneralSession(item: RemoteSession): Promise { - if (this.generalChatPageState.isBusy) { - return; - } - this.stopPolling(); - this.stopGeneralChatStream(false); - await this.generalChatCommandController.openSession( - item, - this.generalChatPageState.isBusy, - async (sessionId: string): Promise => { - return this.generalChatDraftLifecycleController.restore(sessionId); - }, - (_sessionId: string) => { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - ); - } - - async startGeneralChat(text: string): Promise { - const trimmed = text.trim(); - if (trimmed.length === 0 || this.generalChatPageState.isBusy) { - return; - } - this.stopPolling(); - this.stopGeneralChatStream(false); - this.generalChatDraftLifecycleController.cancel(); - const created = await this.generalChatCommandController.createSession( - trimmed, - this.generalChatPageState.isBusy, - async (): Promise => { - await this.generalChatDraftLifecycleController.clearHomeNow(); - }, - (_sessionId: string) => { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - ); - if (!created) { - return; - } - await this.sendGeneralChatMessage(); - } - - async sendVisibleChatMessage(): Promise { - if (this.isGeneralChatVisible()) { - if ((this.generalChatPageState.activeSession.sessionId || '').length === 0) { - this.startVisibleGeneralChat(); - return; - } - await this.sendGeneralChatMessage(); - return; - } - await this.sendChatMessage(); - } - - async stopActiveChatTask(): Promise { - if (this.isGeneralChatVisible()) { - this.stopGeneralChatStream(true); - return; - } - await this.stopActiveTask(); - } - - closeActiveChat(): void { - this.closeFilePreview(); - this.stopVoiceInput(false); - if (this.isRoute(AppRoute.GeneralChat)) { - this.persistVisibleGeneralChatDraft(); - this.stopGeneralChatStream(true); - this.popRoute(AppRoute.ChatHome); - this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); - return; - } - this.stopPolling(); - this.popRoute(AppRoute.RemoteHome); - } - - async renameVisibleSession(title: string): Promise { - if (this.isGeneralChatVisible()) { - await this.generalChatCommandController.renameActiveSession( - this.generalChatPageState.activeSession, - title - ); - return; - } - await this.renameActiveSession(title); - } - - async retryVisibleMessage(text: string): Promise { - if (this.isGeneralChatVisible()) { - const sessionId = this.generalChatPageState.activeSession.sessionId || ''; - const prepared = await this.generalChatCommandController.retryMessage( - sessionId, - text, - this.generalChatPageState.isBusy - ); - if (prepared) { - await this.sendGeneralChatMessage(); - } - return; - } - this.retryMessage(text); - } - - downloadVisibleFile(path: string): void { - if (this.isGeneralChatVisible()) { - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); - return; - } - this.downloadFile(path); - } - - openFilePreview(route: AppRoute, request: FilePreviewRequest): void { - const context = new FilePreviewTargetContext( - this.remotePageState.activeSession.sessionId, - this.remotePageState.activeSession.workspacePath || this.remotePageState.workspacePath, - this.controlTargetEpoch - ); - const resolution = FileTargetResolver.resolve(request.reference, request.label, context); - if (resolution.kind === FileReferenceKind.HttpUrl) { - void this.openExternalLink(route, request.reference); - return; - } - if (route !== AppRoute.RemoteChat) { - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.filePreviewUnavailable')); - return; - } - if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target) { - return; - } - void this.remoteFilePreviewController.open(resolution.target); - } - - private async openExternalLink(route: AppRoute, reference: string): Promise { - const opened = this.host.openExternalLink ? await this.host.openExternalLink(reference) : false; - if (!opened) { - if (AppRouteContract.isGeneralComposerRoute(route)) { - this.generalChatPageState.setStatus(RemoteI18n.t('errors.operationFailed')); - } else { - this.setRemoteStatusText(RemoteI18n.t('errors.operationFailed')); - } - } - } - - closeFilePreview(): void { - this.remoteFilePreviewController.close(); - } - - refreshFilePreview(): void { - void this.remoteFilePreviewController.refresh(); - } - - openFilePreviewLink(reference: string, label: string): void { - this.openFilePreview(AppRoute.RemoteChat, new FilePreviewRequest(reference, label)); - } - - invalidateFilePreviewTarget(): void { - this.controlTargetEpoch += 1; - this.remoteFilePreviewController.close(); - } - - async createSession(agentType: string, inPlace: boolean = false): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.createSession( - agentType, - '', - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - openRemoteCreateSession(): void { - if (!this.ensureRemoteAvailable()) { - return; - } - const deviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; - const deviceName = this.remotePageState.controlTargetDeviceName || this.remotePageState.desktopName; - this.remoteCreateState.prepare(deviceId, deviceName, this.remotePageState.selectedModelId); - if (deviceId.length > 0) { - this.remoteCreateState.setDevices([{ - deviceId, - deviceName: deviceName || deviceId, - online: true - }]); - } - this.remoteCreateState.setWorkspaces(this.remotePageState.recentWorkspaces); - this.pushRoute(AppRoute.RemoteCreate); - this.loadRemoteCreateChoices(); - this.loadRemoteCreateModelCatalog(); - } - - closeRemoteCreateSession(): void { - this.remoteCreateWorkspaceLoadVersion += 1; - this.stopVoiceInput(false); - this.remoteCreateState.closeMenu(); - this.popRoute(AppRoute.RemoteHome); - } - - async loadRemoteCreateChoices(): Promise { - await Promise.all([ - this.loadRemoteCreateDevices(), - this.loadRemoteCreateWorkspaces() - ]); - } - - async loadRemoteCreateModelCatalog(): Promise { - if (this.remotePageState.modelCatalog.models.length > 0) { - return; - } - try { - const catalog = await this.sessionManager.getModelCatalog(); - const selectedModelId = RemoteUiState.selectedModelIdForCatalog( - catalog, - this.remotePageState.selectedModelId - ); - this.remotePageState.setModelCatalog(catalog, selectedModelId); - this.remoteCreateState.setSelectedModelId(selectedModelId); - } catch (_err) { - // Model selection remains hidden when the remote does not expose a catalog. - } - } - - async loadRemoteCreateDevices(): Promise { - this.remoteCreateState.isLoadingDevices = this.remoteCreateState.devices.length === 0; - try { - const phoneDeviceId = this.remoteConnectionViewModel.getDeviceId(); - const accountDevices = await this.listCloudAccountDevices(); - const devices = accountDevices.filter((device: CloudAccountDevice): boolean => - device.online && device.deviceId !== phoneDeviceId - ); - const currentId = this.remoteCreateState.selectedDeviceId; - if (currentId.length > 0 && !devices.some((device: CloudAccountDevice): boolean => device.deviceId === currentId)) { - devices.unshift({ - deviceId: currentId, - deviceName: this.remoteCreateState.selectedDeviceName || currentId, - online: true - }); - } - this.remoteCreateState.setDevices(devices); - } catch (err) { - const currentId = this.remoteCreateState.selectedDeviceId; - if (currentId.length > 0) { - this.remoteCreateState.setDevices([{ - deviceId: currentId, - deviceName: this.remoteCreateState.selectedDeviceName || currentId, - online: true - }]); - } else { - this.remoteCreateState.setDevices([]); - } - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.deviceLoadFailed'); - } - } - - async loadRemoteCreateWorkspaces(): Promise { - const loadVersion = ++this.remoteCreateWorkspaceLoadVersion; - const deviceId = this.remoteCreateState.selectedDeviceId; - this.remoteCreateState.isLoadingWorkspaces = this.remoteCreateState.workspaces.length === 0; - try { - const workspaces = await this.workspaceCoordinator.recentWorkspaces(); - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreateState.selectedDeviceId) { - return; - } - this.remoteCreateState.setWorkspaces(workspaces); - } catch (err) { - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreateState.selectedDeviceId) { - return; - } - this.remoteCreateState.setWorkspaces([]); - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); - } - } - - toggleRemoteCreateDevices(): void { - this.remoteCreateState.toggleMenu('devices'); - if (this.remoteCreateState.openMenu === 'devices' && this.remoteCreateState.devices.length === 0) { - this.loadRemoteCreateDevices(); - } - } - - toggleRemoteCreateWorkspaces(): void { - this.remoteCreateState.toggleMenu('workspaces'); - if (this.remoteCreateState.openMenu === 'workspaces' && this.remoteCreateState.workspaces.length === 0) { - this.loadRemoteCreateWorkspaces(); - } - } - - async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { - if (device.deviceId === this.remoteCreateState.selectedDeviceId) { - this.remoteCreateState.closeMenu(); - return; - } - const draft = this.remoteCreateState.draft; - this.remoteCreateState.closeMenu(); - this.remoteCreateState.isLoadingWorkspaces = true; - try { - await this.selectCloudAccountDevice(device, false); - this.remoteCreateState.selectDevice(device); - this.remoteCreateState.setDraft(draft); - await this.loadRemoteCreateWorkspaces(); - } catch (err) { - this.remoteCreateState.isLoadingWorkspaces = false; - this.remoteCreateState.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceSwitchFailed'); - } - } - - selectRemoteCreateWorkspace(path: string): void { - const workspace = this.remoteCreateState.workspaces.find((item: RecentWorkspaceEntry): boolean => item.path === path); - this.remoteCreateState.selectWorkspace(workspace); - } - - selectRemoteCreateModel(modelId: string): void { - this.remoteCreateState.setSelectedModelId(modelId); - } - - async submitRemoteCreateSession(): Promise { - const instruction = this.remoteCreateState.draft.trim(); - if (instruction.length === 0 || this.remoteCreateState.isSubmitting || !this.ensureRemoteAvailable()) { - return; - } - const context = this.remoteCreateState.submissionContext(); - const activeDeviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; - if (context.deviceId.length === 0 || context.deviceId !== activeDeviceId) { - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.deviceMismatch'); - return; - } - this.remoteCreateState.isSubmitting = true; - this.remoteCreateState.errorText = ''; - this.remoteCreateState.closeMenu(); - try { - if (context.workspacePath.length > 0) { - await this.remoteSessionViewModel.createSessionInWorkspace( - context.workspacePath, - this.workspacePath, - instruction, - context.agentType, - undefined, - this.remoteCreateState.selectedModelId - ); - } else { - await this.remoteSessionViewModel.createSession( - context.agentType, - instruction, - undefined, - this.remoteCreateState.selectedModelId - ); - } - if (this.isRoute(AppRoute.RemoteCreate)) { - this.remoteCreateState.errorText = this.statusText || RemoteI18n.t('remote.create.submitFailed'); - } - } catch (err) { - this.remoteCreateState.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.create.submitFailed'); - } finally { - this.remoteCreateState.isSubmitting = false; - } - } - - async createSessionInWorkspace( - path: string, - agentType: string = 'code', - inPlace: boolean = false - ): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.createSessionInWorkspace( - path, - this.workspacePath, - '', - agentType, - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void { - this.remoteWorkspaceSessions = all; - const current = this.remotePageState.sessions; - const extras = all.filter((item: RemoteSession) => item.workspacePath !== this.workspacePath); - this.remotePageState.setSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); - } - - async loadRecentWorkspacesInBackground(): Promise { - await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); - } - - mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[] { - const merged = primary.slice(); - extras.forEach((item: RemoteSession) => { - if (!merged.some((existing: RemoteSession) => existing.id === item.id)) { - merged.push(item); - } - }); - return merged; - } - - async openSession(item: RemoteSession, inPlace: boolean = false): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.openSession( - item, - this.workspacePath, - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - applyRemoteActiveSession(session: SessionSummary): void { - const current = this.remotePageState.activeSession; - if (this.filePreviewState.visible && - (current.sessionId !== session.sessionId || current.workspacePath !== session.workspacePath)) { - this.closeFilePreview(); - } - this.remotePageState.setActiveSession(session); - } - - async deleteSession(item: RemoteSession): Promise { - await this.remoteSessionViewModel.deleteSession(item, this.workspacePath); - } - - async loadActiveMessages(): Promise { - await this.remoteSessionViewModel.loadActiveMessages((activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - }); - } - - async loadModelCatalog(sessionId: string): Promise { - await this.remoteSessionViewModel.loadModelCatalog(sessionId, (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - }); - } - - async selectModel(modelId: string): Promise { - if (this.isGeneralChatVisible()) { - if (await this.generalChatConfigStore.selectModel(modelId)) { - await this.refreshGeneralChatModelCatalog(); - } - return; - } - await this.remoteSessionViewModel.selectModel(modelId); - } - - async loadOlderMessages(): Promise { - await this.remoteSessionViewModel.loadOlderMessages(this.knownPollVersion); - } - - async sendGeneralChatMessage(): Promise { - await this.generalChatConversationViewModel.sendMessage(); - return; - } - - stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { - this.generalChatConversationViewModel.stop(cancelled, finalStatus); - return; - } - - async sendChatMessage(): Promise { - if (this.remotePageState.isVoiceListening) { - await this.stopVoiceInput(false); - } - const rawText = this.remotePageState.chatInput.trim(); - const images = this.remotePageState.selectedImages.slice(); - const text = rawText.length > 0 ? rawText : (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - const sessionId = this.activeSession.sessionId || ''; - if ((!text && images.length === 0) || !sessionId || this.isBusy) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - this.remotePageState.clearComposer(); - const localMessage = RemoteUiState.localUserMessage(text, images); - this.chatTimelineStore.appendOptimisticMessage(localMessage); - const pendingActiveId = this.chatTimelineStore.setPendingActiveTurn(localMessage.id); - this.syncChatTimelineFromStore(); - RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId}`); - this.startPolling(); - this.nudgeChatPolling(); - const imageContexts: RemoteImageContext[] = images.length > 0 ? this.imagePickerService.toRemoteContexts(images) : []; - await this.remoteChatCommandController.sendPreparedMessage( - sessionId, - text, - this.activeSession.agentType, - rawText, - images, - imageContexts, - localMessage.id, - pendingActiveId, - this.isBusy, - true - ); - } - - async toggleVoiceInput(): Promise { - const route = this.currentRoute(); - await this.voiceInputLifecycleController.toggle(this.host.context(), this.voiceInputSnapshot(route)); - } - - async stopVoiceInput(showStatus: boolean): Promise { - const route = this.currentRoute(); - await this.voiceInputLifecycleController.stop(this.voiceInputSnapshot(route), showStatus); - } - - showVoiceInputError(message: string): void { - const text = message.length > 0 ? message : RemoteI18n.t('errors.voiceInputUnavailable'); - this.setVisibleStatusText(text); - this.host.showToast(text, 2600); - } - - async pickImages(): Promise { - if (this.visibleChatBusy()) { - return; - } - const route = this.currentRoute(); - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - try { - this.setVisibleStatusText(RemoteI18n.t('status.pickImage')); - const picked = await this.imagePickerService.pickImages(3, this.visibleSelectedImages().length); - if (picked.length === 0) { - this.setVisibleStatusText(RemoteI18n.t('status.noImageSelected')); - return; - } - this.addSelectedImagesForRoute(route, picked); - this.setVisibleStatusText(RemoteI18n.f('status.imagesSelected', `${this.visibleSelectedImages().length}`)); - } catch (err) { - this.setVisibleStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - removeSelectedImage(imageId: string): void { - this.removeSelectedImageForRoute(this.currentRoute(), imageId); - } - - startVisibleGeneralChat(): void { - const rawText = this.generalChatPageState.chatInput.trim(); - const text = rawText.length > 0 ? rawText : - (this.generalChatPageState.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - if (text.length === 0 || this.generalChatPageState.isBusy) { - return; - } - if (this.generalChatPageState.serviceState === GeneralChatServiceState.Unconfigured) { - const statusText = GeneralChatServiceStatus.userMessage(this.generalChatPageState.serviceState); - this.generalChatPageState.setStatus(statusText); - this.showHomeToast(statusText); - return; - } - this.startGeneralChat(text); - } - - generalChatHomeStatusText(): string { - if (this.generalChatPageState.serviceState === GeneralChatServiceState.Ready || - this.generalChatPageState.serviceState === GeneralChatServiceState.Sending || - this.generalChatPageState.serviceState === GeneralChatServiceState.Streaming) { - return ''; - } - return GeneralChatServiceStatus.userMessage( - this.generalChatPageState.serviceState, - this.generalChatPageState.statusText - ); - } - - prepareNewGeneralChat(): void { - this.stopVoiceInput(false); - this.stopGeneralChatStream(true); - this.generalChatDraftLifecycleController.clearHome(); - this.generalChatPageState.clearComposer(); - this.generalChatPageState.clearActiveSession(); - this.resetGeneralChatTimeline(''); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - - onVisibleChatInputChange(route: AppRoute, value: string): void { - this.setChatInputForRoute(route, value); - if (!this.isGeneralComposerRoute(route)) { - return; - } - this.generalChatDraftLifecycleController.scheduleVisible(value); - } - - visibleGeneralChatDraftId(): string { - if (this.isGeneralChatVisible()) { - return this.generalChatPageState.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID; - } - return ''; - } - - persistVisibleGeneralChatDraft(): void { - this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); - } - - async restoreGeneralChatDraft(draftId: string): Promise { - this.generalChatPageState.setChatInput(await this.generalChatDraftLifecycleController.restore(draftId)); - } - - latestUserMessageText(): string { - if (this.isGeneralChatVisible()) { - return this.generalChatPageState.latestUserMessageText(); - } - const candidates = this.messages.concat(this.pendingMessages); - 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 ''; - } - - showHomeToast(message: string): void { - if (!this.host.showToast(message, 2600)) { - this.setVisibleStatusText(message); - } - } - - async stopActiveTask(): Promise { - const sessionId = this.activeSession.sessionId || ''; - if (!sessionId) { - return; - } - await this.remoteChatCommandController.stopTask( - sessionId, - this.activeTurnMessage.id, - this.currentActiveTurnId(), - this.ensureRemoteAvailable() - ); - } - - async renameActiveSession(title: string): Promise { - const nextTitle = title.trim(); - if ( - !this.activeSession.sessionId || - nextTitle.length === 0 || - nextTitle === this.activeSession.title || - this.isBusy - ) { - return; - } - await this.remoteChatCommandController.renameActiveSession( - this.activeSession, - nextTitle, - this.isBusy, - this.ensureRemoteAvailable() - ); - } - - async copyMessage(text: string): Promise { - if (text.trim().length === 0) { - return; - } - try { - await this.clipboardService.writeText(text); - this.setRemoteStatusText(RemoteI18n.t('status.messageCopied')); - } catch (err) { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - async downloadFile(path: string): Promise { - const sessionId = this.activeSession.sessionId || ''; - await this.remoteFileDownloadController.download(path, sessionId, this.isBusy, this.ensureRemoteAvailable()); - } - - retryMessage(text: string): void { - if (this.isBusy) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - this.remotePageState.setChatInput(text); - this.sendChatMessage(); - } - - async approveTool(toolId: string, updatedInput?: Object): Promise { - await this.remoteToolActionController.approve( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable(), - updatedInput - ); - } - - async rejectTool(toolId: string): Promise { - await this.remoteToolActionController.reject( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable() - ); - } - - async cancelTool(toolId: string): Promise { - await this.remoteToolActionController.cancel( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable() - ); - } - - async answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { - await this.remoteToolActionController.answer( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable(), - answers - ); - } - - resetChatTimeline(sessionId: string): void { - this.chatTimelineStore.reset(sessionId); - this.knownPollVersion = 0; - this.syncChatTimelineFromStore(); - } - - resetGeneralChatTimeline(sessionId: string): void { - this.chatTimelineStore.reset(sessionId); - this.syncGeneralChatTimelineFromStore(); - } - - syncChatTimelineFromStore(): void { - const state: ChatTimelineState = this.chatTimelineStore.snapshotState(); - const projectedItems: ChatTimelineItem[] = this.projectedTimelineItems(); - this.remotePageState.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - this.hasMoreMessages, - projectedItems - ); - this.remotePageState.setModelCatalog(state.modelCatalog, state.selectedModelId); - } - - syncGeneralChatTimelineFromStore(): void { - const state: ChatTimelineState = this.chatTimelineStore.snapshotState(); - const projectedItems = this.chatTimelineStore.viewState(false); - this.generalChatPageState.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.generalChatPageState.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); - } - - startPolling(): void { - this.remoteChatPollingLifecycleController.startActiveSession({ - sessionId: this.activeSession.sessionId || '', - cursor: this.currentChatPollingCursor(), - activeTurn: this.activeTurnMessage - }); - } - - stopPolling(): void { - this.remoteChatPollingLifecycleController.stop(); - } - - nudgeChatPolling(): void { - this.remoteChatPollingLifecycleController.nudge(); - } - - async pollActiveSession(): Promise { - await this.remoteChatPollingLifecycleController.pollNow(); - } - - currentChatPollingCursor(): RemoteChatPollingCursor { - return { - pollVersion: this.knownPollVersion, - knownMessageCount: this.knownRemoteMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }; - } - - updateChatPollingCursor(pollVersion: number, knownMessageCount: number): void { - this.knownPollVersion = pollVersion; - this.knownRemoteMessageCount = knownMessageCount; - this.remoteChatPollingLifecycleController.updateCursor({ - pollVersion, - knownMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }); - } - - applyChatSessionSnapshot(snapshot: RemoteChatPollingSnapshot): void { - if (!this.isRemoteConversationContext(snapshot.sessionId)) { - return; - } - this.chatTimelineStore.applySnapshot(snapshot); - this.syncChatTimelineFromStore(); - this.knownPollVersion = snapshot.cursor.pollVersion; - this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; - this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; - if (snapshot.title.length > 0) { - this.remotePageState.setActiveSession({ - sessionId: this.activeSession.sessionId, - title: snapshot.title, - workspacePath: this.activeSession.workspacePath, - agentType: this.activeSession.agentType - }); - } - if (snapshot.modelCatalog) { - this.remoteModelController.applyCatalog(snapshot.modelCatalog); - } - this.setRemoteStatusText(this.hasRunningActiveTurn() - ? RemoteI18n.t('status.desktopProcessing') - : RemoteI18n.t('status.messagesSynced')); - if (snapshot.shouldSyncAfterTurnEnded) { - this.syncAfterTurnEnded(); - } - } - - hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; - } - - currentActiveTurnId(): string { - const activeTurnMessage = this.isGeneralChatVisible() ? - this.generalChatPageState.activeTurnMessage : this.activeTurnMessage; - if (activeTurnMessage.turnId && activeTurnMessage.turnId.length > 0) { - return activeTurnMessage.turnId; - } - const activePrefix = 'active-'; - if (activeTurnMessage.id.indexOf(activePrefix) === 0) { - return activeTurnMessage.id.slice(activePrefix.length); - } - return ''; - } - - projectedTimelineItems(): ChatTimelineItem[] { - return this.chatTimelineStore.viewState(this.hasMoreMessages); - } - - startHeartbeat(): void { - this.remoteActivityViewModel.startHeartbeat(); - } - - stopHeartbeat(): void { - this.remoteActivityViewModel.stopHeartbeat(); - } - - async checkConnectionHealth(): Promise { - await this.remoteActivityViewModel.checkConnectionHealth(); - } - - resumeRemoteActivity(): void { - this.remoteActivityViewModel.resume(); - } - - hasRemoteBindingForResume(): boolean { - if (this.remotePageState.controlTargetType === 'account_device') { - return this.remotePageState.accountUserId.trim().length > 0 && - this.remotePageState.controlTargetDeviceId.trim().length > 0 && - this.connectionState !== ConnectionState.Idle && - this.connectionState !== ConnectionState.Disconnected; - } - return this.remoteUrl.trim().length > 0 && - this.userId.trim().length > 0 && - this.connectionState !== ConnectionState.Idle && - this.connectionState !== ConnectionState.Parsing && - this.connectionState !== ConnectionState.Pairing && - this.connectionState !== ConnectionState.Disconnected; - } - - private async reconnectActiveRemote(): Promise { - if (this.remotePageState.controlTargetType !== 'account_device') { - await this.connect(true); - return; - } - const targetId = this.remotePageState.controlTargetDeviceId; - const device = (await this.listCloudAccountDevices()).find((item: CloudAccountDevice): boolean => item.deviceId === targetId); - if (!device) { - throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); - } - await this.selectCloudAccountDevice(device); - } - - hasRemoteBindingForCodeHome(): boolean { - return this.remoteUrl.trim().length > 0 && - this.userId.trim().length > 0 && - (this.connectionState === ConnectionState.Connected || - this.connectionState === ConnectionState.Reconnecting || - this.connectionState === ConnectionState.Pairing || - this.connectionState === ConnectionState.Parsing); - } - - shortSessionId(sessionId: string): string { - if (sessionId.length <= 8) { - return sessionId; - } - return sessionId.slice(0, 4) + '...' + sessionId.slice(sessionId.length - 4); - } - - async syncAfterTurnEnded(): Promise { - if (this.isSyncingAfterTurn) { - return; - } - this.isSyncingAfterTurn = true; - try { - await this.loadActiveMessages(); - } finally { - this.isSyncingAfterTurn = false; - } - } - -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets index b9009f449f..e34e22c5d1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets @@ -12,12 +12,22 @@ export class AppShellState { @Trace showSettings: boolean = false; @Trace settingsMode: string = 'general'; @Trace showConnectSheet: boolean = false; + /** + * Mirror of the resolved master-detail layout mode. Only the presentation + * layer measures the viewport, so runtime logic that must branch on compact + * versus wide reads it from here. + */ + @Trace wideLayout: boolean = false; private accountReturnMode: string = ''; setSidebarVisible(visible: boolean): void { this.showSidebar = visible; } + setWideLayout(wide: boolean): void { + this.wideLayout = wide; + } + setSettingsVisible(visible: boolean): void { this.showSettings = visible; if (!visible) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets new file mode 100644 index 0000000000..f86b14dd1c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets @@ -0,0 +1,191 @@ +import { + ChatMessage, + RemoteModelCatalog, + RemoteModelConfig, + RemoteSession, + SelectedImageAttachment, + SessionSummary +} from '../../model/RemoteModels'; +import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { RemoteUiState } from '../../services/RemoteUiState'; + +/** Shared observable state for General Chat and Remote Chat conversations. */ +@ObservedV2 +export class ConversationCoreState { + @Trace sessions: RemoteSession[] = []; + @Trace activeSession: SessionSummary; + @Trace persistedMessages: ChatMessage[] = []; + @Trace optimisticMessages: ChatMessage[] = []; + @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); + @Trace hasMoreMessages: boolean = false; + @Trace timelineItems: ChatTimelineItem[] = []; + @Trace timelineRevision: number = 0; + @Trace isBusy: boolean = false; + @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); + @Trace selectedModelId: string = ''; + @Trace statusText: string = ''; + @Trace chatInput: string = ''; + @Trace selectedImages: SelectedImageAttachment[] = []; + @Trace isVoiceListening: boolean = false; + private readonly defaultAgentType: string; + private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + + constructor(defaultAgentType: string) { + this.defaultAgentType = defaultAgentType; + this.activeSession = ConversationCoreState.emptySession(defaultAgentType); + } + + setSessions(sessions: RemoteSession[]): void { + this.sessions = sessions.slice(); + } + + setActiveSession(session: SessionSummary): void { + this.activeSession = { + sessionId: session.sessionId, + title: session.title, + workspacePath: session.workspacePath, + agentType: this.defaultAgentType === 'chat' ? 'chat' : session.agentType, + initialTurnId: session.initialTurnId + }; + } + + clearActiveSession(): void { + this.activeSession = ConversationCoreState.emptySession(this.defaultAgentType); + this.clearTimeline(); + } + + setTimelineProjection( + persistedMessages: ChatMessage[], + optimisticMessages: ChatMessage[], + activeTurnMessage: ChatMessage, + hasMoreMessages: boolean, + timelineItems: ChatTimelineItem[] + ): void { + this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); + this.persistedMessages = persistedMessages.slice(); + this.optimisticMessages = optimisticMessages.slice(); + this.activeTurnMessage = activeTurnMessage.id.length > 0 ? + ConversationCoreState.copyMessage(activeTurnMessage) : + RemoteUiState.emptyActiveTurn(); + this.hasMoreMessages = hasMoreMessages; + this.timelineItems = timelineItems.slice(); + } + + setHasMoreMessages(hasMoreMessages: boolean): void { + this.hasMoreMessages = hasMoreMessages; + } + + clearTimeline(): void { + this.persistedMessages = []; + this.optimisticMessages = []; + this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); + this.hasMoreMessages = false; + this.timelineItems = []; + this.timelineRevision = this.timelineRevisionTracker.reset(); + } + + setBusy(isBusy: boolean): void { + this.isBusy = isBusy; + } + + setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { + this.modelCatalog = ConversationCoreState.copyModelCatalog(modelCatalog); + this.selectedModelId = selectedModelId; + } + + setStatusText(statusText: string): void { + this.statusText = statusText; + } + + setChatInput(chatInput: string): void { + this.chatInput = chatInput; + } + + setSelectedImages(selectedImages: SelectedImageAttachment[]): void { + this.selectedImages = selectedImages.slice(); + } + + addSelectedImages(selectedImages: SelectedImageAttachment[]): void { + this.selectedImages = this.selectedImages.concat(selectedImages); + } + + removeSelectedImage(imageId: string): void { + this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + } + + clearComposer(): void { + this.chatInput = ''; + this.selectedImages = []; + } + + setVoiceListening(isVoiceListening: boolean): void { + this.isVoiceListening = isVoiceListening; + } + + hasRunningActiveTurn(): boolean { + return this.activeTurnMessage.id.length > 0 && + (this.activeTurnMessage.status || '').toLowerCase() === 'active'; + } + + latestUserMessageText(): string { + const candidates = this.persistedMessages.concat(this.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 ''; + } + + private static emptySession(agentType: string): SessionSummary { + return { + sessionId: '', + title: '', + workspacePath: '', + agentType + }; + } + + private static copyMessage(message: ChatMessage): ChatMessage { + return { + id: message.id, + role: message.role, + text: message.text, + status: message.status, + renderVersion: message.renderVersion, + turnId: message.turnId, + detail: message.detail, + timestamp: message.timestamp, + thinking: message.thinking, + tools: message.tools ? message.tools.slice() : undefined, + items: message.items ? message.items.slice() : undefined, + images: message.images ? message.images.slice() : undefined + }; + } + + private static copyModelCatalog(modelCatalog: RemoteModelCatalog): RemoteModelCatalog { + return { + version: modelCatalog.version, + models: modelCatalog.models.map((model): RemoteModelConfig => { + return { + id: model.id, + name: model.name, + provider: model.provider, + base_url: model.base_url, + model_name: model.model_name, + context_window: model.context_window, + enabled: model.enabled, + capabilities: model.capabilities.slice(), + reasoning: model.reasoning + }; + }), + default_models: { + primary: modelCatalog.default_models.primary, + fast: modelCatalog.default_models.fast, + search: modelCatalog.default_models.search, + image_understanding: modelCatalog.default_models.image_understanding + }, + session_model_id: modelCatalog.session_model_id + }; + } +} 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 a7db3241bc..a555f25aa0 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 @@ -18,6 +18,7 @@ import { toConversationUiSession } from '../components/ConversationUiModels'; import { AppRoute } from '../navigation/AppRouteContract'; +import { ConversationCoreState } from './ConversationCoreState'; import { GeneralChatPageState } from './GeneralChatPageState'; import { RemotePageState } from './RemotePageState'; @@ -32,6 +33,7 @@ export class ConversationViewState { connectionState: string = 'idle'; composerCapabilities: ChatComposerCapabilities = GENERAL_CHAT_COMPOSER_CAPABILITIES; isBusy: boolean = false; + isLoadingConversation: boolean = false; canStop: boolean = false; hasMoreMessages: boolean = false; timelineItems: ChatTimelineItem[] = []; @@ -63,49 +65,43 @@ export class ConversationViewState { } private static remote(remote: RemotePageState): ConversationViewState { - const state = new ConversationViewState(); - state.activeSession = toConversationUiSession(remote.activeSession); + const state = ConversationViewState.fromCore(remote.conversation); state.surface = ChatSurface.Remote; state.desktopName = remote.desktopName; state.workspaceBranch = remote.workspaceBranch; - state.statusText = remote.statusText; state.connectionState = remote.connectionState; + state.isLoadingConversation = remote.isLoadingConversation; state.composerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; - state.isBusy = remote.isBusy; - state.canStop = remote.hasRunningActiveTurn(); - state.hasMoreMessages = remote.hasMoreMessages; - state.timelineItems = remote.timelineItems; - state.timelineRevision = remote.timelineRevision; state.showSuggestionsWhenEmpty = false; - state.modelCatalog = toConversationUiModelCatalog(remote.modelCatalog); - state.selectedModelId = remote.selectedModelId; state.downloadingFilePath = remote.downloadingFilePath; state.downloadedFilePath = remote.downloadedFilePath; state.fileDownloadStatus = remote.fileDownloadStatus; - state.selectedImages = remote.selectedImages.map((image) => toConversationUiSelectedImage(image)); - state.isVoiceListening = remote.isVoiceListening; - state.chatInput = remote.chatInput; return state; } private static general(general: GeneralChatPageState, inlineStatus: string): ConversationViewState { - const state = new ConversationViewState(); - state.activeSession = toConversationUiSession(general.activeSession); - state.statusText = general.statusText; + const state = ConversationViewState.fromCore(general.conversation); state.inlineStatusText = inlineStatus; state.connectionState = GeneralChatServiceStatus.connectionState(general.serviceState); - state.isBusy = general.isBusy; - state.canStop = general.hasRunningActiveTurn(); - state.hasMoreMessages = general.hasMoreMessages; - state.timelineItems = general.timelineItems; - state.timelineRevision = general.timelineRevision; - state.modelCatalog = toConversationUiModelCatalog(general.modelCatalog); - state.selectedModelId = general.selectedModelId; - state.isSessionPinned = general.activeSession.sessionId.length > 0 && - general.pinnedSessionId() === general.activeSession.sessionId; - state.selectedImages = general.selectedImages.map((image) => toConversationUiSelectedImage(image)); - state.isVoiceListening = general.isVoiceListening; - state.chatInput = general.chatInput; + state.isSessionPinned = general.conversation.activeSession.sessionId.length > 0 && + general.pinnedSessionId() === general.conversation.activeSession.sessionId; + return state; + } + + private static fromCore(core: ConversationCoreState): ConversationViewState { + const state = new ConversationViewState(); + state.activeSession = toConversationUiSession(core.activeSession); + state.statusText = core.statusText; + state.isBusy = core.isBusy; + state.canStop = core.hasRunningActiveTurn(); + state.hasMoreMessages = core.hasMoreMessages; + state.timelineItems = core.timelineItems; + state.timelineRevision = core.timelineRevision; + state.modelCatalog = toConversationUiModelCatalog(core.modelCatalog); + state.selectedModelId = core.selectedModelId; + state.selectedImages = core.selectedImages.map((image) => toConversationUiSelectedImage(image)); + state.isVoiceListening = core.isVoiceListening; + state.chatInput = core.chatInput; return state; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets index 88351095ff..7d51e1a0a7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets @@ -1,4 +1,4 @@ -import { FilePreviewTarget } from './FilePreviewTarget'; +import { FilePreviewTarget } from '../../model/FilePreviewTarget'; export enum FilePreviewPhase { Idle = 'idle', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets index 5c4db437fe..a6bcb8bd0f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets @@ -5,50 +5,44 @@ import { SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; -import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { GeneralChatServiceState } from '../../services/general-chat/GeneralChatServiceState'; -import { RemoteUiState } from '../../services/RemoteUiState'; +import { ConversationCoreState } from './ConversationCoreState'; @ObservedV2 export class GeneralChatPageState { - @Trace activeSession: SessionSummary = GeneralChatPageState.emptySession(); - @Trace sessions: RemoteSession[] = []; - @Trace persistedMessages: ChatMessage[] = []; - @Trace optimisticMessages: ChatMessage[] = []; - @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); - @Trace hasMoreMessages: boolean = false; - @Trace timelineItems: ChatTimelineItem[] = []; - @Trace timelineRevision: number = 0; - @Trace isBusy: boolean = false; + @Trace conversation: ConversationCoreState = new ConversationCoreState('chat'); @Trace serviceState: GeneralChatServiceState = GeneralChatServiceState.Unconfigured; @Trace apiUrl: string = ''; @Trace modelName: string = ''; @Trace hasApiKey: boolean = false; - @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); - @Trace selectedModelId: string = ''; - @Trace statusText: string = ''; - @Trace chatInput: string = ''; - @Trace selectedImages: SelectedImageAttachment[] = []; - @Trace isVoiceListening: boolean = false; - private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + + get activeSession(): SessionSummary { return this.conversation.activeSession; } + get sessions(): RemoteSession[] { return this.conversation.sessions; } + get persistedMessages(): ChatMessage[] { return this.conversation.persistedMessages; } + get optimisticMessages(): ChatMessage[] { return this.conversation.optimisticMessages; } + get activeTurnMessage(): ChatMessage { return this.conversation.activeTurnMessage; } + get hasMoreMessages(): boolean { return this.conversation.hasMoreMessages; } + get timelineItems(): ChatTimelineItem[] { return this.conversation.timelineItems; } + get timelineRevision(): number { return this.conversation.timelineRevision; } + get isBusy(): boolean { return this.conversation.isBusy; } + get modelCatalog(): RemoteModelCatalog { return this.conversation.modelCatalog; } + get selectedModelId(): string { return this.conversation.selectedModelId; } + get statusText(): string { return this.conversation.statusText; } + get chatInput(): string { return this.conversation.chatInput; } + get selectedImages(): SelectedImageAttachment[] { return this.conversation.selectedImages; } + get isVoiceListening(): boolean { return this.conversation.isVoiceListening; } setActiveSession(session: SessionSummary): void { - this.activeSession = { - sessionId: session.sessionId, - title: session.title, - workspacePath: session.workspacePath, - agentType: 'chat', - initialTurnId: session.initialTurnId - }; + this.conversation.setActiveSession(session); } clearActiveSession(): void { - this.activeSession = GeneralChatPageState.emptySession(); - this.clearTimeline(); + this.conversation.clearActiveSession(); } setSessions(sessions: RemoteSession[]): void { - this.sessions = sessions.slice(); + this.conversation.setSessions(sessions); } setTimelineProjection( @@ -58,27 +52,21 @@ export class GeneralChatPageState { hasMoreMessages: boolean, timelineItems: ChatTimelineItem[] ): void { - this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); - this.persistedMessages = persistedMessages.slice(); - this.optimisticMessages = optimisticMessages.slice(); - this.activeTurnMessage = activeTurnMessage.id.length > 0 ? - GeneralChatPageState.copyMessage(activeTurnMessage) : - RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = hasMoreMessages; - this.timelineItems = timelineItems.slice(); + this.conversation.setTimelineProjection( + persistedMessages, + optimisticMessages, + activeTurnMessage, + hasMoreMessages, + timelineItems + ); } clearTimeline(): void { - this.persistedMessages = []; - this.optimisticMessages = []; - this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = false; - this.timelineItems = []; - this.timelineRevision = this.timelineRevisionTracker.reset(); + this.conversation.clearTimeline(); } setBusy(isBusy: boolean): void { - this.isBusy = isBusy; + this.conversation.setBusy(isBusy); } setConfiguration( @@ -98,46 +86,39 @@ export class GeneralChatPageState { } setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { - this.modelCatalog = { - version: modelCatalog.version, - models: modelCatalog.models.slice(), - default_models: modelCatalog.default_models, - session_model_id: modelCatalog.session_model_id - }; - this.selectedModelId = selectedModelId; + this.conversation.setModelCatalog(modelCatalog, selectedModelId); } setStatus(statusText: string): void { - this.statusText = statusText; + this.conversation.setStatusText(statusText); } setChatInput(chatInput: string): void { - this.chatInput = chatInput; + this.conversation.setChatInput(chatInput); } setSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = selectedImages.slice(); + this.conversation.setSelectedImages(selectedImages); } addSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = this.selectedImages.concat(selectedImages); + this.conversation.addSelectedImages(selectedImages); } removeSelectedImage(imageId: string): void { - this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + this.conversation.removeSelectedImage(imageId); } clearComposer(): void { - this.chatInput = ''; - this.selectedImages = []; + this.conversation.clearComposer(); } setVoiceListening(isVoiceListening: boolean): void { - this.isVoiceListening = isVoiceListening; + this.conversation.setVoiceListening(isVoiceListening); } recentSessions(): RemoteSession[] { - const recent = this.sessions.slice(); + const recent = this.conversation.sessions.slice(); recent.sort((first: RemoteSession, second: RemoteSession) => { if ((first.pinned === true) !== (second.pinned === true)) { return first.pinned === true ? -1 : 1; @@ -148,53 +129,20 @@ export class GeneralChatPageState { } pinnedSessionId(): string { - const pinned = this.sessions.find((session: RemoteSession) => session.pinned === true); + const pinned = this.conversation.sessions.find((session: RemoteSession) => session.pinned === true); return pinned ? pinned.id : ''; } hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; + return this.conversation.hasRunningActiveTurn(); } latestUserMessageText(): string { - const candidates = this.persistedMessages.concat(this.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 ''; + return this.conversation.latestUserMessageText(); } private sessionTimeValue(value: string): number { const parsed = new Date(value).getTime(); return Number.isNaN(parsed) ? 0 : parsed; } - - private static emptySession(): SessionSummary { - return { - sessionId: '', - title: '', - workspacePath: '', - agentType: 'chat' - }; - } - - private static copyMessage(message: ChatMessage): ChatMessage { - return { - id: message.id, - role: message.role, - text: message.text, - status: message.status, - renderVersion: message.renderVersion, - turnId: message.turnId, - detail: message.detail, - timestamp: message.timestamp, - thinking: message.thinking, - tools: message.tools ? message.tools.slice() : undefined, - items: message.items ? message.items.slice() : undefined, - images: message.images ? message.images.slice() : undefined - }; - } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets index 27e2d77f75..cb77d09d48 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets @@ -94,8 +94,18 @@ export class RemoteCreateSessionState { this.errorText = ''; } + /** + * The desktop binds every Claw session to its assistant workspace and ignores + * the requested workspace_path, so a picked workspace only holds when it is + * paired with the code agent. No workspace means the chat option, which is + * what Claw is for. + */ submissionContext(): RemoteCreateSessionContext { - return new RemoteCreateSessionContext(this.selectedDeviceId, this.selectedWorkspacePath); + return new RemoteCreateSessionContext( + this.selectedDeviceId, + this.selectedWorkspacePath, + this.selectedWorkspacePath.length > 0 ? 'code' : 'Claw' + ); } clearWorkspace(): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index 8af7b5da7f..3284ff69fb 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets @@ -3,13 +3,13 @@ import { ChatMessage, RecentWorkspaceEntry, RemoteModelCatalog, - RemoteModelConfig, RemoteSession, SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; -import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { RemoteUiState } from '../../services/RemoteUiState'; +import { ConversationCoreState } from './ConversationCoreState'; /** * Observable projection for the Remote home page. @@ -17,6 +17,7 @@ import { RemoteUiState } from '../../services/RemoteUiState'; */ @ObservedV2 export class RemotePageState { + @Trace conversation: ConversationCoreState = new ConversationCoreState('code'); @Trace desktopName: string = ''; @Trace desktopId: string = ''; @Trace remoteUrl: string = ''; @@ -28,10 +29,8 @@ export class RemotePageState { @Trace controlTargetType: string = 'none'; @Trace controlTargetDeviceId: string = ''; @Trace controlTargetDeviceName: string = ''; - @Trace statusText: string = ''; @Trace connectionState: string = 'idle'; @Trace connectionFailureKind: string = ''; - @Trace isBusy: boolean = false; @Trace isLoadingHome: boolean = false; @Trace showRemoteUrlInput: boolean = false; @Trace workspaceName: string = ''; @@ -43,28 +42,32 @@ export class RemotePageState { @Trace assistants: AssistantEntry[] = []; @Trace showWorkspacePicker: boolean = false; @Trace showAssistantPicker: boolean = false; - @Trace sessions: RemoteSession[] = []; - @Trace activeSession: SessionSummary = RemotePageState.emptySession(); - @Trace persistedMessages: ChatMessage[] = []; - @Trace optimisticMessages: ChatMessage[] = []; - @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); - @Trace hasMoreMessages: boolean = false; - @Trace timelineItems: ChatTimelineItem[] = []; - @Trace timelineRevision: number = 0; - @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); - @Trace selectedModelId: string = ''; @Trace downloadingFilePath: string = ''; @Trace downloadedFilePath: string = ''; @Trace fileDownloadStatus: string = ''; - @Trace chatInput: string = ''; - @Trace selectedImages: SelectedImageAttachment[] = []; - @Trace isVoiceListening: boolean = false; @Trace sessionQuery: string = ''; @Trace sessionFilter: string = 'all'; @Trace hasMoreSessions: boolean = false; @Trace isLoadingSessions: boolean = false; + @Trace isLoadingConversation: boolean = false; + @Trace pendingSessionId: string = ''; + @Trace isConversationDismissed: boolean = false; @Trace sessionErrorText: string = ''; - private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + get activeSession(): SessionSummary { return this.conversation.activeSession; } + get sessions(): RemoteSession[] { return this.conversation.sessions; } + get persistedMessages(): ChatMessage[] { return this.conversation.persistedMessages; } + get optimisticMessages(): ChatMessage[] { return this.conversation.optimisticMessages; } + get activeTurnMessage(): ChatMessage { return this.conversation.activeTurnMessage; } + get hasMoreMessages(): boolean { return this.conversation.hasMoreMessages; } + get timelineItems(): ChatTimelineItem[] { return this.conversation.timelineItems; } + get timelineRevision(): number { return this.conversation.timelineRevision; } + get isBusy(): boolean { return this.conversation.isBusy; } + get modelCatalog(): RemoteModelCatalog { return this.conversation.modelCatalog; } + get selectedModelId(): string { return this.conversation.selectedModelId; } + get statusText(): string { return this.conversation.statusText; } + get chatInput(): string { return this.conversation.chatInput; } + get selectedImages(): SelectedImageAttachment[] { return this.conversation.selectedImages; } + get isVoiceListening(): boolean { return this.conversation.isVoiceListening; } setQuery(query: string): void { this.sessionQuery = query; @@ -125,7 +128,7 @@ export class RemotePageState { } setStatusText(statusText: string): void { - this.statusText = statusText; + this.conversation.setStatusText(statusText); } setConnectionState(connectionState: string): void { @@ -137,7 +140,7 @@ export class RemotePageState { } setBusy(isBusy: boolean): void { - this.isBusy = isBusy; + this.conversation.setBusy(isBusy); } setLoadingHome(isLoadingHome: boolean): void { @@ -184,24 +187,20 @@ export class RemotePageState { } setSessions(sessions: RemoteSession[], hasMore: boolean): void { - this.sessions = sessions.slice(); + this.conversation.setSessions(sessions); this.hasMoreSessions = hasMore; this.sessionErrorText = ''; } setActiveSession(session: SessionSummary): void { - this.activeSession = { - sessionId: session.sessionId, - title: session.title, - workspacePath: session.workspacePath, - agentType: session.agentType, - initialTurnId: session.initialTurnId - }; + this.conversation.setActiveSession(session); } clearActiveSession(): void { - this.activeSession = RemotePageState.emptySession(); - this.clearTimeline(); + this.conversation.clearActiveSession(); + this.isLoadingConversation = false; + this.pendingSessionId = ''; + this.isConversationDismissed = false; this.setModelCatalog(RemoteUiState.emptyModelCatalog(), ''); } @@ -212,32 +211,25 @@ export class RemotePageState { hasMoreMessages: boolean, timelineItems: ChatTimelineItem[] ): void { - this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); - this.persistedMessages = persistedMessages.slice(); - this.optimisticMessages = optimisticMessages.slice(); - this.activeTurnMessage = activeTurnMessage.id.length > 0 ? - RemotePageState.copyMessage(activeTurnMessage) : - RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = hasMoreMessages; - this.timelineItems = timelineItems.slice(); + this.conversation.setTimelineProjection( + persistedMessages, + optimisticMessages, + activeTurnMessage, + hasMoreMessages, + timelineItems + ); } setHasMoreMessages(hasMoreMessages: boolean): void { - this.hasMoreMessages = hasMoreMessages; + this.conversation.setHasMoreMessages(hasMoreMessages); } clearTimeline(): void { - this.persistedMessages = []; - this.optimisticMessages = []; - this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = false; - this.timelineItems = []; - this.timelineRevision = this.timelineRevisionTracker.reset(); + this.conversation.clearTimeline(); } setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { - this.modelCatalog = RemotePageState.copyModelCatalog(modelCatalog); - this.selectedModelId = selectedModelId; + this.conversation.setModelCatalog(modelCatalog, selectedModelId); } setDownloadStatus(downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string): void { @@ -257,43 +249,57 @@ export class RemotePageState { } setChatInput(chatInput: string): void { - this.chatInput = chatInput; + this.conversation.setChatInput(chatInput); } setSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = selectedImages.slice(); + this.conversation.setSelectedImages(selectedImages); } addSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = this.selectedImages.concat(selectedImages); + this.conversation.addSelectedImages(selectedImages); } removeSelectedImage(imageId: string): void { - this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + this.conversation.removeSelectedImage(imageId); } clearComposer(): void { - this.chatInput = ''; - this.selectedImages = []; + this.conversation.clearComposer(); } setVoiceListening(isVoiceListening: boolean): void { - this.isVoiceListening = isVoiceListening; + this.conversation.setVoiceListening(isVoiceListening); } setLoading(loading: boolean): void { this.isLoadingSessions = loading; } + setConversationLoading(loading: boolean): void { + this.isLoadingConversation = loading; + } + + setPendingSessionId(sessionId: string): void { + this.pendingSessionId = sessionId; + } + + setConversationDismissed(dismissed: boolean): void { + this.isConversationDismissed = dismissed; + } + setError(errorText: string): void { this.sessionErrorText = errorText; this.isLoadingSessions = false; } clear(): void { - this.sessions = []; + this.conversation.setSessions([]); this.hasMoreSessions = false; this.isLoadingSessions = false; + this.isLoadingConversation = false; + this.pendingSessionId = ''; + this.isConversationDismissed = false; this.isLoadingHome = false; this.sessionErrorText = ''; } @@ -306,7 +312,7 @@ export class RemotePageState { visibleSessions(): RemoteSession[] { const query = this.sessionQuery.trim().toLowerCase(); - return this.sessions.filter((item: RemoteSession) => { + return this.conversation.sessions.filter((item: RemoteSession) => { if (item.id.length === 0 || item.status === 'archived') { return false; } @@ -315,59 +321,6 @@ export class RemotePageState { } hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; - } - - private static emptySession(): SessionSummary { - return { - sessionId: '', - title: '', - workspacePath: '', - agentType: 'code' - }; - } - - private static copyMessage(message: ChatMessage): ChatMessage { - return { - id: message.id, - role: message.role, - text: message.text, - status: message.status, - renderVersion: message.renderVersion, - turnId: message.turnId, - detail: message.detail, - timestamp: message.timestamp, - thinking: message.thinking, - tools: message.tools ? message.tools.slice() : undefined, - items: message.items ? message.items.slice() : undefined, - images: message.images ? message.images.slice() : undefined - }; - } - - private static copyModelCatalog(modelCatalog: RemoteModelCatalog): RemoteModelCatalog { - return { - version: modelCatalog.version, - models: modelCatalog.models.map((model): RemoteModelConfig => { - return { - id: model.id, - name: model.name, - provider: model.provider, - base_url: model.base_url, - model_name: model.model_name, - context_window: model.context_window, - enabled: model.enabled, - capabilities: model.capabilities.slice(), - reasoning: model.reasoning - }; - }), - default_models: { - primary: modelCatalog.default_models.primary, - fast: modelCatalog.default_models.fast, - search: modelCatalog.default_models.search, - image_understanding: modelCatalog.default_models.image_understanding - }, - session_model_id: modelCatalog.session_model_id - }; + return this.conversation.hasRunningActiveTurn(); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets similarity index 98% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets index 53bf57347e..2b723f14d5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets @@ -4,7 +4,7 @@ import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; -import { AppShellState } from './AppShellState'; +import { AppShellState } from '../state/AppShellState'; /** Owns application navigation and global overlay state. */ export class AppShellViewModel { 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 new file mode 100644 index 0000000000..b43aa96612 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -0,0 +1,1038 @@ +import { + RecentWorkspaceEntry, + RemoteImageContext, + RemoteQuestionAnswerPayload, + RemoteSession, + SessionSummary, + SelectedImageAttachment +} from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; +import { ChatTimelineState } 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 { 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 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. */ +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 remoteCreateWorkspaceLoadVersion: number = 0; + + constructor( + general: GeneralChatPageState, + remote: RemotePageState, + remoteCreate: RemoteCreateSessionState, + hooks: ConversationControllerHooks, + remoteRuntime?: RemoteConversationDependencies + ) { + this.general = general; + this.remote = remote; + this.remoteCreate = remoteCreate; + this.hooks = hooks; + this.remoteRuntime = remoteRuntime; + } + + visibleChatInput(): string { + const route = this.hooks.currentRoute(); + return route === AppRoute.RemoteCreate ? this.remoteCreate.draft : + AppRootRouteState.chatInput(route, this.general, this.remote); + } + + visibleSelectedImages(): SelectedImageAttachment[] { + return AppRootRouteState.selectedImages(this.hooks.currentRoute(), this.general, this.remote); + } + + visibleVoiceListening(): boolean { + const route = this.hooks.currentRoute(); + return route === AppRoute.RemoteCreate ? this.remoteCreate.isVoiceListening : + AppRootRouteState.voiceListening(route, this.general, this.remote); + } + + setChatInput(route: AppRoute, value: string): void { + if (route === AppRoute.RemoteCreate) { + this.remoteCreate.setDraft(value); + return; + } + AppRootRouteState.setChatInput(route, value, this.general, this.remote); + } + + addSelectedImages(route: AppRoute, images: SelectedImageAttachment[]): void { + AppRootRouteState.addSelectedImages(route, images, this.general, this.remote); + } + + removeSelectedImage(route: AppRoute, imageId: string): void { + AppRootRouteState.removeSelectedImage(route, imageId, this.general, this.remote); + } + + setVoiceListening(route: AppRoute, isVoiceListening: boolean): void { + if (route === AppRoute.RemoteCreate) { + this.remoteCreate.isVoiceListening = isVoiceListening; + return; + } + AppRootRouteState.setVoiceListening(route, isVoiceListening, this.general, this.remote); + } + + clearAllVoiceListening(): void { + this.general.setVoiceListening(false); + this.remote.setVoiceListening(false); + this.remoteCreate.isVoiceListening = false; + } + + visibleBusy(): boolean { + return this.isGeneralComposerRoute(this.hooks.currentRoute()) ? + this.general.isBusy : this.remote.isBusy; + } + + visibleStatusText(): string { + return this.isGeneralComposerRoute(this.hooks.currentRoute()) ? + this.general.statusText : this.remote.statusText; + } + + setVisibleStatusText(statusText: string): void { + if (this.isGeneralComposerRoute(this.hooks.currentRoute())) { + this.general.setStatus(statusText); + return; + } + this.remote.setStatusText(statusText); + } + + voiceInputSnapshot(route: AppRoute): VoiceInputRouteSnapshot { + if (route === AppRoute.RemoteCreate) { + return { + routeId: `${route}`, + isListening: this.remoteCreate.isVoiceListening, + isBusy: this.remoteCreate.isSubmitting, + inputText: this.remoteCreate.draft, + selectedImageCount: 0 + }; + } + return AppRootRouteState.snapshot(route, this.visibleBusy(), this.general, this.remote); + } + + isGeneralComposerRoute(route: AppRoute): boolean { + return AppRouteContract.isGeneralComposerRoute(route); + } + + 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; + this.requireRemoteRuntime().polling.updateKnownModelCatalogVersion(version); + } + + async loadRemoteMessages(): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.chat.loadMessages( + this.remote.activeSession.sessionId || '', + runtime.hooks.isConversationContext + ); + } + + async loadRemoteModelCatalog(sessionId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.models.loadCatalog( + sessionId, + runtime.connection.ensureAvailable(), + runtime.hooks.isConversationContext + ); + } + + async selectRemoteModel(modelId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.models.selectModel( + 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; + } + 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}`); + 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(); + } + + 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() + ); + } + + async cancelRemoteTool(toolId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.cancel( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable() + ); + } + + 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 + }); + } + + applyRemoteSnapshot(snapshot: RemoteChatPollingSnapshot): void { + const runtime = this.requireRemoteRuntime(); + if (!runtime.hooks.isConversationContext(snapshot.sessionId)) { + return; + } + runtime.timeline.applySnapshot(snapshot); + this.syncRemoteTimeline(); + 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(); + } + } + + hasRunningRemoteTurn(): boolean { + return this.remote.activeTurnMessage.id.length > 0 && + (this.remote.activeTurnMessage.status || '').toLowerCase() === 'active'; + } + + remoteActiveTurnId(): string { + const active = this.remote.activeTurnMessage; + if (active.turnId && active.turnId.length > 0) { + return active.turnId; + } + return active.id.indexOf('active-') === 0 ? active.id.slice('active-'.length) : ''; + } + + projectedRemoteTimelineItems(): ChatTimelineItem[] { + return this.requireRemoteRuntime().timeline.viewState(this.remote.hasMoreMessages); + } + + 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(); + } + } + + 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); + } + + 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)}`); + } + } + + 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) + ); + } + + 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) + ); + } + + 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); + } + + 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); + } + + 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 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); + } + + 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 : ''; + } + + 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 { + await this.loadRemoteMessages(); + } 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.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; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationViewModel.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationViewModel.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets new file mode 100644 index 0000000000..fc8300b1f6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets @@ -0,0 +1,92 @@ +import { FilePreviewRequest, FilePreviewTargetContext } from '../../model/FilePreviewTarget'; +import { SessionSummary } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { FileReferenceKind, FileTargetResolver } from '../../services/FileTargetResolver'; +import { RemoteWorkspaceFileClient } from '../../services/RemoteWorkspaceFileClient'; +import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { RemoteFilePreviewController } from './RemoteFilePreviewController'; + +export interface FilePreviewControllerHooks { + readonly remoteAvailable: () => boolean; + readonly activeSession: () => SessionSummary; + readonly workspacePath: () => string; + readonly openExternalLink: (reference: string) => Promise; + readonly onGeneralStatus: (statusText: string) => void; + readonly onRemoteStatus: (statusText: string) => void; +} + +/** Owns file-preview routing, target validity and the underlying remote file load. */ +export class FilePreviewController { + private readonly state: FilePreviewState; + private readonly hooks: FilePreviewControllerHooks; + private readonly loader: RemoteFilePreviewController; + private controlTargetEpoch: number = 1; + + constructor( + client: RemoteWorkspaceFileClient, + state: FilePreviewState, + hooks: FilePreviewControllerHooks + ) { + this.state = state; + this.hooks = hooks; + this.loader = new RemoteFilePreviewController( + client, + state, + hooks.remoteAvailable, + (): number => this.controlTargetEpoch + ); + } + + open(route: AppRoute, request: FilePreviewRequest): void { + const activeSession = this.hooks.activeSession(); + const context = new FilePreviewTargetContext( + activeSession.sessionId, + activeSession.workspacePath || this.hooks.workspacePath(), + this.controlTargetEpoch + ); + const resolution = FileTargetResolver.resolve(request.reference, request.label, context); + if (resolution.kind === FileReferenceKind.HttpUrl) { + void this.openExternalLink(route, request.reference); + return; + } + if (route !== AppRoute.RemoteChat) { + this.hooks.onGeneralStatus(RemoteI18n.t('generalChat.filePreviewUnavailable')); + return; + } + if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target) { + return; + } + void this.loader.open(resolution.target); + } + + close(): void { + this.loader.close(); + } + + refresh(): void { + void this.loader.refresh(); + } + + openLink(reference: string, label: string): void { + this.open(AppRoute.RemoteChat, new FilePreviewRequest(reference, label)); + } + + invalidate(): void { + this.controlTargetEpoch += 1; + this.loader.close(); + } + + private async openExternalLink(route: AppRoute, reference: string): Promise { + const opened = await this.hooks.openExternalLink(reference); + if (opened) { + return; + } + const statusText = RemoteI18n.t('errors.operationFailed'); + if (AppRouteContract.isGeneralComposerRoute(route)) { + this.hooks.onGeneralStatus(statusText); + return; + } + this.hooks.onRemoteStatus(statusText); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets similarity index 95% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets index cd489b440c..0248180436 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets @@ -8,7 +8,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { Encoding } from '../../services/Encoding'; import { ConversationViewModel } from './ConversationViewModel'; -import { GeneralChatPageState } from './GeneralChatPageState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; @@ -22,26 +22,12 @@ import { } from '../../services/general-chat/GeneralChatPort'; import { RemoteLogger } from '../../services/RemoteLogger'; -export class GeneralChatConversationViewModelHooks { +export interface GeneralChatConversationViewModelHooks { readonly isVisible: (sessionId: string) => boolean; readonly currentActiveTurnId: () => string; readonly latestUserMessageText: () => string; readonly syncTimeline: () => void; readonly refreshSessions: () => void; - - constructor( - isVisible: (sessionId: string) => boolean, - currentActiveTurnId: () => string, - latestUserMessageText: () => string, - syncTimeline: () => void, - refreshSessions: () => void - ) { - this.isVisible = isVisible; - this.currentActiveTurnId = currentActiveTurnId; - this.latestUserMessageText = latestUserMessageText; - this.syncTimeline = syncTimeline; - this.refreshSessions = refreshSessions; - } } /** Owns General Chat stream state and publishes all updates through ConversationViewModel. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets similarity index 78% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets index fd029d22ec..8228df45dd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets @@ -5,7 +5,7 @@ import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; -export class RemoteActivityViewModelHooks { +export interface RemoteActivityViewModelHooks { readonly isConnected: () => boolean; readonly isBusy: () => boolean; readonly hasRemoteBinding: () => boolean; @@ -20,38 +20,6 @@ export class RemoteActivityViewModelHooks { readonly onPoll: () => Promise; readonly onReconnect: () => Promise; readonly onRestoreSession: (session: SessionSummary) => Promise; - - constructor( - isConnected: () => boolean, - isBusy: () => boolean, - hasRemoteBinding: () => boolean, - isRemoteChat: () => boolean, - activeSession: () => SessionSummary, - onConnectionState: (state: string) => void, - onStatus: (status: string) => void, - onConnectionError: (err: Object) => Promise, - onStopHeartbeat: () => void, - onStartPolling: () => void, - onStopPolling: () => void, - onPoll: () => Promise, - onReconnect: () => Promise, - onRestoreSession: (session: SessionSummary) => Promise - ) { - this.isConnected = isConnected; - this.isBusy = isBusy; - this.hasRemoteBinding = hasRemoteBinding; - this.isRemoteChat = isRemoteChat; - this.activeSession = activeSession; - this.onConnectionState = onConnectionState; - this.onStatus = onStatus; - this.onConnectionError = onConnectionError; - this.onStopHeartbeat = onStopHeartbeat; - this.onStartPolling = onStartPolling; - this.onStopPolling = onStopPolling; - this.onPoll = onPoll; - this.onReconnect = onReconnect; - this.onRestoreSession = onRestoreSession; - } } /** Owns foreground recovery, heartbeat health checks, and idempotent resume cancellation. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets similarity index 99% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets index a15d5ca378..40e66955c8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets @@ -16,7 +16,7 @@ import { RemoteSessionController } from '../../services/RemoteSessionController' import { RemoteUiState } from '../../services/RemoteUiState'; import { QrScanService } from '../../services/QrScanService'; import { RemoteConnectionCoordinator, RemoteConnectionRequest } from '../../services/RemoteConnectionCoordinator'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; import { AppRoute } from '../navigation/AppRouteContract'; import { RemoteLogger } from '../../services/RemoteLogger'; @@ -30,7 +30,7 @@ export enum RemoteConnectionState { Disconnected = 'disconnected' } -export class RemoteConnectionViewModel { +export class RemoteConnectionController { private readonly pageState: RemotePageState; private readonly identity: MobileIdentityStore; private readonly pairing: RemotePairingPolicy; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets similarity index 95% rename from src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets index a3299361f5..c2de266168 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets @@ -1,15 +1,15 @@ -import { FileInfo, ReadFileChunkResult, ReadFileResult } from '../model/RemoteModels'; +import { FileInfo, ReadFileChunkResult, ReadFileResult } from '../../model/RemoteModels'; import { FilePreviewPhase, FilePreviewRendererKind, FilePreviewState -} from '../pages/state/FilePreviewState'; -import { FilePreviewTarget } from '../pages/state/FilePreviewTarget'; -import { RemoteI18n } from '../i18n/RemoteI18n'; -import { Encoding } from './Encoding'; -import { FilePreviewErrorPolicy } from './FilePreviewErrorPolicy'; -import { FilePreviewPolicy } from './FilePreviewPolicy'; -import { RemoteWorkspaceFileClient } from './RemoteWorkspaceFileClient'; +} from '../state/FilePreviewState'; +import { FilePreviewTarget } from '../../model/FilePreviewTarget'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { Encoding } from '../../services/Encoding'; +import { FilePreviewErrorPolicy } from '../../services/FilePreviewErrorPolicy'; +import { FilePreviewPolicy } from '../../services/FilePreviewPolicy'; +import { RemoteWorkspaceFileClient } from '../../services/RemoteWorkspaceFileClient'; export class RemoteFilePreviewController { private readonly client: RemoteWorkspaceFileClient; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets similarity index 74% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets index fbedef6d7d..42ab00f630 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets @@ -4,9 +4,9 @@ import { RemoteChatCommandController } from '../../services/RemoteChatCommandCon import { RemoteModelController } from '../../services/RemoteModelController'; import { RemoteSessionController } from '../../services/RemoteSessionController'; import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; -export class RemoteSessionViewModelHooks { +export interface RemoteSessionViewModelHooks { readonly remoteAvailable: () => boolean; readonly isConnected: () => boolean; readonly isBusy: () => boolean; @@ -22,40 +22,6 @@ export class RemoteSessionViewModelHooks { readonly onLoadActiveMessages: () => Promise; readonly onRefreshSessions: () => Promise; readonly onSelectWorkspace: (path: string) => Promise; - - constructor( - remoteAvailable: () => boolean, - isConnected: () => boolean, - isBusy: () => boolean, - onBusy: (busy: boolean) => void, - onRouteChat: (sessionId: string) => void, - onRouteHome: () => void, - onStopPolling: () => void, - onStartPolling: () => void, - onResetTimeline: (sessionId: string) => void, - onClearRemoteFiles: () => void, - onKnownStateReset: () => void, - onLoadModelCatalog: (sessionId: string) => Promise, - onLoadActiveMessages: () => Promise, - onRefreshSessions: () => Promise, - onSelectWorkspace: (path: string) => Promise - ) { - this.remoteAvailable = remoteAvailable; - this.isConnected = isConnected; - this.isBusy = isBusy; - this.onBusy = onBusy; - this.onRouteChat = onRouteChat; - this.onRouteHome = onRouteHome; - this.onStopPolling = onStopPolling; - this.onStartPolling = onStartPolling; - this.onResetTimeline = onResetTimeline; - this.onClearRemoteFiles = onClearRemoteFiles; - this.onKnownStateReset = onKnownStateReset; - this.onLoadModelCatalog = onLoadModelCatalog; - this.onLoadActiveMessages = onLoadActiveMessages; - this.onRefreshSessions = onRefreshSessions; - this.onSelectWorkspace = onSelectWorkspace; - } } /** Owns remote session commands and their page lifecycle effects. */ @@ -158,26 +124,38 @@ export class RemoteSessionViewModel { currentWorkspacePath: string, onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat ): Promise { - await this.sessions.open( - item, - item.workspacePath || currentWorkspacePath, - this.hooks.isBusy(), - this.hooks.remoteAvailable(), - async (session: SessionSummary): Promise => { - this.hooks.onStopPolling(); - this.hooks.onResetTimeline(item.id); - this.hooks.onKnownStateReset(); - this.pageState.setHasMoreMessages(false); - this.files.clear(); - this.pageState.clearComposer(); - onRouteChat(item.id); - await this.hooks.onLoadModelCatalog(item.id); - await this.hooks.onLoadActiveMessages(); - if (this.pageState.activeSession.sessionId === session.sessionId) { - this.hooks.onStartPolling(); + const isBusy = this.hooks.isBusy(); + const remoteAvailable = this.hooks.remoteAvailable(); + if (isBusy || item.id.length === 0 || !remoteAvailable) { + return; + } + this.pageState.setPendingSessionId(item.id); + this.pageState.setConversationLoading(true); + onRouteChat(item.id); + try { + await this.sessions.open( + item, + item.workspacePath || currentWorkspacePath, + false, + true, + async (session: SessionSummary): Promise => { + this.hooks.onStopPolling(); + this.hooks.onResetTimeline(item.id); + this.hooks.onKnownStateReset(); + this.pageState.setHasMoreMessages(false); + this.files.clear(); + this.pageState.clearComposer(); + await this.hooks.onLoadModelCatalog(item.id); + await this.hooks.onLoadActiveMessages(); + if (this.pageState.activeSession.sessionId === session.sessionId) { + this.hooks.onStartPolling(); + } } - } - ); + ); + } finally { + this.pageState.setConversationLoading(false); + this.pageState.setPendingSessionId(''); + } } async deleteSession(item: RemoteSession, currentWorkspacePath: string): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets similarity index 86% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets index 87b9818dd8..f6d3d475ac 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets @@ -2,9 +2,9 @@ import { RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../../model/ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; -export class RemoteWorkspaceViewModelHooks { +export interface RemoteWorkspaceViewModelHooks { readonly isRemoteAvailable: () => boolean; readonly isBusy: () => boolean; readonly onBusy: (isBusy: boolean) => void; @@ -13,26 +13,6 @@ export class RemoteWorkspaceViewModelHooks { readonly onSessionsDiscovered: (sessions: RemoteSession[]) => void; readonly onRefreshSessions: () => Promise; readonly onConnectionFailure: (error: Object) => void; - - constructor( - isRemoteAvailable: () => boolean, - isBusy: () => boolean, - onBusy: (isBusy: boolean) => void, - onStatus: (statusText: string) => void, - onWorkspaceSelected: (workspace: WorkspaceInfo) => void, - onSessionsDiscovered: (sessions: RemoteSession[]) => void, - onRefreshSessions: () => Promise, - onConnectionFailure: (error: Object) => void - ) { - this.isRemoteAvailable = isRemoteAvailable; - this.isBusy = isBusy; - this.onBusy = onBusy; - this.onStatus = onStatus; - this.onWorkspaceSelected = onWorkspaceSelected; - this.onSessionsDiscovered = onSessionsDiscovered; - this.onRefreshSessions = onRefreshSessions; - this.onConnectionFailure = onConnectionFailure; - } } /** Owns the workspace/assistant picker workflows and their presentation state. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets new file mode 100644 index 0000000000..02e90e9f0b --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -0,0 +1,523 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CloudAccountDevice, CloudAccountRequestError, CloudAccountSession, CloudAccountClient } from '../../services/CloudAccountClient'; +import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { Encoding } from '../../services/Encoding'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore, + GeneralChatConfigUpdate, + GeneralChatConfigValidator, + GeneralChatModelSelectionPolicy +} from '../../services/general-chat/GeneralChatConfigStore'; +import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/GeneralChatCloudConfigPolicy'; +import { GeneralChatServiceStatus } from '../../services/general-chat/GeneralChatServiceState'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemotePermissionMode } from '../../model/RemoteModels'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; + +export interface SettingsControllerHooks { + readonly probeConfiguration: (apiUrl: string, apiKey: string, modelName: string) => Promise; +} + +export interface CloudAccountSettingsHooks { + readonly deviceId: () => string; + readonly remoteAvailable: () => boolean; + readonly invalidatePreview: () => void; + readonly invalidateRemoteActivity: () => void; + readonly invalidateRemoteConnection: () => void; + readonly stopPolling: () => void; + readonly stopHeartbeat: () => void; + readonly startHeartbeat: () => void; + readonly resetTimeline: () => void; + readonly resetKnownRemoteState: () => void; + readonly closeSettings: () => void; + readonly closeConnectSheet: () => void; + readonly navigateRemoteHome: () => void; + readonly loadRecentWorkspaces: () => Promise; +} + +export interface CloudAccountSettingsDependencies { + readonly client: CloudAccountClient; + readonly sessionStore: CloudAccountSessionStore; + readonly sessionManager: RemoteSessionManager; + readonly remoteState: RemotePageState; + readonly hooks: CloudAccountSettingsHooks; +} + +/** Owns general-chat model service settings and their presentation projection. */ +export class SettingsController { + private readonly store: GeneralChatConfigStore; + private readonly state: GeneralChatPageState; + private readonly hooks: SettingsControllerHooks; + private readonly cloud?: CloudAccountSettingsDependencies; + private cloudSession?: CloudAccountSession; + private cloudRelayUrl: string = ''; + + constructor( + store: GeneralChatConfigStore, + state: GeneralChatPageState, + hooks: SettingsControllerHooks, + cloud?: CloudAccountSettingsDependencies + ) { + this.store = store; + this.state = state; + this.hooks = hooks; + this.cloud = cloud; + } + + async save( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): Promise { + const update = this.update(apiUrl, apiKey, modelName, clearApiKey); + try { + const validationError = await this.validate(update); + if (validationError.length > 0) { + return validationError; + } + if (!update.clearApiKey) { + const probeError = await this.probe(update); + if (probeError.length > 0) { + return probeError; + } + } + const catalogBeforeSave = await this.store.modelCatalog(); + const snapshot = await this.store.save(update); + if (GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel(catalogBeforeSave)) { + await this.store.selectLocalModel(); + } + this.apply(snapshot); + await this.refreshModelCatalog(); + return ''; + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + async test( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): Promise { + const update = this.update(apiUrl, apiKey, modelName, clearApiKey); + try { + const validationError = await this.validate(update); + if (validationError.length > 0) { + return validationError; + } + if (update.clearApiKey) { + return RemoteI18n.t('settings.modelService.testNeedsKey'); + } + return await this.probe(update); + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + apply(snapshot: GeneralChatConfigSnapshot): void { + this.state.setConfiguration( + snapshot.apiUrl, + snapshot.modelName, + snapshot.hasApiKey, + GeneralChatServiceStatus.fromConfiguration(snapshot.apiUrl, snapshot.modelName, snapshot.hasApiKey) + ); + } + + async refreshModelCatalog(): Promise { + const catalog = await this.store.modelCatalog(); + const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; + this.state.setModelCatalog(catalog, selectedModelId); + const active = await this.store.activeSnapshot(); + this.state.setServiceState( + GeneralChatServiceStatus.fromConfiguration(active.apiUrl, active.modelName, active.hasApiKey) + ); + } + + async selectModel(modelId: string): Promise { + if (!await this.store.selectModel(modelId)) { + return false; + } + await this.refreshModelCatalog(); + return true; + } + + async initializeCloudAccount(context: Context): Promise { + const cloud = this.requireCloud(); + await cloud.sessionStore.init(context); + await this.restoreCloudAccountSession(); + } + + hasCloudAccountSession(): boolean { + return this.cloudSession !== undefined; + } + + async persistDelegatedAccountSession(): Promise { + if (this.cloudSession) { + return; + } + const cloud = this.requireCloud(); + const delegated = cloud.sessionManager.delegatedAccountSession(); + if (!delegated) { + return; + } + this.applyCloudAccountSession(delegated.session, delegated.relayUrl, delegated.session.userId); + await cloud.sessionStore.save({ + relayUrl: delegated.relayUrl, + username: delegated.session.userId, + token: delegated.session.token, + userId: delegated.session.userId, + masterKey: Encoding.bytesToBase64(delegated.session.masterKey) + }); + RemoteLogger.info('delegated account session persisted after room pairing'); + } + + async loginCloudAccount(relayUrl: string, username: string, password: string): Promise { + const cloud = this.requireCloud(); + RemoteLogger.info('cloud account UI login requested'); + const session = await cloud.client.login(relayUrl, username, password, cloud.hooks.deviceId()); + this.applyCloudAccountSession(session, relayUrl, username); + await cloud.sessionStore.save({ + relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey) + }); + await this.loadGeneralChatAccountModels(session, relayUrl); + RemoteLogger.info('cloud account credentials persisted, refreshing account devices'); + RemoteLogger.info(`cloud account login success user=${session.userId}`); + return session.userId; + } + + async syncCloudAccount(): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!session || this.cloudRelayUrl.length === 0) { + throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); + } + let bundles: Object[]; + try { + bundles = await cloud.client.fetchSessions(this.cloudRelayUrl, session, 0); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + throw new Error(RemoteI18n.t('remote.settings.accountExpired')); + } + throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed')); + } + await this.loadGeneralChatAccountModels(session, this.cloudRelayUrl); + RemoteLogger.info(`cloud account backup sync completed count=${bundles.length}`); + return String(bundles.length); + } + + applyCloudAccountSession(session: CloudAccountSession, relayUrl: string, username: string): void { + const remoteState = this.requireCloud().remoteState; + this.cloudSession = session; + this.cloudRelayUrl = relayUrl.trim(); + remoteState.setAccountUserId(session.userId); + remoteState.setAccountUsername(username.trim()); + } + + async logoutCloudAccount(): Promise { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + if (cloud.remoteState.controlTargetType === 'account_device') { + this.resetAccountDeviceConnection(true); + } + this.cloudSession = undefined; + this.cloudRelayUrl = ''; + this.store.replaceAccountModels([]); + await this.refreshModelCatalog(); + await cloud.sessionStore.clear(); + cloud.remoteState.setAccountUserId(''); + cloud.remoteState.setAccountUsername(''); + cloud.remoteState.clearControlTarget(); + RemoteLogger.info('cloud account logout success'); + } + + async listCloudAccountDevices(): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!session || this.cloudRelayUrl.length === 0) { + return []; + } + try { + return await cloud.client.listDevices(this.cloudRelayUrl, session); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + throw new Error(RemoteI18n.t('remote.settings.accountExpired')); + } + if (err instanceof CloudAccountRequestError && + (err.statusCode === 404 || err.statusCode === 503 || err.statusCode === 504)) { + throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); + } + throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.deviceLoadFailed')); + } + } + + async getRemotePermissionMode(): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); + } + return cloud.sessionManager.getPermissionMode(); + } + + async setRemotePermissionMode(mode: RemotePermissionMode): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); + } + return cloud.sessionManager.setPermissionMode(mode); + } + + async restoreCloudTarget(targetDeviceId: string, targetDeviceName: string): Promise { + const targetId = targetDeviceId.trim(); + if (targetId.length === 0) { + return; + } + const remoteState = this.requireCloud().remoteState; + try { + const devices = await this.listCloudAccountDevices(); + const target = devices.find((device: CloudAccountDevice): boolean => device.deviceId === targetId); + if (!target || !target.online) { + const targetName = target?.deviceName || targetDeviceName || targetId; + remoteState.setControlTarget('account_device', targetId, targetName); + remoteState.setDesktopIdentity(targetName, targetId); + remoteState.setConnectionState('failed'); + remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceUnavailable')); + return; + } + await this.selectCloudAccountDevice({ + deviceId: target.deviceId, + deviceName: target.deviceName || targetDeviceName || target.deviceId, + online: target.online, + lastSeenAt: target.lastSeenAt + }); + } catch (err) { + RemoteLogger.warn(`cloud target restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); + } + } + + async handleRemoteConnectionError(err: Object): Promise { + const remoteState = this.requireCloud().remoteState; + if (remoteState.controlTargetType !== 'account_device' || + !(err instanceof CloudAccountRequestError) || err.statusCode !== 401) { + return false; + } + await this.expireCloudAccountSession(); + remoteState.setStatusText(RemoteI18n.t('remote.settings.accountExpired')); + return true; + } + + async selectCloudAccountDevice(device: CloudAccountDevice, navigateHome: boolean = true): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!device.online) { + throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); + } + if (!session || this.cloudRelayUrl.length === 0) { + throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); + } + const deviceId = device.deviceId.trim(); + if (deviceId.length === 0 || deviceId === cloud.hooks.deviceId()) { + return; + } + if (deviceId === cloud.remoteState.controlTargetDeviceId && cloud.remoteState.connectionState === 'connected') { + cloud.hooks.closeConnectSheet(); + if (navigateHome) { + cloud.hooks.navigateRemoteHome(); + } + return; + } + this.prepareAccountDeviceConnection(); + try { + const initialSync = await cloud.sessionManager.connectAccountDevice( + cloud.client, + this.cloudRelayUrl, + session, + deviceId + ); + cloud.remoteState.setControlTarget('account_device', deviceId, device.deviceName); + cloud.remoteState.setDesktopIdentity(device.deviceName, deviceId); + cloud.remoteState.setWorkspace( + initialSync.workspace.name, + initialSync.workspace.path, + initialSync.workspace.assistantId || '', + initialSync.workspace.gitBranch, + initialSync.workspace.workspaceKind || 'normal' + ); + cloud.remoteState.setSessions(initialSync.sessions, initialSync.hasMoreSessions); + cloud.remoteState.setAuthenticatedUserId(initialSync.authenticatedUserId); + cloud.remoteState.setConnectionState('connected'); + cloud.remoteState.setStatusText(RemoteI18n.t('connection.connected')); + cloud.hooks.closeSettings(); + cloud.hooks.closeConnectSheet(); + if (navigateHome) { + cloud.hooks.navigateRemoteHome(); + } + await cloud.sessionStore.save({ + relayUrl: this.cloudRelayUrl, + username: cloud.remoteState.accountUsername, + token: session.token, + userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey), + targetDeviceId: deviceId, + targetDeviceName: device.deviceName + }); + cloud.hooks.startHeartbeat(); + await cloud.hooks.loadRecentWorkspaces(); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + } + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setConnectionState('failed'); + const message = ConnectionErrorPolicy.errorText(err); + cloud.remoteState.setStatusText(message); + cloud.sessionManager.reset(); + throw new Error(message); + } finally { + cloud.remoteState.setLoadingHome(false); + cloud.remoteState.setBusy(false); + } + } + + private update( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): GeneralChatConfigUpdate { + return { apiUrl, apiKey, modelName, clearApiKey }; + } + + private async validate(update: GeneralChatConfigUpdate): Promise { + const snapshot = await this.store.snapshot(); + return GeneralChatConfigValidator.validate(update, snapshot.hasApiKey); + } + + private async probe(update: GeneralChatConfigUpdate): Promise { + const apiKey = await this.effectiveApiKey(update); + if (apiKey.length === 0) { + return RemoteI18n.t('settings.modelService.apiKeyRequired'); + } + try { + await this.hooks.probeConfiguration(update.apiUrl, apiKey, update.modelName); + return ''; + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + private async effectiveApiKey(update: GeneralChatConfigUpdate): Promise { + const directKey = update.apiKey.trim(); + if (directKey.length > 0) { + return directKey; + } + if (update.clearApiKey) { + return ''; + } + return (await this.store.accessToken()).trim(); + } + + private async restoreCloudAccountSession(): Promise { + const cloud = this.requireCloud(); + try { + const persisted = await cloud.sessionStore.load(); + if (!persisted) { + return; + } + const session: CloudAccountSession = { + token: persisted.token, + userId: persisted.userId, + masterKey: Encoding.base64ToBytes(persisted.masterKey) + }; + this.applyCloudAccountSession(session, persisted.relayUrl, persisted.username || session.userId); + await this.loadGeneralChatAccountModels(session, persisted.relayUrl); + } catch (err) { + RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); + await cloud.sessionStore.clear(); + } + } + + private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { + const cloud = this.requireCloud(); + this.store.replaceAccountModels([]); + try { + const blob = await cloud.client.fetchSettings(relayUrl, session); + if (!blob) { + this.store.replaceAccountModels([]); + await this.refreshModelCatalog(); + RemoteLogger.info('cloud model catalog is empty'); + return; + } + const models = GeneralChatCloudConfigPolicy.models(blob.plaintext); + this.store.replaceAccountModels(models); + await this.refreshModelCatalog(); + RemoteLogger.info(`cloud model catalog loaded count=${models.length} version=${blob.version}`); + } catch (err) { + await this.refreshModelCatalog(); + RemoteLogger.warn(`cloud model catalog load failed: ${err instanceof Error ? err.message : 'unknown error'}`); + } + } + + private async expireCloudAccountSession(): Promise { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + this.cloudSession = undefined; + this.cloudRelayUrl = ''; + await cloud.sessionStore.clear(); + cloud.remoteState.setAccountUserId(''); + cloud.remoteState.setAccountUsername(''); + if (cloud.remoteState.controlTargetType === 'account_device') { + this.resetAccountDeviceConnection(false); + } + } + + private prepareAccountDeviceConnection(): void { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + cloud.hooks.invalidateRemoteActivity(); + cloud.hooks.invalidateRemoteConnection(); + cloud.hooks.stopPolling(); + cloud.hooks.stopHeartbeat(); + cloud.remoteState.setConnectionState('reconnecting'); + cloud.remoteState.setLoadingHome(true); + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + cloud.remoteState.setBusy(true); + cloud.remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); + cloud.remoteState.clearActiveSession(); + cloud.hooks.resetTimeline(); + cloud.hooks.resetKnownRemoteState(); + cloud.remoteState.setSessions([], false); + } + + private resetAccountDeviceConnection(clearWorkspace: boolean): void { + const cloud = this.requireCloud(); + cloud.hooks.invalidateRemoteActivity(); + cloud.hooks.stopPolling(); + cloud.hooks.stopHeartbeat(); + cloud.sessionManager.reset(); + cloud.remoteState.clearActiveSession(); + cloud.remoteState.setSessions([], false); + if (clearWorkspace) { + cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + cloud.remoteState.setAuthenticatedUserId(''); + } + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setConnectionState('disconnected'); + } + + private requireCloud(): CloudAccountSettingsDependencies { + if (!this.cloud) { + throw new Error('Cloud account settings dependencies are not configured.'); + } + return this.cloud; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets index a83708868e..1d438f4309 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets @@ -1,4 +1,4 @@ -import { FilePreviewTarget, FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { FilePreviewTarget, FilePreviewTargetContext } from '../model/FilePreviewTarget'; import { RemoteUiState } from './RemoteUiState'; export enum FileReferenceKind { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets index a891801a75..528e3bf957 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets @@ -1,4 +1,4 @@ -import { FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { FilePreviewTargetContext } from '../model/FilePreviewTarget'; import { FileReferenceKind, FileTargetResolver } from './FileTargetResolver'; import { MarkdownParser, diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json index 124f69ff32..22d8c6438e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json @@ -60,6 +60,14 @@ "name": "connect_hero_surface", "value": "#F8FAFF" }, + { + "name": "connect_scan_accent", + "value": "#FFD021" + }, + { + "name": "modal_scrim", + "value": "#99000000" + }, { "name": "soft", "value": "#F4F3F0" diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json index 39e3e9d2c5..9252b40cea 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json @@ -60,6 +60,14 @@ "name": "connect_hero_surface", "value": "#252522" }, + { + "name": "connect_scan_accent", + "value": "#FFD021" + }, + { + "name": "modal_scrim", + "value": "#99000000" + }, { "name": "soft", "value": "#2D2C28" diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets index 1af9cec0d6..8db8af9f1a 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets @@ -1,8 +1,9 @@ import { describe, expect, it } from '@ohos/hypium'; +import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; import { AppRootHostPort } from '../main/ets/pages/host/AppRootHostAdapter'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; -import { AppRootRuntime } from '../main/ets/pages/state/AppRootRuntime'; -import { FilePreviewRequest, FilePreviewTarget } from '../main/ets/pages/state/FilePreviewTarget'; +import { AppRootRuntime } from '../main/ets/pages/runtime/AppRootRuntime'; +import { FilePreviewRequest, FilePreviewTarget } from '../main/ets/model/FilePreviewTarget'; class FakeAppRootHost implements AppRootHostPort { externalLinks: string[] = []; @@ -30,41 +31,36 @@ class FakeAppRootHost implements AppRootHostPort { } class TestAppRootRuntime extends AppRootRuntime { - stopGeneralChatStreamCalls: number = 0; - constructor(host: AppRootHostPort = new FakeAppRootHost()) { super(host); } - - stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { - this.stopGeneralChatStreamCalls += 1; - super.stopGeneralChatStream(cancelled, finalStatus); - } } export default function appRootLifecycleUnitTest() { describe('AppRootRuntime page hide lifecycle', () => { it('keeps backgrounded general chat running on page hide', 0, () => { const runtime = new TestAppRootRuntime(); + runtime.generalChatStreamLifecycleController.begin('session-1'); runtime.onPageHide(); - expect(runtime.stopGeneralChatStreamCalls).assertEqual(0); + expect(runtime.generalChatStreamLifecycleController.hasActiveStream()).assertTrue(); }); it('still performs general chat cleanup when the app truly disappears', 0, () => { const runtime = new TestAppRootRuntime(); + runtime.generalChatStreamLifecycleController.begin('session-1'); runtime.aboutToDisappear(); - expect(runtime.stopGeneralChatStreamCalls).assertEqual(1); + expect(runtime.generalChatStreamLifecycleController.hasActiveStream()).assertFalse(); }); it('routes HTTP Markdown links through the host without opening file preview', 0, async () => { const host = new FakeAppRootHost(); const runtime = new TestAppRootRuntime(host); - runtime.openFilePreview( + runtime.filePreviewController.open( AppRoute.ChatHome, new FilePreviewRequest('https://example.com/docs', 'docs') ); @@ -75,6 +71,22 @@ export default function appRootLifecycleUnitTest() { expect(runtime.filePreviewState.visible).assertFalse(); }); + it('reports external-link failures through the active conversation surface', 0, async () => { + const host = new FakeAppRootHost(); + host.externalLinkResult = false; + const runtime = new TestAppRootRuntime(host); + + runtime.filePreviewController.open( + AppRoute.ChatHome, + new FilePreviewRequest('https://example.com/failure', 'failure') + ); + await new Promise((resolve: () => void) => setTimeout(resolve, 0)); + + expect(runtime.generalChatPageState.conversation.statusText) + .assertEqual(RemoteI18n.t('errors.operationFailed')); + expect(runtime.remotePageState.conversation.statusText).assertEqual(''); + }); + it('closes preview before applying conversation navigation back', 0, () => { const runtime = new TestAppRootRuntime(); runtime.filePreviewState.begin(new FilePreviewTarget( @@ -92,7 +104,7 @@ export default function appRootLifecycleUnitTest() { 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 )); - runtime.invalidateFilePreviewTarget(); + runtime.filePreviewController.invalidate(); expect(runtime.filePreviewState.visible).assertFalse(); }); @@ -109,7 +121,7 @@ export default function appRootLifecycleUnitTest() { 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 )); - runtime.applyRemoteActiveSession({ + runtime.conversationController.applyRemoteActiveSession({ sessionId: 'session-1', title: 'Renamed session', workspacePath: '/workspace', @@ -117,7 +129,7 @@ export default function appRootLifecycleUnitTest() { }); expect(runtime.filePreviewState.visible).assertTrue(); - runtime.applyRemoteActiveSession({ + runtime.conversationController.applyRemoteActiveSession({ sessionId: 'session-2', title: 'Session 2', workspacePath: '/workspace', diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets index 060199acea..0d4d2e8560 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets @@ -1,8 +1,7 @@ import { describe, expect, it } from '@ohos/hypium'; import { AppRootHostPort } from '../main/ets/pages/host/AppRootHostAdapter'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; -import { AppRootRuntime } from '../main/ets/pages/state/AppRootRuntime'; -import { CloudAccountSession } from '../main/ets/services/CloudAccountClient'; +import { AppRootRuntime } from '../main/ets/pages/runtime/AppRootRuntime'; class FakeAppRootHost implements AppRootHostPort { attach(_context: Context, _uiContext: UIContext): void { @@ -21,21 +20,11 @@ class FakeAppRootHost implements AppRootHostPort { } } -class TestAppRootRuntime extends AppRootRuntime { - constructor() { - super(new FakeAppRootHost()); - } - - applySession(session: CloudAccountSession, relayUrl: string, username: string): void { - this.applyCloudAccountSession(session, relayUrl, username); - } -} - export default function appRootRuntimeStartupUnitTest() { describe('AppRootRuntime startup restore', () => { it('applies cloud credentials without selecting a remote target', 0, () => { - const runtime = new TestAppRootRuntime(); - runtime.applySession({ + const runtime = new AppRootRuntime(new FakeAppRootHost()); + runtime.settingsController.applyCloudAccountSession({ token: 'token-1', userId: 'user-1', masterKey: new Uint8Array(32) @@ -45,7 +34,7 @@ export default function appRootRuntimeStartupUnitTest() { expect(runtime.remotePageState.accountUsername).assertEqual('alice'); expect(runtime.remotePageState.controlTargetType).assertEqual('none'); expect(runtime.remotePageState.controlTargetDeviceId).assertEqual(''); - expect(runtime.currentRoute()).assertEqual(AppRoute.ChatHome); + expect(runtime.appShellViewModel.currentRoute()).assertEqual(AppRoute.ChatHome); }); }); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets index 1139637387..4161ebe9fd 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets @@ -7,8 +7,9 @@ import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; import { ChatMessage } from '../main/ets/model/RemoteModels'; -import { AppShellViewModel } from '../main/ets/pages/state/AppShellViewModel'; +import { AppShellViewModel } from '../main/ets/pages/viewmodel/AppShellViewModel'; import { AppNavigationBackAction } from '../main/ets/pages/navigation/AppRouteContract'; +import { WideLayoutGeometry } from '../main/ets/pages/layout/WideLayoutGeometry'; export default function architectureUnitTest() { describe('MobileArchitecture', () => { @@ -91,5 +92,15 @@ export default function architectureUnitTest() { expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); expect(shell.navigationStack.getAllPathName().length).assertEqual(1); }); + + it('keeps wide layout geometry pure and deterministic', 0, () => { + expect(WideLayoutGeometry.detailOffset(false, 24, 8)).assertEqual(24); + expect(WideLayoutGeometry.detailOffset(true, 24, 8)).assertEqual(8); + expect(WideLayoutGeometry.detailWidth(true, 900, 1200)).assertEqual(1200); + expect(WideLayoutGeometry.collapsedVisualBias(true, 0, 1100, 920, 72)).assertEqual(72); + expect(WideLayoutGeometry.collapsedVisualBias(false, 0, 1100, 920, 72)).assertEqual(0); + expect(WideLayoutGeometry.areaLength('1080')).assertEqual(1080); + expect(WideLayoutGeometry.areaLength('invalid')).assertEqual(0); + }); }); } 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 1c6a270efd..57de0bb8df 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -80,9 +80,12 @@ import { } from '../main/ets/services/VoiceInputLifecycleController'; import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; import { AppShellState } from '../main/ets/pages/state/AppShellState'; +import { ConversationCoreState } from '../main/ets/pages/state/ConversationCoreState'; 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 { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { ConversationController } from '../main/ets/pages/viewmodel/ConversationController'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES @@ -564,6 +567,81 @@ export default function conversationStateUnitTest() { }); }); + describe('ConversationController', () => { + it('keeps composer state isolated while the visible route changes', 0, () => { + const general = new GeneralChatPageState(); + const remote = new RemotePageState(); + const remoteCreate = new RemoteCreateSessionState(); + let route = AppRoute.ChatHome; + const controller = new ConversationController( + general, + remote, + remoteCreate, + { currentRoute: (): AppRoute => route } + ); + + controller.setChatInput(AppRoute.ChatHome, 'general draft'); + controller.setChatInput(AppRoute.RemoteChat, 'remote draft'); + controller.setChatInput(AppRoute.RemoteCreate, 'create draft'); + + expect(controller.visibleChatInput()).assertEqual('general draft'); + route = AppRoute.RemoteChat; + expect(controller.visibleChatInput()).assertEqual('remote draft'); + route = AppRoute.RemoteCreate; + expect(controller.visibleChatInput()).assertEqual('create draft'); + expect(general.chatInput).assertEqual('general draft'); + expect(remote.chatInput).assertEqual('remote draft'); + }); + + it('clears voice state for every conversation surface on teardown', 0, () => { + const general = new GeneralChatPageState(); + const remote = new RemotePageState(); + const remoteCreate = new RemoteCreateSessionState(); + const controller = new ConversationController( + general, + remote, + remoteCreate, + { currentRoute: (): AppRoute => AppRoute.RemoteCreate } + ); + controller.setVoiceListening(AppRoute.ChatHome, true); + controller.setVoiceListening(AppRoute.RemoteChat, true); + controller.setVoiceListening(AppRoute.RemoteCreate, true); + + controller.clearAllVoiceListening(); + + expect(general.isVoiceListening).assertFalse(); + expect(remote.isVoiceListening).assertFalse(); + expect(remoteCreate.isVoiceListening).assertFalse(); + }); + }); + + describe('ConversationCoreState', () => { + it('owns shared conversation data while keeping product surfaces isolated', 0, () => { + const general = new ConversationCoreState('chat'); + const remote = new ConversationCoreState('code'); + general.setActiveSession({ + sessionId: 'general-core', title: 'General', workspacePath: '', agentType: 'code' + }); + remote.setActiveSession({ + sessionId: 'remote-core', title: 'Remote', workspacePath: '/workspace', agentType: 'code' + }); + general.setChatInput('general draft'); + remote.setChatInput('remote draft'); + general.setBusy(true); + + expect(general.activeSession.agentType).assertEqual('chat'); + expect(remote.activeSession.agentType).assertEqual('code'); + expect(general.chatInput).assertEqual('general draft'); + expect(remote.chatInput).assertEqual('remote draft'); + expect(remote.isBusy).assertFalse(); + + general.clearActiveSession(); + expect(general.activeSession.sessionId).assertEqual(''); + expect(remote.activeSession.sessionId).assertEqual('remote-core'); + expect(remote.chatInput).assertEqual('remote draft'); + }); + }); + describe('GeneralChatPageState', () => { it('projects configuration, busy state, and status text', 0, () => { const state = new GeneralChatPageState(); @@ -797,6 +875,9 @@ export default function conversationStateUnitTest() { }); state.setTimelineProjection([userMessage], [], activeTurn, false, timelineItems); state.setModelCatalog(modelCatalog, 'model-a'); + state.setConversationLoading(true); + state.setPendingSessionId('remote-2'); + state.setConversationDismissed(true); timelineItems.length = 0; expect(state.activeSession.sessionId).assertEqual('remote-1'); @@ -805,6 +886,14 @@ export default function conversationStateUnitTest() { expect(state.hasRunningActiveTurn()).assertTrue(); expect(state.modelCatalog.version).assertEqual(2); expect(state.selectedModelId).assertEqual('model-a'); + expect(state.isLoadingConversation).assertTrue(); + expect(state.pendingSessionId).assertEqual('remote-2'); + expect(state.isConversationDismissed).assertTrue(); + + state.clearActiveSession(); + expect(state.pendingSessionId).assertEqual(''); + expect(state.isLoadingConversation).assertFalse(); + expect(state.isConversationDismissed).assertFalse(); }); it('copies nested remote session projection and replaces streaming turn snapshots', 0, () => { @@ -932,6 +1021,7 @@ export default function conversationStateUnitTest() { remote.setActiveSession({ sessionId: 'remote-session', title: 'Remote', workspacePath: '/repo', agentType: 'code' }); + remote.setConversationLoading(true); const general = new GeneralChatPageState(); general.setChatInput('general draft'); general.setActiveSession({ @@ -942,6 +1032,7 @@ export default function conversationStateUnitTest() { expect(remoteProjection.surface).assertEqual(ChatSurface.Remote); expect(remoteProjection.chatInput).assertEqual('remote draft'); expect(remoteProjection.activeSession.sessionId).assertEqual('remote-session'); + expect(remoteProjection.isLoadingConversation).assertTrue(); const generalProjection = ConversationViewState.project(AppRoute.ChatHome, remote, general, 'Configure model'); expect(generalProjection.surface).assertEqual(ChatSurface.General); 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 3fbd4c6b56..97a364d5b6 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets @@ -189,6 +189,18 @@ export default function lifecycleUnitTest() { expect(state.showSettings).assertFalse(); expect(state.showConnectSheet).assertFalse(); }); + + it('mirrors the resolved layout mode for runtime branching', 0, () => { + const state = new AppShellState(); + + expect(state.wideLayout).assertFalse(); + state.setWideLayout(true); + expect(state.wideLayout).assertTrue(); + + state.setSidebarVisible(true); + state.closeGlobalSurfaces(); + expect(state.wideLayout).assertTrue(); + }); }); describe('AsyncLifecycleGate', () => { 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 eaf64f2b06..471cc8ef7b 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -76,7 +76,7 @@ import { MessageFileReferenceProjectionCache, MessageFileReferenceProjector } from '../main/ets/services/MessageFileReferenceProjector'; -import { RemoteFilePreviewController } from '../main/ets/services/RemoteFilePreviewController'; +import { RemoteFilePreviewController } from '../main/ets/pages/viewmodel/RemoteFilePreviewController'; import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; import { RemoteModelClient, @@ -107,18 +107,18 @@ import { FilePreviewRendererKind, FilePreviewState } from '../main/ets/pages/state/FilePreviewState'; -import { FilePreviewTarget, FilePreviewTargetContext } from '../main/ets/pages/state/FilePreviewTarget'; +import { FilePreviewTarget, FilePreviewTargetContext } from '../main/ets/model/FilePreviewTarget'; import { FilePreviewPlacement, FilePreviewPlacementPolicy -} from '../main/ets/pages/state/FilePreviewPlacementPolicy'; +} from '../main/ets/pages/policy/FilePreviewPlacementPolicy'; import { ConversationLayoutCrease, ConversationLayoutPolicy -} from '../main/ets/pages/state/ConversationLayoutPolicy'; -import { SessionActionPolicy, SessionActionScope } from '../main/ets/pages/state/SessionActionPolicy'; -import { ConversationSessionFilterPolicy } from '../main/ets/pages/state/ConversationSessionFilterPolicy'; -import { ConversationModelPresentationPolicy } from '../main/ets/pages/state/ConversationModelPresentationPolicy'; +} from '../main/ets/pages/policy/ConversationLayoutPolicy'; +import { SessionActionPolicy, SessionActionScope } from '../main/ets/pages/policy/SessionActionPolicy'; +import { ConversationSessionFilterPolicy } from '../main/ets/pages/policy/ConversationSessionFilterPolicy'; +import { ConversationModelPresentationPolicy } from '../main/ets/pages/policy/ConversationModelPresentationPolicy'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES @@ -788,7 +788,7 @@ export default function remoteControllersUnitTest() { expect(state.selectedWorkspaceName).assertEqual('BitFun'); }); - it('freezes the selected creation target and keeps workspace chats as Claw sessions', 0, () => { + it('freezes the selected creation target and pairs a workspace with the code agent', 0, () => { const state = new RemoteCreateSessionState(); state.prepare('desktop-b', 'Desktop B'); state.setWorkspaces([{ @@ -802,6 +802,17 @@ export default function remoteControllersUnitTest() { expect(context.deviceId).assertEqual('desktop-b'); expect(context.workspacePath).assertEqual('/workspace/BitFun'); + expect(context.agentType).assertEqual('code'); + }); + + it('keeps the chat option on the assistant agent so the desktop binds its assistant workspace', 0, () => { + const state = new RemoteCreateSessionState(); + state.prepare('desktop-b', 'Desktop B'); + state.selectWorkspace(undefined); + + const context = state.submissionContext(); + + expect(context.workspacePath).assertEqual(''); expect(context.agentType).assertEqual('Claw'); }); }); @@ -1677,6 +1688,14 @@ export default function remoteControllersUnitTest() { expect(remoteChat.name).assertEqual(AppRoute.RemoteChat); expect(remoteChat.routeParam().sessionId).assertEqual('remote-1'); }); + + it('routes an in-place remote session selection to an explicit chat destination', 0, () => { + const target = AppRouteContract.remoteSessionDestination('remote-session-1'); + + expect(target.name).assertEqual(AppRoute.RemoteChat); + expect(target.hasSessionParam()).assertTrue(); + expect(target.routeParam().sessionId).assertEqual('remote-session-1'); + }); }); describe('SessionActionPolicy', () => { 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 e6b32f7f91..d083ef6550 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -20,6 +20,7 @@ import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ import { GeneralChatConfigSnapshot, GeneralChatConfigStore, + GeneralChatConfigUpdate, GeneralChatConfigValidator, GeneralChatModelSelectionPolicy } from '../main/ets/services/general-chat/GeneralChatConfigStore'; @@ -84,6 +85,7 @@ import { import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; import { AppShellState } from '../main/ets/pages/state/AppShellState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { SettingsController } from '../main/ets/pages/viewmodel/SettingsController'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; import { @@ -217,6 +219,54 @@ function modelProviderRecordedResponse(statusCode: number, body: string): ModelP return response; } +class InMemorySettingsConfigStore extends GeneralChatConfigStore { + snapshotResult: GeneralChatConfigSnapshot = { + apiUrl: '', + modelName: '', + hasApiKey: false + }; + accessTokenResult: string = ''; + modelCatalogResults: RemoteModelCatalog[] = []; + modelCatalogCalls: number = 0; + saveRequests: GeneralChatConfigUpdate[] = []; + selectLocalModelCalls: number = 0; + + async snapshot(): Promise { + return this.snapshotResult; + } + + async save(update: GeneralChatConfigUpdate): Promise { + this.saveRequests.push(update); + this.snapshotResult = { + apiUrl: update.apiUrl.trim(), + modelName: update.modelName.trim(), + hasApiKey: !update.clearApiKey + }; + return this.snapshotResult; + } + + async accessToken(): Promise { + return this.accessTokenResult; + } + + async modelCatalog(): Promise { + const index = Math.min(this.modelCatalogCalls, this.modelCatalogResults.length - 1); + this.modelCatalogCalls += 1; + if (index >= 0) { + return this.modelCatalogResults[index]; + } + return { version: 1, models: [], default_models: {} }; + } + + async selectLocalModel(): Promise { + this.selectLocalModelCalls += 1; + } + + async activeSnapshot(): Promise { + return this.snapshotResult; + } +} + export default function transportAndGeneralChatUnitTest() { describe('RemoteDescriptorParser', () => { it('parses hash route URLs', 0, () => { @@ -748,6 +798,66 @@ export default function transportAndGeneralChatUnitTest() { }); }); + describe('SettingsController', () => { + it('tests model configuration with the stored key when the form keeps it unchanged', 0, async () => { + const store = new InMemorySettingsConfigStore(); + store.snapshotResult = { + apiUrl: 'https://chat.example.com', + modelName: 'model-a', + hasApiKey: true + }; + store.accessTokenResult = ' stored-key '; + let probedApiKey = ''; + const controller = new SettingsController(store, new GeneralChatPageState(), { + probeConfiguration: async (_apiUrl: string, apiKey: string, _modelName: string): Promise => { + probedApiKey = apiKey; + } + }); + + const error = await controller.test('https://chat.example.com', '', 'model-a', false); + + expect(error).assertEqual(''); + expect(probedApiKey).assertEqual('stored-key'); + }); + + it('saves the first local model and projects its catalog into page state', 0, async () => { + const store = new InMemorySettingsConfigStore(); + store.modelCatalogResults = [ + { version: 1, models: [], default_models: {} }, + { + version: 2, + models: [{ + id: 'local-general-chat', + name: 'model-a', + provider: 'local', + base_url: 'https://chat.example.com', + model_name: 'model-a', + enabled: true, + capabilities: ['text_chat'] + }], + default_models: { primary: 'local-general-chat' }, + session_model_id: 'local-general-chat' + } + ]; + const state = new GeneralChatPageState(); + const controller = new SettingsController(store, state, { + probeConfiguration: async (_apiUrl: string, _apiKey: string, _modelName: string): Promise => { + } + }); + + const error = await controller.save( + 'https://chat.example.com', 'new-key', 'model-a', false + ); + + expect(error).assertEqual(''); + expect(store.saveRequests.length).assertEqual(1); + expect(store.selectLocalModelCalls).assertEqual(1); + expect(state.apiUrl).assertEqual('https://chat.example.com'); + expect(state.conversation.selectedModelId).assertEqual('local-general-chat'); + expect(state.serviceState).assertEqual(GeneralChatServiceState.Ready); + }); + }); + describe('GeneralChatModelSelectionPolicy', () => { it('keeps an existing cloud selection when a local model is saved', 0, () => { const shouldActivateLocal = GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel({