diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md new file mode 100644 index 0000000000..c177c473f1 --- /dev/null +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -0,0 +1,24 @@ +# HarmonyOS App Instructions + +These rules apply to all changes under `src/apps/mobile/harmonyos`. + +## 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. +- Conversation header controls must use the approved `remote_ref_back` and `remote_ref_more` assets. Do not replace them with a system chevron or text such as `...` / bullet characters. +- Render monochrome reference assets in template mode and tint them with semantic theme colors such as `INK`. Never rely on the bitmap's original black or white pixels; the same control must remain legible in light and dark themes. +- Keep paired header controls on the same fixed touch-target size and optical alignment. A responsive layout may reposition a control, but must not silently change its icon geometry or visual weight. +- Keep `SymbolGlyph` geometry separate from its touch target. When a glyph is clickable or sits in a decorated control, wrap it in `Stack({ alignContent: Alignment.Center })` (or use a centered `Button`) and give the glyph its visual size; do not stretch the glyph itself to the full 32vp/40vp/44vp target, because font metrics can make the icon look off-center. + +## Responsive interaction semantics + +- Wide and compact layouts must keep the same interaction meaning. Responsive presentation may change spacing and available width, but it must not turn a lightweight anchored action menu into a bottom sheet by default. +- Conversation-header overflow actions open from the top-right trigger as an anchored popover on both compact and wide layouts. Use a bottom sheet only when the content is a genuinely large or multi-step mobile workflow and the design explicitly calls for it. +- Anchor popovers to their actual trigger with `bindPopup` or the equivalent platform API. Do not emulate the anchor with unrelated page-level absolute positioning. +- Preserve auto-dismiss, outside-tap handling, accessibility labels, and a short enter/exit transition for anchored menus. + +## Theme and device verification + +- Use existing semantic colors from `Theme.ets`; do not hard-code a light-only foreground or surface color. +- For changes to navigation controls, menus, or responsive presentation, verify compact and wide behavior, light and dark theme legibility, and capture a real-device screenshot before completion when a device is connected. +- Run the smallest matching HarmonyOS build/check plus `pnpm run theme:color-audit:all` for theme or color-related changes. diff --git a/src/apps/mobile/harmonyos/README.md b/src/apps/mobile/harmonyos/README.md index 03c10dfea5..dd852c97a3 100644 --- a/src/apps/mobile/harmonyos/README.md +++ b/src/apps/mobile/harmonyos/README.md @@ -1,7 +1,7 @@ # BitFun HarmonyOS -Native HarmonyOS phone client for BitFun. The application provides general -chat and remote control of BitFun desktop sessions. +Native HarmonyOS client for BitFun. The application provides general chat and +remote control of BitFun desktop sessions on phone and tablet devices. ## Project Layout @@ -29,4 +29,4 @@ Signing configuration is intentionally not stored in the repository. Configure a local signing identity in DevEco Studio when installing the app on a device. The current project targets HarmonyOS `6.1.1(24)` and supports -`6.0.1(21)` or newer on phone devices. +`6.0.1(21)` or newer on phone and tablet devices. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index e2829c2156..c032190ac6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,4 +1,4 @@ -import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; +import { AbilityConstant, Configuration, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; @@ -6,6 +6,8 @@ const DOMAIN = 0x0000; const TAG = 'BitFunRemote'; export default class EntryAbility extends UIAbility { + private mainWindow?: window.Window; + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { try { this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); @@ -24,13 +26,9 @@ export default class EntryAbility extends UIAbility { hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onWindowStageCreate'); try { const mainWindow = windowStage.getMainWindowSync(); + this.mainWindow = mainWindow; mainWindow.setWindowLayoutFullScreen(false); - mainWindow.setWindowSystemBarProperties({ - statusBarColor: '#FAFAF8', - navigationBarColor: '#FAFAF8', - statusBarContentColor: '#171717', - navigationBarContentColor: '#171717' - }); + this.updateSystemBars(this.context.config?.colorMode); } catch (err) { hilog.error(DOMAIN, TAG, 'Failed to configure window bars. Cause: %{public}s', JSON.stringify(err)); } @@ -54,8 +52,25 @@ export default class EntryAbility extends UIAbility { hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onForeground'); } + onConfigurationUpdate(newConfig: Configuration): void { + this.updateSystemBars(newConfig.colorMode); + } + onBackground(): void { // Ability has back to background hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onBackground'); } + + private updateSystemBars(colorMode?: ConfigurationConstant.ColorMode): void { + if (!this.mainWindow) { + return; + } + const dark = colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK; + this.mainWindow.setWindowSystemBarProperties({ + statusBarColor: dark ? '#151514' : '#FDFDFB', + navigationBarColor: dark ? '#151514' : '#FDFDFB', + statusBarContentColor: dark ? '#F4F3EF' : '#171717', + navigationBarContentColor: dark ? '#F4F3EF' : '#171717' + }); + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets index deb13d538c..1fce0571d1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets @@ -2,6 +2,7 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['app.title', 'BitFun'], ['common.cancel', '取消'], + ['common.back', '返回'], ['common.close', '关闭'], ['common.copy', '复制'], ['common.current', '当前'], @@ -48,6 +49,7 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['generalChat.interrupted', '回复已中断,已保留收到的内容。'], ['generalChat.replyInterrupted', '回复中断'], ['generalChat.fileDownloadMock', '普通聊天暂不支持下载桌面端文件,请进入 Code 后处理本地文件。'], + ['generalChat.filePreviewUnavailable', '普通聊天暂不支持预览桌面端文件,请进入 Code 后打开。'], ['generalChat.localRestoreFailed', '普通对话历史暂时无法恢复,你仍可新建对话。'], ['generalChat.modelNotConfigured', '请先在设置中配置普通对话模型。'], ['generalChat.imageNotSupported', '当前模型通道暂不支持图片,请先发送文字消息。'], @@ -58,12 +60,22 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['generalChat.emptyResponse', '模型没有返回文字内容。'], ['generalChat.requestFailed', '模型请求失败(HTTP {0})。'], - ['settings.modelService.section', '模型服务'], + ['settings.modelService.section', '普通对话'], ['settings.title', '设置'], ['settings.about.section', '关于'], ['settings.about.product', '产品'], ['settings.about.version', '版本'], - ['settings.modelService.title', '普通对话模型'], + ['settings.modelService.title', '模型'], + ['settings.modelService.manageTitle', '普通对话模型'], + ['settings.modelService.localTitle', '本机自定义模型'], + ['settings.modelService.currentModel', '当前使用'], + ['settings.modelService.accountModels', '账号同步'], + ['settings.modelService.accountModelSummary', '云端账号模型'], + ['settings.modelService.localModel', '本机自定义'], + ['settings.modelService.accountSource', '云端账号'], + ['settings.modelService.localSource', '本机'], + ['settings.modelService.syncedCount', '已同步 {0} 个'], + ['settings.modelService.accountEmpty', '暂无可用的账号模型'], ['settings.modelService.configured', '已配置'], ['settings.modelService.notConfigured', '未配置'], ['settings.modelService.apiUrl', 'API URL'], @@ -107,6 +119,31 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['sidebar.noSearchResult', '没有匹配的会话'], ['sidebar.signedOutNewChat', '新聊天'], ['sidebar.signInBitFunAccount', '登录BitFun账号'], + ['sidebar.collapse', '收起侧边栏'], + ['sidebar.restore', '展开侧边栏'], + ['session.actions', '会话操作'], + ['session.details', '会话详情'], + ['session.viewDetails', '查看详情'], + ['session.agentType', 'Agent 类型'], + ['session.workspace', '工作区'], + ['session.workspacePath', '工作区路径'], + ['session.createdAt', '创建时间'], + ['session.updatedAt', '更新时间'], + ['session.messageCount', '消息数'], + ['session.status', '状态'], + ['viewSettings.title', '视图设置'], + ['viewSettings.subtitle', '调整会话列表的分组和信息密度'], + ['viewSettings.grouping', '分组方式'], + ['viewSettings.filters', '筛选'], + ['viewSettings.metadata', '显示信息'], + ['viewSettings.workspace', '工作区'], + ['viewSettings.agentType', '会话类型'], + ['viewSettings.allWorkspaces', '全部工作区'], + ['viewSettings.allAgentTypes', '全部类型'], + ['viewSettings.allStatuses', '全部状态'], + ['viewSettings.updated', '更新时间'], + ['viewSettings.status', '运行状态'], + ['common.unknown', '未知'], ['code.title', 'BitFun Code'], ['code.heroTitle', '本地开发助手'], @@ -126,17 +163,22 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['remote.searchChats', '搜索聊天记录'], ['remote.emptyTitle', '还没有远程对话'], ['remote.emptyText', '新建聊天后,可以从手机继续处理桌面端任务。'], + ['remote.pickSession', '选择一个会话'], + ['remote.pickSessionText', '从侧边栏打开会话,或新建一个。'], + ['remote.startSession', '新建会话'], ['remote.connectTitle', '连接桌面端'], ['remote.connectText', '扫描桌面端显示的二维码,开始远程处理任务。'], ['remote.actions', '远程设置'], ['remote.device', '连接的设备'], ['remote.newChat', '聊天'], + ['remote.create.title', '新建任务'], ['remote.create.chat', '聊天'], ['remote.create.noDevice', '选择桌面设备'], ['remote.create.noOnlineDevice', '没有可用的在线桌面设备'], ['remote.create.placeholder', '告诉 BitFun 要做什么'], ['remote.create.deviceLoadFailed', '设备列表加载失败,请稍后重试。'], ['remote.create.workspaceLoadFailed', '工作区加载失败,请稍后重试。'], + ['remote.create.deviceMismatch', '所选设备尚未连接,请重新选择设备后再试。'], ['remote.create.submitFailed', '无法创建会话,请检查桌面连接后重试。'], ['remote.workspace', '工作区'], ['remote.assistant', '助理'], @@ -397,6 +439,19 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['chat.fileLink', '文件链接'], ['chat.reading', '读取中'], ['chat.download', '下载'], + ['filePreview.title', '文件预览'], + ['filePreview.loading', '正在读取文件'], + ['filePreview.offline', '离线,仅显示已加载内容'], + ['filePreview.fitImage', '适应窗口'], + ['filePreview.actualImageSize', '原始大小'], + ['filePreview.imageDecodeFailed', '图片无法解码,请重试或下载文件。'], + ['filePreview.loadFailed', '无法打开文件'], + ['filePreview.notFound', '文件不存在或已被移动'], + ['filePreview.unavailable', '文件不存在或不在当前工作区'], + ['filePreview.accessDenied', '无法访问工作区外的文件'], + ['filePreview.tooLarge', '文件过大,无法在移动端预览'], + ['filePreview.unsupported', '此文件暂不支持预览'], + ['filePreview.truncated', '已显示前 {0},下载后可查看完整内容'], ['chat.pendingConfirmation', '待确认'], ['chat.cancelled', '已取消'], ['chat.jsonObjectRequired', '请输入 JSON 对象'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index ff359d2a89..ce5ad00ddd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -83,6 +83,7 @@ export interface CreateSessionOptions { agentType: string; title: string; instruction: string; + modelId?: string; } export type RemotePermissionMode = 'ask' | 'auto' | 'full_access'; @@ -453,3 +454,12 @@ export interface ReadFileResult { mimeType: string; size: number; } + +export interface ReadFileChunkResult { + name: string; + contentBase64: string; + offset: number; + chunkSize: number; + totalSize: number; + mimeType: string; +} 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 a2de3319da..4a918697db 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 @@ -25,6 +25,10 @@ struct AppRoot { this.runtime.aboutToDisappear(); } + onBackPress(): boolean { + return this.runtime.handleRootBack(); + } + build() { Stack() { AppRootPresentation({ @@ -33,8 +37,8 @@ struct AppRoot { remotePageState: this.runtime.remotePageState, remoteCreateState: this.runtime.remoteCreateState, generalPageState: this.runtime.generalChatPageState, + filePreviewState: this.runtime.filePreviewState, deviceId: this.runtime.remoteConnectionViewModel.getDeviceId(), - currentRoute: this.runtime.currentRoute(), actions: this.runtime.presentationActions }) } 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 e63230a8c4..c38cd9de27 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,21 +1,30 @@ 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 { ConversationSourceSwitcher } from './ConversationSourceSwitcher'; +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 { RemoteHomeView } from './RemoteHomeView'; import { RemoteSessionList } from './RemoteSessionList'; +import { RemoteSessionLoadingView } from './RemoteSessionLoadingView'; +import { SidebarToggleButton } from './SidebarToggleButton'; +import { SessionActionPresentation } from './SessionActionSurface'; import { SettingsSheet } from './SettingsSheet'; -import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED } from './Theme'; -import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; +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 { AppShellState } from '../state/AppShellState'; import { ConversationLayoutCrease, @@ -25,6 +34,12 @@ 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 { + FilePreviewLayout, + FilePreviewPlacement, + FilePreviewPlacementPolicy +} from '../state/FilePreviewPlacementPolicy'; const WIDE_DETAIL_CONTENT_MAX_WIDTH: number = 920; @@ -36,16 +51,27 @@ function safeFoldStatus(): display.FoldStatus { } } +function safeDeviceType(): string { + try { + return deviceInfo.deviceType || ''; + } catch (_err) { + return ''; + } +} + 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( @@ -53,26 +79,51 @@ export class AppRootPresentationActions { 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; @@ -80,6 +131,8 @@ export class RemoteCreatePresentationActions { 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( @@ -89,6 +142,8 @@ export class RemoteCreatePresentationActions { selectDevice: (device: CloudAccountDevice) => void, selectWorkspace: (path: string) => void, draftChanged: (value: string) => void, + voiceInput: () => void, + selectModel: (modelId: string) => void, send: () => void ) { this.back = back; @@ -97,6 +152,8 @@ export class RemoteCreatePresentationActions { this.selectDevice = selectDevice; this.selectWorkspace = selectWorkspace; this.draftChanged = draftChanged; + this.voiceInput = voiceInput; + this.selectModel = selectModel; this.send = send; } } @@ -109,8 +166,12 @@ export class RemoteHomePresentationActions { 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 createAssistant: () => void; - readonly createInWorkspace: (path: string, agentType: string) => void; readonly openSession: (session: RemoteSession) => 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( @@ -119,8 +180,11 @@ export class RemoteHomePresentationActions { 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, createAssistant: () => void, - createInWorkspace: (path: string, agentType: string) => void, openSession: (session: RemoteSession) => 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; @@ -128,8 +192,10 @@ export class RemoteHomePresentationActions { 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.createAssistant = createAssistant; - this.createInWorkspace = createInWorkspace; this.openSession = openSession; this.deleteSession = deleteSession; + 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; } } @@ -210,16 +276,31 @@ export struct AppRootPresentation { @Param remotePageState: RemotePageState = new RemotePageState(); @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param filePreviewState: FilePreviewState = new FilePreviewState(); @Param deviceId: string = ''; - @Param currentRoute: AppRoute = AppRoute.ChatHome; @Local viewportWidth: number = 0; @Local wideLayoutMatched: boolean = false; @Local foldStatus: display.FoldStatus = safeFoldStatus(); + @Local largeScreenLayout: boolean = false; @Local wideMasterPaneWidth: number = ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH; @Local wideMasterDetailGap: number = 0; @Local wideDetailContentOffset: number = 0; @Local wideDetailContentWidth: number = 0; + @Local wideCollapsedDetailContentOffset: number = 0; + @Local wideCollapsedDetailContentWidth: number = 0; + @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; + private readonly deviceType: string = safeDeviceType(); + private verticalCreases: ConversationLayoutCrease[] = []; private wideQueryListener?: mediaQuery.MediaQueryListener; private foldStatusChanged: (status: display.FoldStatus) => void = (status: display.FoldStatus): void => { @@ -229,17 +310,19 @@ export struct AppRootPresentation { private wideQueryChanged: (result: mediaQuery.MediaQueryResult) => void = (result: mediaQuery.MediaQueryResult): void => { this.wideLayoutMatched = result.matches; + this.refreshWideGeometry(); }; @Param actions: AppRootPresentationActions = new AppRootPresentationActions( - () => false, () => {}, () => {}, () => {}, + () => false, () => {}, () => {}, () => {}, () => {}, () => {}, new RemoteHomePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, - () => {}, () => {}, () => {}, () => {}, () => {}), - new RemoteCreatePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), + () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), + 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(() => {}, () => {}, () => {}, () => {}), () => '' ); @@ -253,12 +336,27 @@ export struct AppRootPresentation { this.unbindResponsiveQueries(); } + @Monitor('filePreviewState.visible') + onFilePreviewVisibilityChanged(): void { + if (this.filePreviewState.visible && this.wideMasterPaneCollapsed) { + this.restoreCollapsedMasterAfterPreview = true; + this.wideMasterPaneCollapsed = false; + } else if (!this.filePreviewState.visible && this.restoreCollapsedMasterAfterPreview && this.isWideLayout()) { + this.wideMasterPaneCollapsed = true; + this.restoreCollapsedMasterAfterPreview = false; + } + } + build() { Stack() { - AppShell({ shellState: this.shellState, content: () => { this.NavigationContent(); }, + AppShell({ shellState: this.shellState, useWideLayout: this.isWideLayout(), content: () => { this.NavigationContent(); }, sidebar: () => { this.SidebarContent(); }, settings: () => { this.SettingsContent(); }, connect: () => { this.ConnectContent(); }, onCloseSidebar: this.actions.onCloseSidebar }) + if (this.filePreviewPlacement() === FilePreviewPlacement.CompactFullPage) { + this.FilePreviewPane() + } }.width('100%').height('100%') + .bindSheet($$this.showRemoteViewSettings, this.RemoteViewSettingsSheet(), this.remoteViewSettingsSheetOptions()) .onAreaChange((_oldArea: Area, newArea: Area) => { this.viewportWidth = this.areaWidth(newArea.width); this.refreshWideGeometry(); @@ -282,60 +380,68 @@ export struct AppRootPresentation { RouteContent(route: AppRoute) { if (this.isGeneralWideRoute(route) && this.isWideLayout()) { this.WideGeneralChatContent(route) - } else if (route === AppRoute.RemoteChat && this.isWideLayout()) { + } 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() } else { - this.RouteSurfaceContent(route, true, true) + this.RouteSurfaceContent(route, true, route !== AppRoute.ChatHome) } } @Builder - RouteSurfaceContent(route: AppRoute, showSidebarButton: boolean, showBackButton: boolean) { + RouteSurfaceContent( + route: AppRoute, + showSidebarButton: boolean, + showBackButton: boolean, + showSidebarRestoreButton: boolean = false, + useWidePresentation: boolean = false + ) { Column() { if (route === AppRoute.RemoteHome) { - RemoteHomeView({ pageState: this.remotePageState, isBusy: this.remotePageState.isBusy, - selectedSessionId: this.remotePageState.activeSession.sessionId, - onOpenSidebar: this.actions.onRemoteHome.openSidebar, - onConnectWorkspace: this.actions.onRemoteHome.connectWorkspace, - onAddConnection: this.actions.onRemoteHome.addConnection, - onOpenRemoteSettings: this.actions.onRemoteHome.openSettings, - onRefresh: this.actions.onRemoteHome.refresh, - onShowWorkspaces: this.actions.onRemoteHome.showWorkspaces, - onShowAssistants: this.actions.onRemoteHome.showAssistants, - onSelectWorkspace: this.actions.onRemoteHome.selectWorkspace, - onSelectAssistant: this.actions.onRemoteHome.selectAssistant, - onCancelWorkspacePicker: this.actions.onRemoteHome.cancelWorkspace, - onCancelAssistantPicker: this.actions.onRemoteHome.cancelAssistant, - onSessionQueryChange: this.actions.onRemoteHome.queryChanged, - onSearchSessions: this.actions.onRemoteHome.search, - onLoadMoreSessions: this.actions.onRemoteHome.loadMore, - onReconnect: this.actions.onRemoteHome.reconnect, - onDisconnect: this.actions.onRemoteHome.disconnect, - onClearPairing: this.actions.onRemoteHome.clearPairing, - onCreate: this.actions.onRemoteHome.create, - onCreateAssistantSession: this.actions.onRemoteHome.createAssistant, - onCreateInWorkspace: this.actions.onRemoteHome.createInWorkspace, - onOpenSession: this.actions.onRemoteHome.openSession, - onDeleteSession: this.actions.onRemoteHome.deleteSession }) + 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) }) } @@ -345,30 +451,11 @@ export struct AppRootPresentation { @Builder WideGeneralChatContent(route: AppRoute) { Row() { - Column() { - AppSidebar({ sessions: this.generalPageState.recentSessions(), pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: this.generalPageState.activeSession.sessionId, - connectionState: this.remotePageState.connectionState, - accountUserId: this.remotePageState.accountUserId, - activeSection: 'chat', - showConversationSourceSwitcher: true, - conversationSource: ConversationSource.General, - onClose: this.actions.onSidebar.close, - onNewChat: this.actions.onSidebar.newChat, - onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), - onConversationSource: this.actions.onWideConversationSource, - onOpenSettings: 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 }) + if (!this.wideMasterPaneCollapsed) { + this.WideMasterPane(ConversationSource.General, false) + this.WideMasterDetailGap() } - .width(this.wideMasterPaneWidth) - .height('100%') - .backgroundColor(PAGE_BG) - .border({ width: { right: 1 }, color: LINE }) - - this.WideMasterDetailGap() - this.WideConversationDetail(route, true) + this.WideConversationDetail(route, false) } .width('100%') .height('100%') @@ -378,8 +465,10 @@ export struct AppRootPresentation { @Builder WideRemoteHomeContent() { Row() { - this.RemoteMasterPane('') - this.WideMasterDetailGap() + if (!this.wideMasterPaneCollapsed) { + this.WideMasterPane(ConversationSource.Remote, false) + this.WideMasterDetailGap() + } Column() { this.RemoteFlowPlaceholder() @@ -394,39 +483,132 @@ export struct AppRootPresentation { } @Builder - RemoteMasterPane(selectedSessionId: string) { - Column({ space: 12 }) { - this.RemoteWidePaneHeader() - ConversationSourceSwitcher({ - activeSource: ConversationSource.Remote, - onSelectSource: this.actions.onWideConversationSource - }) - if (this.canShowRemoteWideList()) { + 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: selectedSessionId, + selectedSessionId: showSelectedSession ? this.remotePageState.activeSession.sessionId : '', onCreate: () => { - this.actions.onRemoteHome.create('code'); + this.createRemoteSession('code', compact); }, onCreateAssistantSession: () => { - this.actions.onRemoteHome.createAssistant(); + this.createRemoteAssistantSession(compact); }, onCreateInWorkspace: (path: string, agentType: string) => { - this.actions.onRemoteHome.createInWorkspace(path, agentType); + this.createRemoteSessionInWorkspace(path, agentType, compact); }, onSelectWorkspace: (path: string) => { this.actions.onRemoteHome.selectWorkspace(path); }, onOpenSession: (session: RemoteSession) => { - this.actions.onRemoteHome.openSession(session); + this.openRemoteSession(session, compact); }, onDeleteSession: (session: RemoteSession) => { this.actions.onRemoteHome.deleteSession(session); @@ -435,135 +617,102 @@ export struct AppRootPresentation { this.actions.onRemoteHome.loadMore(); } }) - this.RemoteWideSearchBar() } else { - this.RemoteWideDisconnected() + this.RemoteDisconnectedState() } } - .width(this.wideMasterPaneWidth) + .width('100%') .height('100%') - .padding({ left: 24, right: 18, top: 14, bottom: 12 }) - .backgroundColor(PAGE_BG) - .border({ width: { right: 1 }, color: LINE }) + .alignItems(HorizontalAlign.Start) + .padding({ bottom: 84 }) } + /** Connection status lives in the remote content, not in the shared header. */ @Builder - RemoteWidePaneHeader() { - Row({ space: 10 }) { - Column({ space: 4 }) { - Text(RemoteI18n.t('remote.title')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Row({ space: 6 }) { - this.RemoteWideStatusDot() - Text(this.remoteWideStatusText()) - .fontSize(12) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - } - .width('100%') - .alignItems(VerticalAlign.Center) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_more')) - .width(22) - .height(8) - .objectFit(ImageFit.Contain) - } - .width(38) - .height(38) - .backgroundColor(CARD) - .borderRadius(19) - .border({ width: 1, color: LINE }) - .onClick(() => { - this.actions.onRemoteHome.openSettings(); - }) + RemoteStatusRow() { + Row({ space: 6 }) { + this.RemoteStatusIndicator() + Text(this.remoteStatusText()) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) } .width('100%') - .height(46) + .margin({ top: 16, bottom: 6 }) .alignItems(VerticalAlign.Center) } @Builder - RemoteWideStatusDot() { - Stack() { - Text('') - } - .width(7) - .height(7) - .backgroundColor(this.remoteWideStatusColor()) - .borderRadius(4) + 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; + } + }) } @Builder - RemoteWideSearchBar() { - Row({ space: 8 }) { - Row({ space: 7 }) { - Image($r('app.media.remote_ref_search_reference')) - .width(16) - .height(16) - .objectFit(ImageFit.Contain) - .opacity(0.58) - TextInput({ placeholder: RemoteI18n.t('remote.searchChats'), text: this.remotePageState.sessionQuery }) - .height(38) - .fontSize(14) - .fontColor(INK) - .placeholderColor(MUTED) - .backgroundColor('#00000000') - .padding({ left: 0, right: 0 }) - .layoutWeight(1) - .onChange((value: string) => { - this.actions.onRemoteHome.queryChanged(value); - }) - .onSubmit(() => { - this.actions.onRemoteHome.search(); - }) - } - .height(42) - .layoutWeight(1) - .padding({ left: 12, right: 10 }) - .backgroundColor(CARD) - .borderRadius(21) - .border({ width: 1, color: LINE }) - - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_new_chat')) - .width(20) - .height(20) - .objectFit(ImageFit.Contain) + RemoteStatusIndicator() { + if (this.isRemoteInitialLoading()) { + LoadingProgress() + .width(14) + .height(14) + .color(MUTED) + } else { + Stack() { + Text('') } - .width(42) - .height(42) - .backgroundColor(INK) - .borderRadius(21) - .opacity(this.remotePageState.isBusy ? 0.45 : 1) - .onClick(() => { - if (!this.remotePageState.isBusy) { - this.actions.onRemoteHome.createAssistant(); - } - }) + .width(7) + .height(7) + .backgroundColor(this.remoteStatusColor()) + .borderRadius(4) } - .width('100%') - .height(46) - .alignItems(VerticalAlign.Center) } @Builder - RemoteWideDisconnected() { + RemoteDisconnectedState() { Column({ space: 12 }) { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_device')) - .width(42) - .height(42) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(42) + .fontColor([INK]) } .width(74) .height(74) @@ -580,12 +729,13 @@ export struct AppRootPresentation { .lineHeight(20) .fontColor(MUTED) .textAlign(TextAlign.Center) - Button(RemoteI18n.t('connect.connect')) + Text(RemoteI18n.t('connect.connect')) .width(136) .height(44) .fontSize(15) - .fontColor(CARD) - .backgroundColor(INK) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center) .borderRadius(22) .onClick(() => { this.actions.onRemoteHome.connectWorkspace(); @@ -600,10 +750,45 @@ export struct AppRootPresentation { @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.RemoteMasterPane(this.remotePageState.activeSession.sessionId) - this.WideMasterDetailGap() - this.WideConversationDetail(AppRoute.RemoteChat, false) + this.WideConversationDetail( + AppRoute.RemoteChat, + false, + this.filePreviewLayout().conversationPaneWidth + ) + this.WidePaneGap(this.filePreviewLayout().conversationPreviewGap) + this.FilePreviewPane(this.filePreviewLayout().previewPaneWidth) } .width('100%') .height('100%') @@ -611,31 +796,82 @@ export struct AppRootPresentation { } @Builder - WideConversationDetail(route: AppRoute, showBackButton: boolean) { - Row() { - if (this.wideDetailContentOffset > 0) { - Blank().width(this.wideDetailContentOffset) + FilePreviewPane(paneWidth: number = 0) { + 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 + }) + } + .layoutWeight(paneWidth > 0 ? 0 : 1) + .width(paneWidth > 0 ? paneWidth : '100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + WideConversationDetail(route: AppRoute, showBackButton: boolean, paneWidth: number = 0) { + if (paneWidth > 0) { + Column() { + this.RouteSurfaceContent(route, false, showBackButton, false, true) } - Row() { - Column() { - this.RouteSurfaceContent(route, false, showBackButton) + .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%') - .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .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 })) + } } - .width(this.wideDetailContentWidth > 0 ? this.wideDetailContentWidth : '100%') + .layoutWeight(1) .height('100%') - .justifyContent(FlexAlign.Center) - if (this.wideDetailContentOffset > 0) { - Blank().layoutWeight(1) - } + .backgroundColor(PAGE_BG) } - .layoutWeight(1) - .height('100%') - .justifyContent(FlexAlign.Center) - .backgroundColor(PAGE_BG) } @Builder @@ -649,10 +885,100 @@ export struct AppRootPresentation { } } + @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() { + 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) @@ -664,20 +990,28 @@ export struct AppRootPresentation { .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } - .alignItems(HorizontalAlign.Start) - Blank() + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + Blank().width(48).height(48) } .width('100%') .height(76) - .padding({ left: 24, right: 24, top: 14, bottom: 12 }) + .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.remoteWideStatusText()) + Text(this.remoteStatusText()) .fontSize(14) .fontColor(MUTED) .maxLines(2) @@ -695,10 +1029,73 @@ export struct AppRootPresentation { } private isWideLayout(): boolean { - return ConversationLayoutPolicy.useMasterDetail( + 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; + } + this.enableWideMasterPaneMotion(); + this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseInOut }, () => { + this.wideMasterPaneCollapsed = true; + }); + } + + private restoreWideMasterPane(): void { + this.enableWideMasterPaneMotion(); + this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseInOut }, () => { + this.wideMasterPaneCollapsed = false; + this.restoreCollapsedMasterAfterPreview = false; + }); + } + + private enableWideMasterPaneMotion(): void { + this.wideMasterPaneMotionActive = true; + setTimeout(() => { + this.wideMasterPaneMotionActive = false; + }, 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; + } + + private filePreviewLayout(): FilePreviewLayout { + return FilePreviewPlacementPolicy.resolveLayout( + this.filePreviewState.visible, + this.isWideLayout(), this.viewportWidth, - this.wideLayoutMatched, - this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED + this.verticalCreases, + this.wideMasterPaneWidth ); } @@ -706,6 +1103,14 @@ export struct AppRootPresentation { 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 bindResponsiveQueries(): void { this.unbindResponsiveQueries(); try { @@ -753,14 +1158,29 @@ export struct AppRootPresentation { } private refreshWideGeometry(): void { + const wasWideLayout = this.largeScreenLayout; + const verticalCreases = this.currentVerticalCreases(); + this.verticalCreases = verticalCreases; + this.largeScreenLayout = ConversationLayoutPolicy.useMasterDetail( + this.viewportWidth, + this.wideLayoutMatched, + this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED, + this.deviceType, + verticalCreases + ); const geometry = ConversationLayoutPolicy.resolveWideGeometry( this.viewportWidth, - this.currentVerticalCreases() + verticalCreases ); this.wideMasterPaneWidth = geometry.masterPaneWidth; this.wideMasterDetailGap = geometry.masterDetailGap; this.wideDetailContentOffset = geometry.detailContentOffset; this.wideDetailContentWidth = geometry.detailContentWidth; + this.wideCollapsedDetailContentOffset = geometry.collapsedDetailContentOffset; + this.wideCollapsedDetailContentWidth = geometry.collapsedDetailContentWidth; + if (wasWideLayout && !this.largeScreenLayout) { + this.actions.onCompactLayoutEntered(); + } } private currentVerticalCreases(): ConversationLayoutCrease[] { @@ -782,19 +1202,89 @@ export struct AppRootPresentation { } } - private canShowRemoteWideList(): boolean { + /** + * 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 remoteWideStatusText(): string { + 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 remoteWideStatusColor(): string { + private remoteStatusColor(): ResourceColor { if (this.remotePageState.connectionState === 'connected') { return GREEN; } @@ -809,22 +1299,98 @@ export struct AppRootPresentation { 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 { + height: 520, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: true + }; + } + return { + height: 520, + width: 560, + preferType: SheetType.CENTER, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + + /** + * 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.generalPageState.recentSessions(), pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: this.currentRoute === AppRoute.ChatHome || this.currentRoute === AppRoute.GeneralChat ? - this.generalPageState.activeSession.sessionId : '', connectionState: this.remotePageState.connectionState, + 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.currentRoute === AppRoute.RemoteHome || this.currentRoute === AppRoute.RemoteChat ? 'remote' : 'chat', + 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.actions.onSidebar.newChat, onEnterCode: this.actions.onSidebar.enterCode, - onOpenSettings: this.actions.onSidebar.settings, onOpenAccount: this.actions.onSidebar.openAccount, + 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 }) + onArchiveSession: this.actions.onSidebar.archive, + onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession + }) } @Builder SettingsContent() { @@ -849,6 +1415,8 @@ export struct AppRootPresentation { } 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, deviceId: this.deviceId, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets index 1d210fd783..1d71ccca67 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets @@ -1,14 +1,25 @@ import { AppShellState } from '../state/AppShellState'; +import { PAGE_BG } from './Theme'; + +const WIDE_SETTINGS_SHEET_MAX_WIDTH: number = 680; +const WIDE_CONNECT_SHEET_MAX_WIDTH: number = 620; +const WIDE_SHEET_MIN_WIDTH: number = 540; +const WIDE_SHEET_MAX_HEIGHT: number = 760; +const WIDE_SHEET_MIN_HEIGHT: number = 560; +const WIDE_SHEET_VERTICAL_MARGIN: number = 80; +const WIDE_SHEET_RADIUS: number = 30; @ComponentV2 export struct AppShell { @Param shellState: AppShellState = new AppShellState(); + @Param useWideLayout: boolean = false; @BuilderParam content: () => void = this.EmptyBuilder; @BuilderParam sidebar: () => void = this.EmptyBuilder; @BuilderParam settings: () => void = this.EmptyBuilder; @BuilderParam connect: () => void = this.EmptyBuilder; @Event onCloseSidebar: () => void = () => {}; @Local shellWidth: number = 440; + @Local shellHeight: number = 0; build() { Stack({ alignContent: Alignment.Start }) { @@ -31,20 +42,15 @@ export struct AppShell { } .width('100%') .height('100%') - .bindSheet($$this.shellState.showConnectSheet, this.connect(), { - height: SheetSize.LARGE, - backgroundColor: '#00000000', - maskColor: '#44000000', - showClose: false, - dragBar: false - }) + .bindSheet($$this.shellState.showConnectSheet, this.ConnectSheetContent(), this.connectSheetOptions()) if (this.shellState.showSidebar) { Column() { } .width('100%') .height('100%') - .backgroundColor('#66FFFFFF') + .backgroundColor(PAGE_BG) + .opacity(0.62) .transition(TransitionEffect.opacity(0) .animation({ duration: 210, curve: Curve.EaseOut })) .onClick(() => { @@ -54,7 +60,7 @@ export struct AppShell { } .width('100%') .height('100%') - .backgroundColor('#FFFFFF') + .backgroundColor(PAGE_BG) .borderRadius(this.shellState.showSidebar ? 28 : 0) .clip(true) .shadow({ @@ -74,30 +80,45 @@ export struct AppShell { duration: this.shellState.showSidebar ? 320 : 250, curve: Curve.EaseOut }) - - if (this.shellState.showAccount) { - Column() { - this.settings() - } - .width('100%') - .height('100%') - .backgroundColor('#FFFFFF') - .zIndex(20) - .transition(TransitionEffect.opacity(0) - .animation({ duration: 180, curve: Curve.EaseOut })) - } } .width('100%') .height('100%') .onAreaChange((_oldArea: Area, newArea: Area) => { - this.shellWidth = Number(newArea.width); + this.shellWidth = this.areaLength(newArea.width); + this.shellHeight = this.areaLength(newArea.height); }) - .bindSheet($$this.shellState.showSettings, this.settings(), { - height: SheetSize.LARGE, - backgroundColor: '#00000000', - maskColor: '#44000000', - showClose: false, - dragBar: false + .bindSheet($$this.shellState.showSettings, this.SettingsSheetContent(), this.settingsSheetOptions()) + } + + @Builder + private SettingsSheetContent() { + Column() { + this.settings() + } + .width('100%') + .height('100%') + .borderRadius(this.shouldUseWideSheetLayout() ? WIDE_SHEET_RADIUS : 0) + .clip(this.shouldUseWideSheetLayout()) + .shadow({ + radius: this.shouldUseWideSheetLayout() ? 30 : 0, + color: this.shouldUseWideSheetLayout() ? '#22000000' : '#00000000', + offsetY: this.shouldUseWideSheetLayout() ? 12 : 0 + }) + } + + @Builder + private ConnectSheetContent() { + Column() { + this.connect() + } + .width('100%') + .height('100%') + .borderRadius(this.shouldUseWideSheetLayout() ? WIDE_SHEET_RADIUS : 0) + .clip(this.shouldUseWideSheetLayout()) + .shadow({ + radius: this.shouldUseWideSheetLayout() ? 30 : 0, + color: this.shouldUseWideSheetLayout() ? '#22000000' : '#00000000', + offsetY: this.shouldUseWideSheetLayout() ? 12 : 0 }) } @@ -111,6 +132,75 @@ export struct AppShell { return Math.min(420, Math.max(280, Math.round(this.shellWidth * 0.35))); } + private settingsSheetOptions(): SheetOptions { + if (!this.shouldUseWideSheetLayout()) { + return this.bottomSheetOptions(); + } + return this.wideCenterSheetOptions(this.settingsSheetWidth()); + } + + private connectSheetOptions(): SheetOptions { + if (!this.shouldUseWideSheetLayout()) { + return this.bottomSheetOptions(); + } + return this.wideCenterSheetOptions(this.connectSheetWidth()); + } + + private bottomSheetOptions(): SheetOptions { + return { + height: SheetSize.LARGE, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + + private wideCenterSheetOptions(width: number): SheetOptions { + return { + height: this.wideSheetHeight(), + width, + preferType: SheetType.CENTER, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + + private settingsSheetWidth(): number { + return Math.min( + WIDE_SETTINGS_SHEET_MAX_WIDTH, + Math.max(WIDE_SHEET_MIN_WIDTH, Math.round(this.shellWidth * 0.48)) + ); + } + + private connectSheetWidth(): number { + return Math.min( + WIDE_CONNECT_SHEET_MAX_WIDTH, + Math.max(WIDE_SHEET_MIN_WIDTH, Math.round(this.shellWidth * 0.46)) + ); + } + + private wideSheetHeight(): number { + if (this.shellHeight <= 0) { + return WIDE_SHEET_MAX_HEIGHT; + } + return Math.min( + WIDE_SHEET_MAX_HEIGHT, + Math.max(WIDE_SHEET_MIN_HEIGHT, Math.round(this.shellHeight - WIDE_SHEET_VERTICAL_MARGIN)) + ); + } + + private shouldUseWideSheetLayout(): boolean { + return this.useWideLayout; + } + + private areaLength(value: Object): number { + const parsed = Number.parseFloat(`${value}`); + return Number.isNaN(parsed) ? 0 : parsed; + } + @Builder EmptyBuilder() { Column() { 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 3576fbfe77..08cec8c99d 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 @@ -1,8 +1,12 @@ import { RemoteSession } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, MUTED, SUBTLE } from './Theme'; +import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, SOFT, SUBTLE } from './Theme'; 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 { SessionDetailsView } from './SessionDetailsView'; @Component export struct AppSidebar { @@ -13,11 +17,17 @@ export struct AppSidebar { @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) => {}; @@ -26,10 +36,18 @@ export struct AppSidebar { onExportSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @State activeActionSessionId: string = ''; - @State pendingDeleteSessionId: string = ''; + @State showSessionActionSheet: boolean = false; + @State detailsSessionId: string = ''; + @State showSessionDetails: boolean = false; @State showSearch: boolean = false; @State sessionSearchQuery: string = ''; @State archivedSessionsExpanded: boolean = false; + /** + * Session content for the current conversation source. The shell around it + * (header, source switcher, content origin, footer) stays identical for every + * source so switching Local/Remote never moves shared chrome. + */ + @BuilderParam contentSlot: () => void = this.LocalSessionContent; build() { this.SidebarContent() @@ -59,97 +77,147 @@ export struct AppSidebar { .width('100%') .margin({ top: 18 }) + Stack({ alignContent: Alignment.Bottom }) { + Column() { + if (this.showCustomContent) { + this.contentSlot() + } else { + this.LocalSessionContent() + } + } + .width('100%') + .height('100%') + .alignItems(HorizontalAlign.Start) + + if (this.isAccountAuthenticated()) { + this.AuthenticatedFooter() + } else { + this.SignedOutFooter() + } + } + .width('100%') + .layoutWeight(1) + } + .width('100%') + .height('100%') + .padding({ left: 20, right: 20, top: 4, bottom: 16 }) + .backgroundColor(PAGE_BG) + .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) + .bindSheet($$this.showSessionDetails, this.SessionDetailsSheet(), this.sessionDetailsSheetOptions()) + } + + @Builder + LocalSessionContent() { + Column() { if (this.visiblePinnedSessions().length > 0) { Text('置顶') - .fontSize(16).fontWeight(FontWeight.Bold).fontColor(INK) - .width('100%').margin({ top: 18, bottom: 10 }) + .fontSize(14).fontWeight(FontWeight.Medium).fontColor(MUTED) + .width('100%').margin({ top: 16, bottom: 6 }) ForEach(this.visiblePinnedSessions(), (session: RemoteSession) => { this.PinnedRow(session) }, (session: RemoteSession) => session.id) } Text(RemoteI18n.t('sidebar.recent')) - .fontSize(16) - .fontWeight(FontWeight.Bold) - .fontColor(INK) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) .width('100%') - .margin({ top: 18, bottom: 10 }) + .margin({ top: 16, bottom: 6 }) - Stack({ alignContent: Alignment.Bottom }) { - List({ space: 2 }) { - if (this.visibleRecentSessions().length === 0) { + List({ space: 2 }) { + if (this.visibleRecentSessions().length === 0) { + ListItem() { + this.EmptyRecent() + } + } + Repeat(this.visibleRecentSessions()) + .each((obj: RepeatItem) => { ListItem() { - this.EmptyRecent() + this.RecentRow(obj) } + }) + .key((item: RemoteSession) => item.id) + .virtualScroll({ totalCount: this.visibleRecentSessions().length }) + if (this.archivedSessionCount() > 0) { + ListItem() { + this.ArchivedDisclosureRow() } - Repeat(this.visibleRecentSessions()) + } + if (this.archivedSessionsExpanded) { + Repeat(this.visibleArchivedSessions()) .each((obj: RepeatItem) => { ListItem() { this.RecentRow(obj) } }) .key((item: RemoteSession) => item.id) - .virtualScroll({ totalCount: this.visibleRecentSessions().length }) - if (this.archivedSessionCount() > 0) { - ListItem() { - this.ArchivedDisclosureRow() - } - } - if (this.archivedSessionsExpanded) { - Repeat(this.visibleArchivedSessions()) - .each((obj: RepeatItem) => { - ListItem() { - this.RecentRow(obj) - } - }) - .key((item: RemoteSession) => item.id) - .virtualScroll({ totalCount: this.visibleArchivedSessions().length }) - } - } - .width('100%') - .height('100%') - .margin({ left: 0 }) - .padding({ bottom: 72 }) - .scrollBar(BarState.Off) - .divider(null) - - if (this.isAccountAuthenticated()) { - this.AuthenticatedFooter() - } else { - this.SignedOutFooter() + .virtualScroll({ totalCount: this.visibleArchivedSessions().length }) } } .width('100%') .layoutWeight(1) + .margin({ left: 0 }) + .padding({ bottom: 84 }) + .scrollBar(BarState.Off) + .divider(null) } .width('100%') .height('100%') - .padding({ left: 24, right: 18, top: 4, bottom: 7 }) - .backgroundColor(CARD) + .alignItems(HorizontalAlign.Start) } @Builder private AuthenticatedHeader() { Row() { Text('BitFun') - .fontSize(23) + .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor(INK) Blank() - Stack({ alignContent: Alignment.Center }) { - this.SearchGlyph() - } - .width(46) - .height(46) + // Right-aligned so the search and collapse controls keep the same + // position whether or not the source-specific view-settings entry shows. + Row({ space: 6 }) { + if (this.showViewSettingsButton) { + Stack({ alignContent: Alignment.Center }) { + this.MoreDotsGlyph() + } + .width(38) + .height(38) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(19) + .shadow({ radius: 14, color: '#16000000', offsetY: 6 }) + .accessibilityText(RemoteI18n.t('remote.actions')) + .onClick(() => { + this.onOpenViewSettings(); + }) + } + + Stack({ alignContent: Alignment.Center }) { + this.SearchGlyph() + } + .width(38) + .height(38) .backgroundColor(CARD) - .borderRadius(23) - .shadow({ radius: 18, color: '#10000000', offsetY: 8 }) + .border({ width: 1, color: LINE }) + .borderRadius(19) + .shadow({ radius: 14, color: '#16000000', offsetY: 6 }) + .accessibilityText(RemoteI18n.t('common.search')) .onClick(() => { this.showSearch = !this.showSearch; if (!this.showSearch) { - this.sessionSearchQuery = ''; + this.updateSearchQuery(''); } }) + + if (this.showCollapseButton) { + SidebarToggleButton({ + controlSize: 38, + onToggle: this.onCollapse + }) + } + } } .width('100%') .height(50) @@ -163,54 +231,71 @@ export struct AppSidebar { .fontColor(INK) .placeholderColor(SUBTLE) .padding({ left: 14, right: 14 }) - .backgroundColor('#F4F4F2') + .backgroundColor(SOFT) .borderRadius(8) .margin({ top: 12 }) .onChange((value: string) => { - this.sessionSearchQuery = value; + this.updateSearchQuery(value); }) } } + private updateSearchQuery(value: string): void { + this.sessionSearchQuery = value; + this.onSearchQueryChange(value); + } + @Builder private SignedOutHeader() { - Row({ space: 16 }) { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(25) - .fontColor([INK]) - .width(26) - .height(26) - Text(RemoteI18n.t('sidebar.signedOutNewChat')) - .fontSize(18) - .fontWeight(FontWeight.Medium) - .fontColor(INK) + Row() { + Row({ space: 14 }) { + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(23) + .fontColor([INK]) + .width(24) + .height(24) + Text(RemoteI18n.t('sidebar.signedOutNewChat')) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + } + .layoutWeight(1) + .height(50) + .alignItems(VerticalAlign.Center) + .onClick(() => { + this.onNewChat(); + }) + + if (this.showCollapseButton) { + SidebarToggleButton({ + controlSize: 38, + onToggle: this.onCollapse + }) + } } .width('100%') .height(50) .alignItems(VerticalAlign.Center) - .onClick(() => { - this.onNewChat(); - }) } @Builder private AuthenticatedFooter() { Row() { - Button() { - Row({ space: 10 }) { - this.EditGlyph() - Text(RemoteI18n.t('sidebar.newChat')) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor(CARD) - } - .justifyContent(FlexAlign.Center) - .width('100%') + Row({ space: 9 }) { + this.EditGlyph() + Text(RemoteI18n.t('sidebar.newChat')) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(INK) } .width(116) - .height(44) - .backgroundColor('#3B82F6') - .borderRadius(22) + .height(46) + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(23) + .shadow({ radius: 14, color: '#16000000', offsetY: 6 }) .onClick(() => { this.onNewChat(); }) @@ -224,13 +309,15 @@ export struct AppSidebar { .height(46) .backgroundColor(CARD) .borderRadius(23) - .shadow({ radius: 16, color: '#0E000000', offsetY: 8 }) + .shadow({ radius: 14, color: '#16000000', offsetY: 6 }) .onClick(() => { this.onOpenSettings(); }) } .width('100%') + .height(56) .alignItems(VerticalAlign.Center) + .zIndex(2) } @Builder @@ -261,7 +348,7 @@ export struct AppSidebar { .width('100%') .height(46) .padding({ left: 12, right: 8 }) - .backgroundColor(isActive ? '#F3F3F3' : '#00000000') + .backgroundColor(isActive ? SOFT : '#00000000') .borderRadius(12) .onClick(action) } @@ -275,7 +362,7 @@ export struct AppSidebar { .width(22) .height(22) Text(RemoteI18n.t('sidebar.archived')) - .fontSize(16) + .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(INK) Blank() @@ -287,7 +374,7 @@ export struct AppSidebar { .width(24) .height(22) .textAlign(TextAlign.Center) - .backgroundColor('#EFEFED') + .backgroundColor(SOFT) .borderRadius(11) Stack({ alignContent: Alignment.Center }) { if (this.archivedSessionsExpanded) { @@ -309,125 +396,143 @@ export struct AppSidebar { } .width('100%') .height(46) - .padding({ left: 14, right: 68 }) + .padding({ left: 12, right: 68 }) .margin({ top: 8 }) - .backgroundColor(this.archivedSessionsExpanded ? '#F7F7F5' : '#00000000') - .borderRadius(14) + .backgroundColor(this.archivedSessionsExpanded ? SOFT : '#00000000') + .borderRadius(10) .onClick(() => { this.archivedSessionsExpanded = !this.archivedSessionsExpanded; - this.activeActionSessionId = ''; - this.pendingDeleteSessionId = ''; + this.closeSessionActions(); }) } @Builder PinnedRow(session: RemoteSession) { - Row({ space: 10 }) { - Image($r('app.media.remote_actions_check')) - .width(19).height(19).objectFit(ImageFit.Contain) + Row({ space: 8 }) { + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(18).fontColor([MUTED]).width(19).height(19) Text(session.title || RemoteI18n.t('sidebar.untitled')) - .fontSize(17).fontColor(INK).layoutWeight(1) + .fontSize(15).fontColor(INK).layoutWeight(1) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + this.SessionMoreButton(session) } - .width('100%').height(46) - .padding({ left: 14, right: 12 }) - .backgroundColor(this.selectedSessionId === session.id ? '#F3F3F3' : '#00000000') - .borderRadius(14) + .width('100%').height(44) + .padding({ left: 12, right: 4 }) + .backgroundColor(this.selectedSessionId === session.id ? SOFT : '#00000000') + .borderRadius(10) .onClick(() => this.openSession(session)) + .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(session))) + .bindPopup(this.showCollapseButton && this.activeActionSessionId === session.id, { + builder: () => { this.SessionActionPopover() }, + placement: Placement.Right, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 6, + onStateChange: (event) => { + if (!event.isVisible) { + this.closeSessionActions(); + } + } + }) } @Builder RecentRow(obj: RepeatItem) { - Column({ space: 4 }) { - Row() { - Text(obj.item.title || RemoteI18n.t('sidebar.untitled')) - .fontSize(17) - .fontWeight(FontWeight.Regular) - .fontColor(INK) - .width('100%') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - .height(46) - .padding({ left: 14, right: 12 }) - .backgroundColor(this.selectedSessionId === obj.item.id || this.activeActionSessionId === obj.item.id ? - '#F3F3F3' : '#00000000') - .borderRadius(14) - .onClick(() => { - this.openSession(obj.item); - }) - .gesture( - LongPressGesture({ repeat: false }) - .onAction(() => { - this.activeActionSessionId = obj.item.id; - this.pendingDeleteSessionId = ''; - }) - ) - if (this.activeActionSessionId === obj.item.id && this.pendingDeleteSessionId !== obj.item.id) { - Column({ space: 2 }) { - if (obj.item.agentType === 'chat') { - this.SessionAction(obj.item.status === 'archived' ? - RemoteI18n.t('sidebar.unarchive') : RemoteI18n.t('sidebar.archive'), () => { - this.activeActionSessionId = ''; - this.onArchiveSession(obj.item, obj.item.status !== 'archived'); - }) - this.SessionAction(RemoteI18n.t('sidebar.exportMarkdown'), () => { - this.activeActionSessionId = ''; - this.onExportSession(obj.item); - }) - } - this.SessionAction(RemoteI18n.t('common.delete'), () => { - this.pendingDeleteSessionId = obj.item.id; - }, true) - this.SessionAction(RemoteI18n.t('common.cancel'), () => { - this.activeActionSessionId = ''; - this.pendingDeleteSessionId = ''; - }) - } - .width('100%') - .padding({ top: 6, bottom: 2 }) - } - if (this.pendingDeleteSessionId === obj.item.id) { - Row({ space: 10 }) { - Text(RemoteI18n.t('sidebar.deleteConfirm')) - .fontSize(12) - .fontColor(SUBTLE) - .layoutWeight(1) - Text(RemoteI18n.t('common.delete')) - .fontSize(12) - .fontColor('#C33B32') - .height(30) - .padding({ left: 12, right: 12 }) - .textAlign(TextAlign.Center) - .backgroundColor('#FFF0EE') - .borderRadius(15) - .onClick(() => { - this.activeActionSessionId = ''; - this.pendingDeleteSessionId = ''; - this.onDeleteSession(obj.item); - }) + Row({ space: 8 }) { + Text(obj.item.title || RemoteI18n.t('sidebar.untitled')) + .fontSize(15) + .fontWeight(FontWeight.Regular) + .fontColor(INK) + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + this.SessionMoreButton(obj.item) + } + .width('100%') + .height(44) + .padding({ left: 12, right: 4 }) + .backgroundColor(this.selectedSessionId === obj.item.id || this.activeActionSessionId === obj.item.id ? + SOFT : '#00000000') + .borderRadius(10) + .onClick(() => { + this.openSession(obj.item); + }) + .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(obj.item))) + .bindPopup(this.showCollapseButton && this.activeActionSessionId === obj.item.id, { + builder: () => { this.SessionActionPopover() }, + placement: Placement.Right, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 6, + onStateChange: (event) => { + if (!event.isVisible) { + this.closeSessionActions(); } - .width('100%') - .margin({ top: 6 }) - .alignItems(VerticalAlign.Center) } + }) + } + + @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) } - .width('100%') + .height(8) + .alignItems(VerticalAlign.Center) } @Builder - SessionAction(label: string, action: () => void, destructive: boolean = false) { - Text(label) - .width('100%') - .height(34) - .padding({ left: 12, right: 12 }) - .fontSize(13) - .fontColor(destructive ? '#C33B32' : INK) - .textAlign(TextAlign.Start) - .backgroundColor(destructive ? '#FFF0EE' : '#F4F4F2') - .borderRadius(6) - .onClick(action) + private SessionMoreButton(session: RemoteSession) { + Stack({ alignContent: Alignment.Center }) { + this.MoreDotsGlyph() + } + .width(34) + .height(40) + .opacity(0.62) + .accessibilityText(RemoteI18n.t('session.actions')) + .onClick(() => this.openSessionActions(session)) + } + + @Builder + private SessionActionSheet() { + this.SessionActionContent(SessionActionPresentation.BottomSheet) + } + + @Builder + private SessionActionPopover() { + this.SessionActionContent(SessionActionPresentation.Popover) + } + + @Builder + private SessionActionContent(presentation: SessionActionPresentation) { + SessionActionSurface({ + presentation, + sessionTitle: this.actionSessionTitle(), + archived: this.actionSessionArchived(), + canViewDetails: this.actionCapabilities().canViewDetails, + canArchive: this.actionCapabilities().canArchive, + canExport: this.actionCapabilities().canExport, + canDelete: this.actionCapabilities().canDelete, + onViewDetails: () => this.openActionSessionDetails(), + onArchive: () => this.archiveActionSession(), + onExport: () => this.exportActionSession(), + onDelete: () => this.deleteActionSession(), + onClose: () => this.closeSessionActions() + }) + } + + @Builder + private SessionDetailsSheet() { + SessionDetailsView({ + session: this.detailsSession(), + onClose: () => this.closeSessionDetails() + }) } private openSession(item: RemoteSession): void { @@ -455,26 +560,40 @@ export struct AppSidebar { @Builder RemoteGlyph() { - if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { - Image($r('app.media.remote_ref_sidebar_connected')) - .width(35) - .height(34) - .objectFit(ImageFit.Contain) - } else { - Image($r('app.media.remote_logo')) - .width(34) - .height(34) - .objectFit(ImageFit.Contain) + 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() { - Image($r('app.media.sidebar_ref_search_reference')) - .width(23) - .height(23) - .objectFit(ImageFit.Contain) - .translate({ x: -2.5, y: 1.5 }) + SymbolGlyph($r('sys.symbol.magnifyingglass')) + .fontSize(22) + .fontColor([INK]) + .width(24) + .height(24) } @Builder @@ -622,19 +741,20 @@ export struct AppSidebar { @Builder EditGlyph() { - Image($r('app.media.remote_ref_new_chat')) - .width(25) - .height(25) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(22) + .fontColor([INK]) + .width(24) + .height(24) } @Builder SettingsGlyph() { - Image($r('app.media.sidebar_ref_settings_reference')) - .width(24.5) - .height(25) - .objectFit(ImageFit.Contain) - .translate({ x: -5 }) + SymbolGlyph($r('sys.symbol.gearshape')) + .fontSize(22) + .fontColor([INK]) + .width(24) + .height(24) } private visibleRecentSessions(): RemoteSession[] { @@ -670,6 +790,118 @@ export struct AppSidebar { }); } + private openSessionActions(session: RemoteSession): void { + this.activeActionSessionId = session.id; + if (!this.showCollapseButton) { + this.showSessionActionSheet = true; + } + } + + private closeSessionActions(): void { + this.showSessionActionSheet = false; + this.activeActionSessionId = ''; + } + + private actionSession(): RemoteSession | undefined { + return this.sessions.find((session: RemoteSession) => session.id === this.activeActionSessionId); + } + + private actionSessionTitle(): string { + const session = this.actionSession(); + return session ? session.title : ''; + } + + private actionSessionArchived(): boolean { + const session = this.actionSession(); + return session ? session.status === 'archived' : false; + } + + private actionSessionIsGeneralChat(): boolean { + const session = this.actionSession(); + return session ? session.agentType === 'chat' : false; + } + + private actionCapabilities(): SessionActionCapabilities { + const session = this.actionSession(); + return SessionActionPolicy.resolve( + SessionActionScope.General, + session ? session.agentType : '', + session === undefined + ); + } + + private archiveActionSession(): void { + const session = this.actionSession(); + if (session) { + this.onArchiveSession(session, session.status !== 'archived'); + } + } + + private exportActionSession(): void { + const session = this.actionSession(); + if (session) { + this.onExportSession(session); + } + } + + private deleteActionSession(): void { + const session = this.actionSession(); + if (session) { + this.onDeleteSession(session); + } + } + + private openActionSessionDetails(): void { + const session = this.actionSession(); + if (session) { + this.detailsSessionId = session.id; + this.showSessionDetails = true; + } + } + + private closeSessionDetails(): void { + this.showSessionDetails = false; + this.detailsSessionId = ''; + } + + private detailsSession(): RemoteSession { + const session = this.sessions.find((item: RemoteSession) => item.id === this.detailsSessionId); + return session || { + id: '', title: '', agentType: '', status: '', updatedAt: '', createdAt: '', messageCount: 0 + }; + } + + private sessionActionSheetOptions(): SheetOptions { + return { + height: this.actionSessionIsGeneralChat() ? 380 : 300, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + + private sessionDetailsSheetOptions(): SheetOptions { + if (!this.showCollapseButton) { + return { + height: SheetSize.LARGE, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + return { + height: 560, + width: 560, + preferType: SheetType.CENTER, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + private isAccountAuthenticated(): boolean { return this.accountUserId.trim().length > 0; } 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 687cdbf92e..38375e4387 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets @@ -1,6 +1,6 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { DEFAULT_CLOUD_RELAY_URL } from '../../services/CloudAccountClient'; -import { CARD, INK, MUTED } from './Theme'; +import { CARD, INK, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SUBTLE } from './Theme'; @Component export struct BitFunAccountLoginPage { @@ -36,7 +36,7 @@ export struct BitFunAccountLoginPage { .height(58) .fontSize(17) .fontColor(INK) - .placeholderColor('#B8B8BC') + .placeholderColor(SUBTLE) .backgroundColor(CARD) .borderRadius(18) .padding({ left: 20, right: 20 }) @@ -46,7 +46,7 @@ export struct BitFunAccountLoginPage { .height(58) .fontSize(17) .fontColor(INK) - .placeholderColor('#B8B8BC') + .placeholderColor(SUBTLE) .backgroundColor(CARD) .borderRadius(18) .padding({ left: 20, right: 20 }) @@ -64,7 +64,7 @@ export struct BitFunAccountLoginPage { .height(52) .fontSize(14) .fontColor(INK) - .placeholderColor('#B8B8BC') + .placeholderColor(SUBTLE) .backgroundColor(CARD) .borderRadius(16) .padding({ left: 18, right: 18 }) @@ -74,7 +74,7 @@ export struct BitFunAccountLoginPage { Text(this.errorText) .fontSize(13) .lineHeight(19) - .fontColor('#D04A3A') + .fontColor(RED) .width('100%') .margin({ top: 12 }) } @@ -85,8 +85,8 @@ export struct BitFunAccountLoginPage { .width('100%') .fontSize(17) .fontWeight(FontWeight.Bold) - .fontColor(CARD) - .backgroundColor(INK) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) .borderRadius(18) .margin({ top: this.errorText.length > 0 ? 22 : 30 }) .opacity(this.canSubmit() ? 1 : 0.28) @@ -104,10 +104,11 @@ export struct BitFunAccountLoginPage { .scrollBar(BarState.Off) Button() { - Image($r('app.media.remote_ref_back')) + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(23) + .fontColor([INK]) .width(26) .height(26) - .objectFit(ImageFit.Contain) } .width(44) .height(44) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets index 372f43c008..6edfc38e4a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets @@ -4,11 +4,21 @@ export class ChatComposerCapabilities { readonly surface: ChatSurface; readonly supportsAttachments: boolean; readonly requiresRemoteConnection: boolean; + readonly showAddButton: boolean; + readonly showVoiceInput: boolean; - constructor(surface: ChatSurface, supportsAttachments: boolean, requiresRemoteConnection: boolean) { + constructor( + surface: ChatSurface, + supportsAttachments: boolean, + requiresRemoteConnection: boolean, + showAddButton: boolean = true, + showVoiceInput: boolean = true + ) { this.surface = surface; this.supportsAttachments = supportsAttachments; this.requiresRemoteConnection = requiresRemoteConnection; + this.showAddButton = showAddButton; + this.showVoiceInput = showVoiceInput; } } @@ -17,3 +27,6 @@ export const GENERAL_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities = export const REMOTE_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities = new ChatComposerCapabilities(ChatSurface.Remote, true, true); + +export const REMOTE_CREATE_COMPOSER_CAPABILITIES: ChatComposerCapabilities = + new ChatComposerCapabilities(ChatSurface.Remote, false, true, false, true); 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 6e42f3b6e0..b2b3e8531c 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,18 +1,16 @@ import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, LINE, MUTED, RED, SOFT } from './Theme'; +import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; import { FileReferenceCard } from './FileReferenceCard'; import { MarkdownContent } from './MarkdownContent'; -import { MarkdownParser, ParsedMarkdownBlock, ParsedMarkdownInline, ParsedMarkdownListItem } from '../../services/MarkdownParser'; import { StreamingMarkdownContent } from './StreamingMarkdownContent'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolStatusList } from './ToolStatusList'; - -interface FileReference { - id: string; - path: string; - label: string; -} +import { FileTargetResolver } from '../../services/FileTargetResolver'; +import { + MessageFileReference, + MessageFileReferenceProjectionCache +} from '../../services/MessageFileReferenceProjector'; interface SubagentTaskInput { description?: string; @@ -48,10 +46,13 @@ export struct ChatMessageBubble { }; @Param isStreaming: boolean = false; @Param isFinalizing: boolean = false; + @Param showRetryAction: boolean = false; @Param isBusy: boolean = false; @Param downloadingFilePath: string = ''; @Param downloadedFilePath: string = ''; @Param fileDownloadStatus: string = ''; + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; @@ -60,10 +61,13 @@ export struct ChatMessageBubble { (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @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)) { @@ -93,27 +97,32 @@ export struct ChatMessageBubble { UserBubble() { Row() { Blank() - Column({ space: 8 }) { - if (this.visibleMessageText(this.item).length > 0) { - Text(this.visibleMessageText(this.item)) - .fontSize(14) - .lineHeight(20) - .fontColor(INK) - .padding({ left: 16, right: 16, top: 10, bottom: 10 }) - .backgroundColor(SOFT) - .borderRadius(21) - } - if (this.item.images && this.item.images.length > 0) { - this.MessageImages(this.item.images) + 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') { + 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(CARD) + .fontColor(PRIMARY_ACTION_TEXT) .height(28) .padding({ left: 10, right: 10 }) .backgroundColor(ACCENT) @@ -124,7 +133,7 @@ export struct ChatMessageBubble { } } } - .width('70%') + .constraintSize({ maxWidth: '70%' }) .alignItems(HorizontalAlign.End) } .width('100%') @@ -143,14 +152,15 @@ export struct ChatMessageBubble { if (this.item.images && this.item.images.length > 0) { this.MessageImages(this.item.images) } - if (this.item.status === 'failed' && (this.item.detail || '').trim().length > 0) { + 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(CARD) + .fontColor(PRIMARY_ACTION_TEXT) .height(28) .padding({ left: 10, right: 10 }) .backgroundColor(ACCENT) @@ -215,12 +225,12 @@ export struct ChatMessageBubble { Text('') .width(6) .height(6) - .backgroundColor(CARD) + .backgroundColor(PRIMARY_ACTION_TEXT) .borderRadius(3) Text('') .width(6) .height(6) - .backgroundColor(CARD) + .backgroundColor(PRIMARY_ACTION_TEXT) .borderRadius(3) } .width(32) @@ -261,13 +271,15 @@ export struct ChatMessageBubble { }) } } else if (group.items.length > 0) { - this.StructuredItem( - group.items[0], - group.path, - group.itemStatuses[0] || '', - group.itemStreaming[0] || false, - group.itemChildActiveScopes[0] || false - ) + if (!omitActiveThinking || !this.isThinkingEntry(group.items[0])) { + this.StructuredItem( + group.items[0], + group.path, + group.itemStatuses[0] || '', + group.itemStreaming[0] || false, + group.itemChildActiveScopes[0] || false + ) + } } } @@ -356,7 +368,7 @@ export struct ChatMessageBubble { } .width('100%') .padding({ left: 10, right: 10, top: 8, bottom: 8 }) - .backgroundColor('#F7F7F4') + .backgroundColor(SOFT) .borderRadius(12) .border({ width: 1, color: LINE }) } @@ -403,6 +415,9 @@ export struct ChatMessageBubble { }, onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => { this.onAnswerQuestion(toolId, answers); + }, + onOpenFilePreview: (path: string, label: string) => { + this.onOpenFilePreview(path, label); } }) } @@ -456,6 +471,22 @@ export struct ChatMessageBubble { .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) + } + @Builder MessageText(text: string, active: boolean = false, streamKey: string = '') { if (active) { @@ -465,6 +496,9 @@ export struct ChatMessageBubble { streamKey, onCopyText: (body: string) => { this.onCopyMessage(body); + }, + onOpenLink: (reference: string, label: string) => { + this.onOpenFilePreview(reference, label); } }) } else { @@ -472,6 +506,9 @@ export struct ChatMessageBubble { text, onCopyText: (body: string) => { this.onCopyMessage(body); + }, + onOpenLink: (reference: string, label: string) => { + this.onOpenFilePreview(reference, label); } }) } @@ -480,18 +517,25 @@ export struct ChatMessageBubble { @Builder FileCards(text: string) { Column({ space: 8 }) { - ForEach(this.fileReferences(text), (file: FileReference) => { + 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: FileReference) => file.id) + }, (file: MessageFileReference) => file.id) } .width('100%') } @@ -532,7 +576,6 @@ export struct ChatMessageBubble { private shouldPinThinkingToBottom(item: ConversationUiMessage): boolean { return this.isStreamingAssistant() && - !this.hasVisibleAssistantOutput(item) && this.currentThinkingText(item).length > 0; } @@ -1174,165 +1217,8 @@ export struct ChatMessageBubble { normalized === 'ask_user_question'; } - private fileReferences(text: string): FileReference[] { - const matches: FileReference[] = []; - const seen = new Set(); - MarkdownParser.parse(text).forEach((block: ParsedMarkdownBlock) => { - this.collectInlineFileReferences(block.inlines, matches, seen); - block.items.forEach((item: ParsedMarkdownListItem) => { - this.collectInlineFileReferences(item.inlines, matches, seen); - }); - }); - return matches.slice(0, 4); - } - - private collectInlineFileReferences( - inlines: ParsedMarkdownInline[], - matches: FileReference[], - seen: Set - ): void { - inlines.forEach((inline: ParsedMarkdownInline) => { - if (inline.type === 'link') { - const linkTarget = this.downloadableLinkTarget(inline.url); - if (linkTarget.length > 0) { - this.addFileReference(linkTarget, matches, seen); - } - return; - } - if (inline.type !== 'code') { - this.collectRawComputerLinks(inline.text, matches, seen); - } - }); - } - - private collectRawComputerLinks(text: string, matches: FileReference[], seen: Set): void { - const found = text.match(/computer:\/\/[^\s)\]}>"']+/g) || []; - found.forEach((raw: string) => { - this.addFileReference(this.cleanFileLink(raw), matches, seen); - }); - } - - private addFileReference(path: string, matches: FileReference[], seen: Set): void { - const clean = this.cleanFileLink(path); - if (clean.length === 0 || seen.has(clean)) { - return; - } - seen.add(clean); - matches.push({ - id: `file-${matches.length}-${clean}`, - path: clean, - label: this.fileLabel(clean) - }); - } - - private downloadableLinkTarget(href: string): string { - const value = this.cleanFileLink(href); - if (value.indexOf('computer://') === 0) { - return value; - } - return this.localDownloadablePath(value); - } - - private localDownloadablePath(href: string): string { - if (href.length === 0 || href === '/') { - return ''; - } - if (href.indexOf('://') >= 0 && href.indexOf('file://') !== 0) { - return ''; - } - if (href.indexOf('#') === 0 || href.indexOf('//') === 0) { - return ''; - } - - const filePath = this.normalizeFileLikeHref(href); - if (filePath.length === 0) { - return ''; - } - - if (filePath.indexOf('/') === 0) { - const segments = filePath.split('/').filter((segment: string) => segment.length > 0); - if (segments.length < 2) { - return ''; - } - } - - const fileName = this.fileLabel(filePath); - const dotIndex = fileName.lastIndexOf('.'); - if (dotIndex <= 0) { - return ''; - } - - const extension = fileName.slice(dotIndex + 1).toLowerCase(); - if (extension.length === 0) { - return ''; - } - - if (filePath.indexOf('/') === 0 || this.isWindowsAbsolutePath(filePath)) { - return this.isCodeFileExtension(extension) ? '' : filePath; - } - return this.isDownloadableFileExtension(extension) ? filePath : ''; - } - - private normalizeFileLikeHref(rawHref: string): string { - let filePath = rawHref.trim(); - if (filePath.indexOf('file://') === 0) { - filePath = filePath.slice('file://'.length); - } else if (filePath.indexOf('file:') === 0) { - filePath = filePath.slice('file:'.length); - } - - const workspacePlaceholder = '{{workspaceFolder}}'; - if (filePath.indexOf(workspacePlaceholder) === 0) { - filePath = filePath.slice(workspacePlaceholder.length); - if (filePath.indexOf('/') === 0) { - filePath = filePath.slice(1); - } - } - - if (filePath.length >= 4 && filePath.charAt(0) === '/' && filePath.charAt(2) === ':' && - (filePath.charAt(3) === '/' || filePath.charAt(3) === '\\')) { - filePath = filePath.slice(1); - } - - try { - return decodeURIComponent(filePath); - } catch (_err) { - return filePath; - } - } - - private cleanFileLink(value: string): string { - let clean = value.trim(); - while (clean.length > 0) { - const last = clean.charAt(clean.length - 1); - if (last === ',' || last === '.' || last === ';' || last === ':' || last === ')' || - last === ']' || last === '}' || last === '>' || last === ',' || last === '。' || - last === ';' || last === ':') { - clean = clean.slice(0, clean.length - 1); - } else { - break; - } - } - return clean; - } - - private isWindowsAbsolutePath(path: string): boolean { - return path.length >= 3 && path.charAt(1) === ':' && - (path.charAt(2) === '/' || path.charAt(2) === '\\'); - } - - private isCodeFileExtension(extension: string): boolean { - return '|js|jsx|ts|tsx|mjs|cjs|mts|cts|py|pyw|pyi|rs|go|java|kt|kts|scala|groovy|c|cpp|cc|cxx|h|hpp|hxx|hh|cs|rb|php|swift|vue|svelte|css|scss|less|sass|json|jsonc|yaml|yml|toml|xml|md|mdx|rst|txt|sh|bash|zsh|fish|ps1|bat|cmd|sql|graphql|gql|proto|lock|env|ini|cfg|conf|cj|ets|editorconfig|gitignore|log|'.indexOf(`|${extension}|`) >= 0; - } - - private isDownloadableFileExtension(extension: string): boolean { - return '|pdf|doc|docx|xls|xlsx|ppt|pptx|odt|ods|odp|rtf|pages|numbers|key|png|jpg|jpeg|gif|bmp|svg|webp|ico|tiff|tif|zip|tar|gz|bz2|7z|rar|dmg|iso|xz|mp3|wav|ogg|flac|aac|m4a|wma|mp4|avi|mkv|mov|webm|wmv|flv|csv|tsv|sqlite|db|parquet|epub|mobi|apk|ipa|exe|msi|deb|rpm|ttf|otf|woff|woff2|'.indexOf(`|${extension}|`) >= 0; - } - - private fileLabel(path: string): string { - const normalized = path.replace(/^computer:\/\//, '').replace(/^file:\/\//, '').replace(/\\/g, '/'); - const parts = normalized.split('/'); - return parts[parts.length - 1] || normalized || 'file'; + private fileReferences(text: string): MessageFileReference[] { + return this.fileReferenceCache.referencesFor(text); } private fileStatus(path: string): string { 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 6edbb59239..e61d366e2e 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,11 +1,11 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { INK, LINE, MUTED, PAGE_BG } from './Theme'; +import { INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; @Component export struct ChatStatusBar { @Prop title: string = ''; @Prop detail: string = ''; - @Prop color: string = MUTED; + @Prop color: ResourceColor = MUTED; @Prop canStop: boolean = false; onStop: () => void = () => {}; @@ -37,7 +37,7 @@ export struct ChatStatusBar { .fontWeight(FontWeight.Medium) .fontColor(INK) .textAlign(TextAlign.Center) - .backgroundColor('#F0EFEC') + .backgroundColor(SOFT) .borderRadius(17) .onClick(() => { this.onStop(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index 9f379a06a0..79ad92a1cc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -19,6 +19,9 @@ export struct ChatTimeline { @Param downloadingFilePath: string = ''; @Param downloadedFilePath: string = ''; @Param fileDownloadStatus: string = ''; + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; + @Param maxContentWidth: number = 0; @Event onLoadOlder: () => void = () => {}; @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; @@ -28,6 +31,7 @@ export struct ChatTimeline { (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @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) => {}; build() { @@ -53,15 +57,14 @@ export struct ChatTimeline { .width('100%') .height('100%') .padding({ left: 20, right: 20, top: 0, bottom: 12 }) - .stackFromEnd(true) + .stackFromEnd(false) .scrollBar(BarState.Off) if (this.surface === ChatSurface.Remote && this.timelineItems.length > 2) { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_down')) - .width(18) - .height(18) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_down')) + .fontSize(18) + .fontColor([INK]) } .width(42) .height(42) @@ -76,6 +79,8 @@ export struct ChatTimeline { } .layoutWeight(1) .width('100%') + .constraintSize({ maxWidth: this.maxContentWidth > 0 ? this.maxContentWidth : 10000 }) + .alignSelf(ItemAlign.Center) } @Builder @@ -140,10 +145,13 @@ export struct ChatTimeline { item: toConversationUiMessage(item.message!), isStreaming: item.isStreaming, isFinalizing: item.isFinalizing, + showRetryAction: item.showRetryAction === true, isBusy: this.isBusy, downloadingFilePath: this.downloadingFilePath, downloadedFilePath: this.downloadedFilePath, fileDownloadStatus: this.fileDownloadStatus, + activeFilePreviewPath: this.activeFilePreviewPath, + activeFilePreviewLoading: this.activeFilePreviewLoading, onApproveTool: (toolId: string, updatedInput?: Object) => { this.onApproveTool(toolId, updatedInput); }, @@ -162,6 +170,9 @@ export struct ChatTimeline { onRetryMessage: (text: string) => { this.onRetryMessage(text); }, + onOpenFilePreview: (path: string, label: string) => { + this.onOpenFilePreview(path, label); + }, onDownloadFile: (path: string) => { this.onDownloadFile(path); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CompactMenuButton.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CompactMenuButton.ets new file mode 100644 index 0000000000..ab6f189da4 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CompactMenuButton.ets @@ -0,0 +1,28 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK } from './Theme'; + +@ComponentV2 +export struct CompactMenuButton { + @Param controlSize: number = 48; + @Event onOpen: () => void = () => {}; + + build() { + Stack({ alignContent: Alignment.Center }) { + Image($r('app.media.gpt_home_menu_glyph')) + .width(22) + .height(14) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) + } + .width(this.controlSize) + .height(this.controlSize) + .backgroundColor(CARD) + .borderRadius(this.controlSize / 2) + .shadow({ radius: 16, color: '#12000000', offsetY: 6 }) + .accessibilityText(RemoteI18n.t('sidebar.more')) + .onClick(() => { + this.onOpen(); + }) + } +} 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 284322c714..8f2d8f4ba5 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 @@ -2,24 +2,53 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatComposerPolicy } from '../../services/ChatComposerPolicy'; import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; import { ChatSurface } from './ChatSurface'; -import { ConversationUiSelectedImage } from './ConversationUiModels'; -import { CARD, GREEN, INK, MUTED } from './Theme'; +import { + ConversationUiModel, + ConversationUiModelCatalog, + ConversationUiSelectedImage +} from './ConversationUiModels'; +import { ConversationModelPresentationPolicy } from '../state/ConversationModelPresentationPolicy'; +import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; + +export enum ComposerPresentation { + Compact = 'compact', + Floating = 'floating', + Create = 'create' +} + +const COMPOSER_ACTION_SIZE: number = 40; +const COMPOSER_INPUT_HEIGHT: number = 42; +const COMPOSER_EXPANDED_INPUT_HEIGHT: number = 74; @ComponentV2 export struct ComposerBar { + @Param presentation: ComposerPresentation = ComposerPresentation.Compact; @Param capabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; + @Param inputId: string = 'conversation-composer-input'; @Param chatInput: string = ''; @Local inputText: string = ''; + @Local inputFocused: boolean = false; @Param showQuickActions: boolean = false; @Param selectedImages: ConversationUiSelectedImage[] = []; @Param isBusy: boolean = false; + @Param canStop: boolean = false; @Param connectionState: string = 'connected'; @Param isVoiceListening: boolean = false; + @Param modelCatalog: ConversationUiModelCatalog = { + version: 0, + models: [], + default_models: {} + }; + @Param selectedModelId: string = ''; + @Local showModelSelectorSheet: boolean = false; + @Local showModelSelectorPopover: boolean = false; @Event onToggleQuickActions: () => void = () => {}; @Event onPickImages: () => void = () => {}; @Event onRemoveImage: (imageId: string) => void = (_imageId: string) => {}; @Event onSend: () => void = () => {}; + @Event onStop: () => void = () => {}; @Event onVoiceInput: () => void = () => {}; + @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; @Event onChatInputChange: (value: string) => void = (_value: string) => {}; aboutToAppear(): void { @@ -33,42 +62,252 @@ export struct ComposerBar { } } + @Monitor('presentation') + onPresentationChanged(): void { + this.closeModelSelector(); + } + build() { Column({ space: 8 }) { if (this.selectedImages.length > 0) { this.SelectedImageStrip() } - Row({ space: 5 }) { + this.AdaptiveComposer() + } + .width('100%') + .constraintSize({ maxWidth: this.presentation === ComposerPresentation.Floating ? 760 : 10000 }) + .alignSelf(ItemAlign.Center) + .padding({ + left: this.presentation === ComposerPresentation.Floating ? 24 : 16, + right: this.presentation === ComposerPresentation.Floating ? 24 : 16, + top: 8, + bottom: this.presentation === ComposerPresentation.Floating ? 18 : 14 + }) + .backgroundColor('#00000000') + .bindSheet($$this.showModelSelectorSheet, this.ModelSelector(true), this.modelSelectorSheetOptions()) + } + + @Builder + AdaptiveComposer() { + Column({ space: 2 }) { + Row({ space: this.isComposerExpanded() ? 0 : 5 }) { if (this.shouldShowAddButton()) { - Stack({ alignContent: Alignment.Center }) { - this.PlusGlyph() + Row() { + this.AddButton() } - .width(36) - .height(44) - .onClick(() => { - if (this.isVoiceListening) { - return; - } - if (this.capabilities.supportsAttachments) { - this.onToggleQuickActions(); - return; - } - this.onPickImages(); - }) + .width(this.isComposerExpanded() ? 0 : COMPOSER_ACTION_SIZE) + .height(COMPOSER_ACTION_SIZE) + .opacity(this.isComposerExpanded() ? 0 : 1) + .visibility(this.isComposerExpanded() ? Visibility.None : Visibility.Visible) + .clip(true) } this.InputField() - this.PrimaryActionButton() + Row() { + this.PrimaryActionButton() + } + .width(this.isComposerExpanded() ? 0 : COMPOSER_ACTION_SIZE) + .height(COMPOSER_ACTION_SIZE) + .opacity(this.isComposerExpanded() ? 0 : 1) + .visibility(this.isComposerExpanded() ? Visibility.None : Visibility.Visible) + .clip(true) } .width('100%') - .height(52) - .padding({ left: 8, right: 3 }) - .backgroundColor(CARD) - .borderRadius(26) - .shadow({ radius: 24, color: '#18000000', offsetY: 7 }) + .height(this.isComposerExpanded() ? 76 : 52) + + if (this.isComposerExpanded()) { + Row({ space: 6 }) { + if (this.shouldShowAddButton()) { + this.AddButton() + } + if (this.shouldShowModelControl()) { + this.ModelControl() + } + Blank() + this.PrimaryActionButton() + } + .width('100%') + .height(44) + .padding({ left: 2, right: 0 }) + .alignItems(VerticalAlign.Center) + .transition(TransitionEffect.translate({ x: 0, y: 8 }) + .combine(TransitionEffect.opacity(0)) + .animation({ duration: 180, curve: Curve.EaseOut })) + } } .width('100%') - .padding({ left: 16, right: 16, top: 8, bottom: 14 }) + .height(this.isComposerExpanded() ? 126 : 52) + .padding({ + left: 8, + right: 8, + top: this.isComposerExpanded() ? 4 : 0, + bottom: this.isComposerExpanded() ? 2 : 0 + }) + .backgroundColor(CARD) + .borderRadius(this.isComposerExpanded() ? 18 : + (this.presentation === ComposerPresentation.Floating ? 18 : 26)) + .shadow({ + radius: this.presentation === ComposerPresentation.Floating ? 18 : 10, + color: this.presentation === ComposerPresentation.Floating ? '#18000000' : '#0D000000', + offsetY: this.presentation === ComposerPresentation.Floating ? 6 : 2 + }) + .animation({ duration: 220, curve: Curve.EaseOut }) + } + + @Builder + ModelControl() { + Row({ space: 3 }) { + Text(this.displaySelectedModel()) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: 192 }) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph(this.isModelSelectorExpanded() ? + $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + .fontSize(13) + .fontColor([INK]) + .opacity(0.68) + } + .width(16) + .height(34) + } + .height(34) + .constraintSize({ maxWidth: 220 }) + .padding({ left: 4, right: 4 }) .backgroundColor('#00000000') + .accessibilityText(`${RemoteI18n.t('chat.selectModel')} · ${this.displaySelectedModel()}`) + .bindPopup(this.showModelSelectorPopover, { + builder: () => { + this.ModelSelector(false) + }, + placement: Placement.Top, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 8, + onStateChange: (event) => { + if (!event.isVisible) { + this.showModelSelectorPopover = false; + } + } + }) + .onClick(() => { + if (this.presentation === ComposerPresentation.Floating) { + this.showModelSelectorPopover = !this.showModelSelectorPopover; + } else { + this.showModelSelectorSheet = true; + } + }) + } + + @Builder + ModelSelector(asSheet: boolean) { + Column({ space: 10 }) { + if (asSheet) { + Row() { + Text(RemoteI18n.t('chat.selectModel')) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Blank() + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(15) + .fontColor([MUTED]) + .width(18) + .height(18) + } + .width(32) + .height(32) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => { + this.closeModelSelector(); + }) + } + .width('100%') + } + + List({ space: 6 }) { + ForEach(this.selectorModels(), (model: ConversationUiModel) => { + ListItem() { + this.ModelRow(model) + } + }, (model: ConversationUiModel) => model.id) + } + .width('100%') + .height(this.modelListHeight()) + .scrollBar(BarState.Auto) + .edgeEffect(EdgeEffect.Spring) + .divider(null) + } + .width(asSheet ? '100%' : 330) + .padding({ left: 10, right: 10, top: 10, bottom: 10 }) + .backgroundColor(asSheet ? CARD : FLOATING_PANEL_BG) + .borderRadius(asSheet ? { topLeft: 20, topRight: 20 } : 14) + .border({ width: asSheet ? 0 : 1, color: asSheet ? '#00000000' : LINE }) + .shadow({ radius: asSheet ? 0 : 18, color: asSheet ? '#00000000' : '#18000000', offsetY: 7 }) + } + + @Builder + ModelRow(model: ConversationUiModel) { + Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + if (this.isSelectedModel(model)) { + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(16) + .fontColor([INK]) + } + } + .width(20) + .height(20) + + Column({ space: 2 }) { + Text(ConversationModelPresentationPolicy.primaryLabel(model, RemoteI18n.t('chat.model'))) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(ConversationModelPresentationPolicy.secondaryLabel(model, RemoteI18n.t('chat.model'))) + .fontSize(11) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height(48) + .padding({ left: 10, right: 10 }) + .backgroundColor(this.isSelectedModel(model) ? SOFT : '#00000000') + .borderRadius(9) + .onClick(() => { + this.closeModelSelector(); + this.onSelectModel(model.id); + }) + } + + @Builder + AddButton() { + Stack({ alignContent: Alignment.Center }) { + this.PlusGlyph() + } + .width(COMPOSER_ACTION_SIZE) + .height(COMPOSER_ACTION_SIZE) + .onClick(() => { + if (this.isVoiceListening) { + return; + } + if (this.capabilities.supportsAttachments) { + this.onToggleQuickActions(); + return; + } + this.onPickImages(); + }) } @Builder @@ -77,16 +316,29 @@ export struct ComposerBar { if (this.isVoiceListening) { this.ListeningWave() } - TextInput({ placeholder: this.inputPlaceholder(), text: this.inputText }) + TextArea({ placeholder: this.inputPlaceholder(), text: this.inputText }) + .id(this.inputId) .layoutWeight(1) - .height(42) + .height(this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_HEIGHT : COMPOSER_INPUT_HEIGHT) .fontSize(16) .fontColor(INK) .placeholderColor(this.isVoiceListening ? GREEN : MUTED) .backgroundColor('#00000000') .borderRadius(20) - .padding({ left: this.isVoiceListening ? 0 : 4, right: 4 }) + .padding({ + left: this.isVoiceListening ? 0 : 4, + right: 4, + top: this.isComposerExpanded() ? 10 : 9, + bottom: this.isComposerExpanded() ? 8 : 9 + }) + .maxLines(this.isComposerExpanded() ? 4 : 1) .defaultFocus(false) + .onFocus(() => { + this.inputFocused = true; + }) + .onBlur(() => { + this.inputFocused = false; + }) .onChange((value: string, previewText?: PreviewText) => { // The first callback value excludes IME pre-edit text. Keep that text // inside the native field until the IME commits it. @@ -98,31 +350,38 @@ export struct ComposerBar { }) } .layoutWeight(1) - .height(42) + .height(this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_HEIGHT : COMPOSER_INPUT_HEIGHT) + .alignItems(VerticalAlign.Center) .padding({ left: this.isVoiceListening ? 12 : 0, right: 0 }) - .backgroundColor(this.isVoiceListening ? '#F0FAF4' : '#00000000') + .backgroundColor(this.isVoiceListening ? SOFT : '#00000000') .borderRadius(20) - .border({ width: this.isVoiceListening ? 1 : 0, color: this.isVoiceListening ? '#BDE8CC' : '#00000000' }) + .border({ width: this.isVoiceListening ? 1 : 0, color: this.isVoiceListening ? GREEN : '#00000000' }) } @Builder PrimaryActionButton() { Button() { Stack({ alignContent: Alignment.Center }) { - if (this.isVoiceListening) { + if (this.isVoiceListening || this.canStop) { Text('') .width(13) .height(13) .backgroundColor(CARD) .borderRadius(3) } else if (this.hasComposedContent()) { - Image($r('app.media.gpt_composer_send')) - .width(39) - .height(39) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.arrow_up')) + .fontSize(23) + .fontWeight(FontWeight.Medium) + .fontColor([INK]) .opacity(this.canSend() ? 1 : 0.38) - } else { + } else if (this.capabilities.showVoiceInput) { this.MicrophoneGlyph() + } else { + SymbolGlyph($r('sys.symbol.arrow_up')) + .fontSize(23) + .fontWeight(FontWeight.Medium) + .fontColor([INK]) + .opacity(0.38) } } .width(40) @@ -139,6 +398,10 @@ export struct ComposerBar { this.onVoiceInput(); return; } + if (this.canStop) { + this.onStop(); + return; + } if (this.canSend()) { this.onSend(); return; @@ -151,19 +414,17 @@ export struct ComposerBar { @Builder PlusGlyph() { - Image($r('app.media.gpt_composer_plus')) - .width(23) - .height(23) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.plus')) + .fontSize(22) + .fontColor([INK]) .opacity(this.isVoiceListening ? 0.36 : 1) } @Builder MicrophoneGlyph() { - Image($r('app.media.gpt_composer_mic')) - .width(22) - .height(27) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.mic')) + .fontSize(22) + .fontColor([INK]) .opacity(this.canUseVoice() ? 1 : 0.4) } @@ -248,7 +509,8 @@ export struct ComposerBar { } private canUseVoice(): boolean { - return ChatComposerPolicy.canUseVoice(this.inputText, this.selectedImages.length, this.isBusy); + return this.capabilities.showVoiceInput && + ChatComposerPolicy.canUseVoice(this.inputText, this.selectedImages.length, this.isBusy); } private hasComposedContent(): boolean { @@ -256,7 +518,70 @@ export struct ComposerBar { } private shouldShowAddButton(): boolean { - return this.capabilities.supportsAttachments || this.capabilities.surface === ChatSurface.General; + return this.capabilities.showAddButton && + (this.capabilities.supportsAttachments || this.capabilities.surface === ChatSurface.General); + } + + private isComposerExpanded(): boolean { + return this.inputFocused || this.showQuickActions || this.showModelSelectorSheet || + this.showModelSelectorPopover || this.inputText.indexOf('\n') >= 0; + } + + private isModelSelectorExpanded(): boolean { + return this.showModelSelectorSheet || this.showModelSelectorPopover; + } + + private shouldShowModelControl(): boolean { + return (this.capabilities.surface === ChatSurface.Remote || this.capabilities.surface === ChatSurface.General) && + this.enabledModels().length > 0; + } + + private enabledModels(): ConversationUiModel[] { + return ConversationModelPresentationPolicy.enabledModels(this.modelCatalog); + } + + private selectedModel(): ConversationUiModel | undefined { + return ConversationModelPresentationPolicy.selectedModel(this.modelCatalog, this.selectedModelId); + } + + private isSelectedModel(model: ConversationUiModel): boolean { + const selected = this.selectedModel(); + return !!selected && selected.id === model.id; + } + + private displaySelectedModel(): string { + const selected = this.selectedModel(); + return selected ? ConversationModelPresentationPolicy.primaryLabel(selected, RemoteI18n.t('chat.model')) : + RemoteI18n.t('chat.model'); + } + + private modelListHeight(): number { + const visibleRows = Math.min(this.enabledModels().length, 7); + return visibleRows * 48 + Math.max(0, visibleRows - 1) * 6; + } + + private selectorModels(): ConversationUiModel[] { + const models = this.enabledModels(); + const selected = this.selectedModel(); + if (!selected) { + return models; + } + return [selected, ...models.filter((model: ConversationUiModel) => model.id !== selected.id)]; + } + + private closeModelSelector(): void { + this.showModelSelectorSheet = false; + this.showModelSelectorPopover = false; + } + + private modelSelectorSheetOptions(): SheetOptions { + return { + height: Math.min(480, 86 + this.modelListHeight()), + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: true + }; } private inputPlaceholder(): string { @@ -265,7 +590,10 @@ export struct ComposerBar { RemoteI18n.t('chat.inputPlaceholder'); } - private actionBackgroundColor(): string { + private actionBackgroundColor(): ResourceColor { + if (this.canStop) { + return RED; + } if (this.isVoiceListening) { return GREEN; } 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 4af02a9561..8b917cecdd 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,15 +2,11 @@ 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 { ACCENT, CARD, GREEN, INK, LINE, MUTED, RED, SOFT, SUBTLE } from './Theme'; - -const CONNECT_HERO: string = '#E6EDFF'; -const CONNECT_HERO_BLUE: string = '#9DB4FF'; -const CONNECT_HERO_LILAC: string = '#C9C5FF'; -const CONNECT_HERO_MIST: string = '#F8FAFF'; +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, + SUBTLE } from './Theme'; const CONNECT_SCAN_YELLOW: string = '#FFD021'; const CONNECT_OVERLAY: string = '#99000000'; -const CONNECT_DISABLED: string = '#D8D6D1'; const CAMERA_PERMISSION: Permissions = 'ohos.permission.CAMERA'; @Component @@ -146,7 +142,7 @@ export struct ConnectView { } .width('100%') .height('100%') - .backgroundColor('#FAFAF9') + .backgroundColor(PAGE_BG) } @Builder @@ -241,20 +237,22 @@ export struct ConnectView { .margin({ left: 16, right: 16 }) Row({ space: 12 }) { - Image($r('app.media.remote_actions_link')) + SymbolGlyph($r('sys.symbol.link')) + .fontSize(20) + .fontColor([MUTED]) .width(22) .height(22) - .objectFit(ImageFit.Contain) .opacity(0.66) Text(RemoteI18n.t('connect.scanPairCodeAction')) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(INK) .layoutWeight(1) - Image($r('app.media.settings_chevron_right')) - .width(8) - .height(12) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13) + .fontColor([MUTED]) + .width(16) + .height(16) .opacity(0.44) } .width('100%') @@ -277,18 +275,18 @@ export struct ConnectView { Text('') .width(26) .height(22) - .backgroundColor('#ECECE9') + .backgroundColor(SOFT) .borderRadius(5) Column({ space: 7 }) { Text('') .width('58%') .height(12) - .backgroundColor('#ECECE9') + .backgroundColor(SOFT) .borderRadius(4) Text('') .width(52) .height(9) - .backgroundColor('#F0F0ED') + .backgroundColor(SOFT) .borderRadius(4) } .layoutWeight(1) @@ -303,8 +301,8 @@ export struct ConnectView { @Builder AccountConnectDeviceRow(device: CloudAccountDevice) { Row({ space: 12 }) { - Image($r('app.media.remote_ref_device')) - .width(26).height(24).objectFit(ImageFit.Contain).opacity(device.online ? 0.68 : 0.38) + 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) @@ -315,8 +313,8 @@ export struct ConnectView { .layoutWeight(1) .alignItems(HorizontalAlign.Start) if (device.online) { - Image($r('app.media.settings_chevron_right')) - .width(8).height(12).objectFit(ImageFit.Contain).opacity(0.44) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) } } .width('100%') @@ -391,7 +389,7 @@ export struct ConnectView { .fontColor(INK) .backgroundColor(CARD) .borderRadius(28) - .border({ width: 1.5, color: CONNECT_DISABLED }) + .border({ width: 1.5, color: LINE }) .margin({ bottom: 10 }) .onClick(() => { this.stopInlineScan(); @@ -451,7 +449,7 @@ export struct ConnectView { .fontColor(INK) .backgroundColor(CARD) .borderRadius(35) - .border({ width: 1.5, color: CONNECT_DISABLED }) + .border({ width: 1.5, color: LINE }) .onClick(() => { this.stopInlineScan(); this.showManualPairing = true; @@ -470,26 +468,26 @@ export struct ConnectView { Text('') .width('100%') .height(282) - .backgroundColor(CONNECT_HERO) + .backgroundColor(CONNECT_HERO_BG) .borderRadius(36) Text('') .width(260) .height(142) - .backgroundColor(CONNECT_HERO_MIST) + .backgroundColor(CONNECT_HERO_SURFACE) .opacity(0.7) .borderRadius(72) .position({ x: 112, y: 26 }) Text('') .width(188) .height(134) - .backgroundColor(CONNECT_HERO_BLUE) + .backgroundColor(CONNECT_HERO_ACCENT) .opacity(0.42) .borderRadius(68) .position({ x: -42, y: 198 }) Text('') .width(188) .height(126) - .backgroundColor(CONNECT_HERO_LILAC) + .backgroundColor(CONNECT_HERO_SECONDARY) .opacity(0.54) .borderRadius(64) .position({ x: 258, y: 0 }) @@ -521,10 +519,9 @@ export struct ConnectView { @Builder BackGlyph() { - Image($r('app.media.remote_ref_back')) - .width(12) - .height(20) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(21) + .fontColor([INK]) } @Builder @@ -581,8 +578,8 @@ export struct ConnectView { .height(58) .fontSize(21) .fontWeight(FontWeight.Bold) - .fontColor(CARD) - .backgroundColor(ACCENT) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) .borderRadius(29) .margin({ bottom: 14 }) .onClick(() => { @@ -605,7 +602,7 @@ export struct ConnectView { } .height(30) .padding({ left: 12, right: 12 }) - .backgroundColor(this.isConnectError() ? '#FFF1F1' : SOFT) + .backgroundColor(SOFT) .borderRadius(15) .onClick(() => { this.handleStatusClick(); @@ -651,7 +648,7 @@ export struct ConnectView { .height(62) .fontSize(20) .fontColor(INK) - .backgroundColor('#ECEBE8') + .backgroundColor(SOFT) .borderRadius(31) .padding({ left: 20, right: 20 }) .defaultFocus(true) @@ -663,7 +660,7 @@ export struct ConnectView { .height(56) .fontSize(18) .fontColor(INK) - .backgroundColor('#ECEBE8') + .backgroundColor(SOFT) .borderRadius(28) .padding({ left: 20, right: 20 }) .onChange((value: string) => { @@ -673,7 +670,7 @@ export struct ConnectView { .height(56) .fontSize(18) .fontColor(INK) - .backgroundColor('#ECEBE8') + .backgroundColor(SOFT) .borderRadius(28) .padding({ left: 20, right: 20 }) .type(InputType.Password) @@ -693,7 +690,7 @@ export struct ConnectView { .fontSize(19) .fontWeight(FontWeight.Bold) .fontColor(INK) - .backgroundColor('#ECEBE8') + .backgroundColor(SOFT) .borderRadius(29) .onClick(() => { this.stopInlineScan(); @@ -705,8 +702,8 @@ export struct ConnectView { .height(58) .fontSize(19) .fontWeight(FontWeight.Bold) - .fontColor(this.canConnect() ? CARD : SUBTLE) - .backgroundColor(this.canConnect() ? ACCENT : '#ECEBE8') + .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) + .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) .borderRadius(29) .enabled(this.canConnect()) .onClick(() => { @@ -720,9 +717,9 @@ export struct ConnectView { } .width('82%') .padding({ left: 28, right: 28, top: 30, bottom: 28 }) - .backgroundColor('#F6F6F3') + .backgroundColor(CARD) .borderRadius(34) - .border({ width: 1, color: '#FFFFFF' }) + .border({ width: 1, color: LINE }) } .width('100%') .height('100%') @@ -870,8 +867,8 @@ export struct ConnectView { .height(50) .fontSize(16) .fontWeight(FontWeight.Medium) - .fontColor(this.canConnect() ? CARD : SUBTLE) - .backgroundColor(this.canConnect() ? ACCENT : '#EDEBE6') + .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) + .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) .borderRadius(14) .enabled(this.canConnect()) .onClick(() => { @@ -992,7 +989,7 @@ export struct ConnectView { }) } - private statusDotColor(): string { + private statusDotColor(): ResourceColor { if (this.isConnected) { return GREEN; } 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/components/ConversationIntent.ets index 71d1efcdd0..6531352eeb 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets @@ -1,4 +1,5 @@ import { ConversationUiQuestionAnswer } from './ConversationUiModels'; +import { FilePreviewRequest } from '../state/FilePreviewTarget'; export enum ConversationIntentType { OpenSidebar = 'open_sidebar', @@ -20,6 +21,7 @@ export enum ConversationIntentType { SelectModel = 'select_model', PickImages = 'pick_images', RemoveImage = 'remove_image', + OpenFilePreview = 'open_file_preview', DownloadFile = 'download_file', Send = 'send', VoiceInput = 'voice_input', @@ -32,19 +34,22 @@ export class ConversationIntent { readonly toolId: string; readonly updatedInput?: Object; readonly answers?: ConversationUiQuestionAnswer; + readonly filePreviewRequest?: FilePreviewRequest; constructor( type: ConversationIntentType, value: string = '', toolId: string = '', updatedInput?: Object, - answers?: ConversationUiQuestionAnswer + answers?: ConversationUiQuestionAnswer, + filePreviewRequest?: FilePreviewRequest ) { this.type = type; this.value = value; this.toolId = toolId; this.updatedInput = updatedInput; this.answers = answers; + this.filePreviewRequest = filePreviewRequest; } } @@ -68,4 +73,15 @@ export class ConversationIntents { static answerQuestion(toolId: string, answers: ConversationUiQuestionAnswer): ConversationIntent { return new ConversationIntent(ConversationIntentType.AnswerQuestion, '', toolId, undefined, answers); } + + static openFilePreview(reference: string, label: string): ConversationIntent { + return new ConversationIntent( + ConversationIntentType.OpenFilePreview, + '', + '', + undefined, + undefined, + new FilePreviewRequest(reference, label) + ); + } } 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 c030e3ec3e..f940c851e5 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 @@ -13,7 +13,7 @@ export struct ConversationSourceSwitcher { this.SourceOption(ConversationSource.Remote, RemoteI18n.t('sidebar.code')) } .width('100%') - .height(42) + .height(40) .padding(3) .backgroundColor(SOFT) .borderRadius(8) @@ -24,9 +24,9 @@ export struct ConversationSourceSwitcher { private SourceOption(source: ConversationSource, label: string) { Text(label) .layoutWeight(1) - .height(34) - .fontSize(14) - .fontWeight(this.activeSource === source ? FontWeight.Bold : FontWeight.Medium) + .height(32) + .fontSize(13) + .fontWeight(FontWeight.Medium) .fontColor(this.activeSource === source ? INK : MUTED) .textAlign(TextAlign.Center) .backgroundColor(this.activeSource === source ? CARD : '#00000000') 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 40a0ff0dac..6dbb9e2694 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 @@ -1,3 +1,4 @@ +import { KeyboardAvoidMode } from '@kit.ArkUI'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; @@ -12,10 +13,10 @@ import { ConversationUiSelectedImage, ConversationUiSession } from './ConversationUiModels'; -import { ComposerBar } from './ComposerBar'; +import { ComposerBar, ComposerPresentation } from './ComposerBar'; import { GeneralChatHeader } from './GeneralChatHeader'; import { RemoteChatHeader } from './RemoteChatHeader'; -import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED } from './Theme'; +import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; @ComponentV2 export struct ConversationView { @@ -50,14 +51,19 @@ export struct ConversationView { @Param downloadingFilePath: string = ''; @Param downloadedFilePath: string = ''; @Param fileDownloadStatus: string = ''; + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; @Param selectedImages: ConversationUiSelectedImage[] = []; @Param isVoiceListening: boolean = false; @Param chatInput: string = ''; @Param showSidebarButton: boolean = true; @Param showBackButton: boolean = true; + @Param showSidebarRestoreButton: boolean = false; + @Param composerPresentation: ComposerPresentation = ComposerPresentation.Compact; + @Param contentHorizontalOffset: number = 0; @Event onOpenSidebar: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; @Event onBack: () => void = () => {}; - @Event onNewSession: () => void = () => {}; @Param isSessionPinned: boolean = false; @Event onTogglePinSession: () => void = () => {}; @Event onArchiveSession: () => void = () => {}; @@ -74,6 +80,7 @@ export struct ConversationView { @Event onRenameSession: (title: string) => void = (_title: string) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; + @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; @Event onPickImages: () => void = () => {}; @Event onRemoveImage: (imageId: string) => void = (_imageId: string) => {}; @@ -84,38 +91,52 @@ export struct ConversationView { @Event onToggleQuickActions: () => void = () => {}; @Local showQuickActions: boolean = false; @Local showHeaderActions: boolean = false; + private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; + + aboutToAppear(): void { + this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); + this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); + } + + aboutToDisappear(): void { + this.getUIContext().setKeyboardAvoidMode(this.previousKeyboardAvoidMode); + } build() { Stack() { Column() { this.Header() - if (this.shouldShowStatusBar()) { - this.ExecutionStatusBar() - } - if (this.shouldShowSuggestions()) { - Blank().layoutWeight(1) - if (!this.isVoiceListening) { - this.PromptArea() + Column() { + if (this.shouldShowStatusBar()) { + this.ExecutionStatusBar() } - } else { - this.MessageList() - } - if (this.inlineStatusText.length > 0) { - Text(this.inlineStatusText) - .fontSize(12).lineHeight(18).fontColor(MUTED).width('100%') - .padding({ left: 20, right: 20, bottom: 8 }) + if (this.shouldShowSuggestions()) { + Blank().layoutWeight(1) + if (!this.isVoiceListening) { + this.PromptArea() + } + } else if (this.shouldCenterInlineStatus()) { + this.CenteredInlineStatus() + } else { + this.MessageList() + } + if (this.inlineStatusText.length > 0 && !this.shouldCenterInlineStatus()) { + Text(this.inlineStatusText) + .fontSize(12).lineHeight(18).fontColor(MUTED).width('100%') + .padding({ left: 20, right: 20, bottom: 8 }) + } + this.Composer() } - this.Composer() + .layoutWeight(1) + .width('100%') + .translate({ x: this.contentHorizontalOffset, y: 0 }) + .animation({ duration: 220, curve: Curve.EaseInOut }) } .width('100%').height('100%').backgroundColor(PAGE_BG) if (this.showQuickActions && this.composerCapabilities.supportsAttachments) { this.MenuBackdrop(() => { this.showQuickActions = false; }) this.QuickActionsMenu() } - if (this.showHeaderActions) { - this.MenuBackdrop(() => { this.showHeaderActions = false; }) - this.HeaderActionsMenu() - } } .width('100%') .height('100%') @@ -126,19 +147,30 @@ export struct ConversationView { Header() { if (this.surface === ChatSurface.General) { GeneralChatHeader({ + title: this.activeSession.title, showActions: ConversationViewContract.hasRealTimelineItem(this.timelineItems), showSidebarButton: this.showSidebarButton, + showBackButton: this.showBackButton, + showSidebarRestoreButton: this.showSidebarRestoreButton, + showActionsMenu: this.showHeaderActions, + actionsMenu: () => { + this.HeaderActionsPopover(); + }, onOpenSidebar: () => { this.onOpenSidebar(); }, - onNewSession: () => { - this.showQuickActions = false; - this.showHeaderActions = false; - this.onNewSession(); + onRestoreSidebar: () => { + this.onRestoreSidebar(); + }, + onBack: () => { + this.onBack(); }, onOpenActions: () => { this.showQuickActions = false; this.showHeaderActions = !this.showHeaderActions; + }, + onActionsMenuStateChange: (visible: boolean) => { + this.showHeaderActions = visible; } }) } else { @@ -146,24 +178,27 @@ export struct ConversationView { activeSession: this.activeSession, workspaceBranch: this.workspaceBranch, desktopName: this.desktopName, - canStop: this.canStop, - modelCatalog: this.modelCatalog, - selectedModelId: this.selectedModelId, showBackButton: this.showBackButton, + showSidebarRestoreButton: this.showSidebarRestoreButton, + showActionsMenu: this.showHeaderActions, + actionsMenu: () => { + this.HeaderActionsPopover(); + }, onBack: () => { this.onBack(); }, - onNewSession: () => { - this.onNewSession(); + onRestoreSidebar: () => { + this.onRestoreSidebar(); }, - onStop: () => { - this.onStop(); + onOpenActions: () => { + this.showQuickActions = false; + this.showHeaderActions = !this.showHeaderActions; + }, + onActionsMenuStateChange: (visible: boolean) => { + this.showHeaderActions = visible; }, onRenameSession: (title: string) => { this.onRenameSession(title); - }, - onSelectModel: (modelId: string) => { - this.onSelectModel(modelId); } }) } @@ -195,6 +230,9 @@ export struct ConversationView { downloadingFilePath: this.downloadingFilePath, downloadedFilePath: this.downloadedFilePath, fileDownloadStatus: this.fileDownloadStatus, + activeFilePreviewPath: this.activeFilePreviewPath, + activeFilePreviewLoading: this.activeFilePreviewLoading, + maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 800 : 0, onLoadOlder: () => { this.onLoadOlder(); }, @@ -216,12 +254,31 @@ export struct ConversationView { onRetryMessage: (text: string) => { this.onRetryMessage(text); }, + onOpenFilePreview: (path: string, label: string) => { + this.onOpenFilePreview(path, label); + }, onDownloadFile: (path: string) => { this.onDownloadFile(path); } }) } + @Builder + CenteredInlineStatus() { + Stack({ alignContent: Alignment.Center }) { + Text(this.inlineStatusText) + .fontSize(14) + .lineHeight(20) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .maxLines(2) + } + .layoutWeight(1) + .width('100%') + .height('100%') + .padding({ left: 32, right: 32, bottom: 48 }) + } + @Builder PromptArea() { Column({ space: 15 }) { @@ -259,6 +316,7 @@ export struct ConversationView { @Builder Composer() { ComposerBar({ + presentation: this.composerPresentation, capabilities: this.composerCapabilities, chatInput: this.chatInput, showQuickActions: this.showQuickActions, @@ -267,8 +325,11 @@ export struct ConversationView { }, selectedImages: this.selectedImages, isBusy: this.isBusy, + canStop: this.canStop, connectionState: this.connectionState, isVoiceListening: this.isVoiceListening, + modelCatalog: this.modelCatalog, + selectedModelId: this.selectedModelId, onPickImages: () => { this.onPickImages(); }, @@ -278,9 +339,15 @@ export struct ConversationView { onSend: () => { this.onSend(); }, + onStop: () => { + this.onStop(); + }, onVoiceInput: () => { this.onVoiceInput(); }, + onSelectModel: (modelId: string) => { + this.onSelectModel(modelId); + }, onChatInputChange: (value: string) => { this.onChatInputChange(value); } @@ -318,81 +385,100 @@ export struct ConversationView { } .width('100%') .padding({ left: 18, right: 18, top: 12, bottom: 10 }) - .backgroundColor(CARD) - .border({ width: { top: 1 }, color: LINE }) } @Builder MenuBackdrop(onClose: () => void) { Text('') .width('100%').height('100%') - .backgroundColor('#18000000') + .backgroundColor(this.composerPresentation === ComposerPresentation.Floating ? '#00000000' : '#18000000') .zIndex(5) .onClick(onClose) } @Builder QuickActionsMenu() { - Column({ space: 4 }) { - this.MenuItem('gpt_home_image_glyph', '相机', () => this.onPickImages()) - this.MenuItem('gpt_home_image_glyph', '照片', () => this.onPickImages()) - this.MenuItem('remote_actions_link', '文件', () => {}) - this.MenuItem('remote_actions_chat', '插件', () => {}) - this.MenuItem('remote_actions_settings', '智能', () => {}) + if (this.composerPresentation === ComposerPresentation.Floating) { + this.QuickActionsPopover() + } else { + this.QuickActionsBottomSheet() + } + } + + @Builder + QuickActionsPopover() { + Column() { + this.AttachmentPanel() } - .width(300).padding({ left: 18, right: 18, top: 14, bottom: 14 }) - .backgroundColor('#FDFDFD') - .borderRadius(24) - .shadow({ radius: 24, color: '#26000000', offsetY: 8 }) - .position({ left: 28, bottom: 72 }) + .width(360) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(18) + .shadow({ radius: 20, color: '#1A000000', offsetY: 8 }) + .position({ left: 80 + this.contentHorizontalOffset, bottom: 86 }) .zIndex(6) - .transition(TransitionEffect.translate({ x: 0, y: 18 }) + .transition(TransitionEffect.translate({ x: 0, y: 14 }) .combine(TransitionEffect.opacity(0)) .animation({ duration: 220, curve: Curve.EaseOut })) } @Builder - HeaderActionsMenu() { + QuickActionsBottomSheet() { Column() { - Text('会话') - .fontSize(13).fontWeight(FontWeight.Medium).fontColor('#8E8E93') - .width('100%').height(28).padding({ left: 8 }) - this.RemoteStyleMenuItem('remote_actions_check', - this.isSessionPinned ? '取消置顶' : '置顶', () => this.onTogglePinSession(), this.isSessionPinned) - this.RemoteStyleMenuItem('remote_actions_cloud', '已上传的文件', () => this.onShowUploadedFiles()) - Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) - this.RemoteStyleMenuItem('remote_actions_folder', '归档', () => this.onArchiveSession()) - this.RemoteStyleMenuItem('remote_actions_settings', '删除', () => this.onDeleteSession()) + Text('') + .width(36) + .height(4) + .backgroundColor(LINE) + .borderRadius(2) + .margin({ top: 10 }) + this.AttachmentPanel() } - .width(330) - .padding({ left: 16, right: 16, top: 14, bottom: 14 }) + .width('100%') .backgroundColor(CARD) - .border({ width: 1, color: '#0D000000' }) - .borderRadius(20) - .shadow({ radius: 20, color: '#1A000000', offsetY: 8 }) - .position({ right: 18, top: 14 }) + .border({ width: { top: 1 }, color: LINE }) + .borderRadius({ topLeft: 18, topRight: 18 }) + .position({ left: 0, bottom: 0 }) .zIndex(6) - .transition(TransitionEffect.translate({ x: 18, y: -12 }) + .alignItems(HorizontalAlign.Center) + .transition(TransitionEffect.translate({ x: 0, y: 24 }) .combine(TransitionEffect.opacity(0)) .animation({ duration: 220, curve: Curve.EaseOut })) } @Builder - MenuItem(icon: string, label: string, action: () => void) { - Row({ space: 18 }) { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.' + icon)) - .width(25).height(25).objectFit(ImageFit.Contain) - } - .width(40).height(40).backgroundColor('#F2F2F2').borderRadius(20) - Text(label).fontSize(17).fontColor(INK).layoutWeight(1) + HeaderActionsPopover() { + Column() { + this.HeaderActionsContent() + } + .width(292) + .padding({ left: 12, right: 12, top: 10, bottom: 10 }) + .backgroundColor(FLOATING_PANEL_BG) + .border({ width: 1, color: LINE }) + .borderRadius(16) + .shadow({ radius: 18, color: LINE, offsetY: 7 }) + .transition(TransitionEffect.translate({ x: 8, y: -8 }) + .combine(TransitionEffect.opacity(0)) + .animation({ duration: 180, curve: Curve.EaseOut })) + } + + @Builder + HeaderActionsContent() { + Text('会话') + .fontSize(13).fontWeight(FontWeight.Medium).fontColor(MUTED) + .width('100%').height(28).padding({ left: 8 }) + if (this.surface === ChatSurface.General) { + this.RemoteStyleMenuItem('remote_actions_check', + this.isSessionPinned ? '取消置顶' : '置顶', () => this.onTogglePinSession(), this.isSessionPinned) + } + this.RemoteStyleMenuItem('remote_actions_cloud', '已上传的文件', () => this.onShowUploadedFiles()) + if (this.surface === ChatSurface.General) { + Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) + this.RemoteStyleMenuItem('remote_actions_folder', '归档', () => this.onArchiveSession()) + this.RemoteStyleMenuItem('remote_actions_settings', '删除', () => this.onDeleteSession()) + } else if (this.canStop) { + Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) + this.RemoteStyleMenuItem('remote_actions_settings', RemoteI18n.t('chat.stop'), () => this.onStop()) } - .width('100%').height(56) - .onClick(() => { - action(); - this.showQuickActions = false; - this.showHeaderActions = false; - }) } @Builder @@ -406,7 +492,7 @@ export struct ConversationView { .width('100%').height(48) .padding({ left: 8, right: 8 }) .borderRadius(10) - .backgroundColor(selected ? '#F3F3F3' : '#00000000') + .backgroundColor(selected ? SOFT : '#00000000') .onClick(() => { action() this.showHeaderActions = false @@ -416,15 +502,15 @@ export struct ConversationView { @Builder MenuIcon(icon: string) { if (icon === 'remote_actions_link') { - Image($r('app.media.remote_actions_link')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.link')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else if (icon === 'remote_actions_check') { - Image($r('app.media.remote_actions_check')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.checkmark_circle')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else if (icon === 'remote_actions_folder') { - Image($r('app.media.remote_actions_folder')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.folder')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else if (icon === 'remote_actions_cloud') { - Image($r('app.media.remote_actions_cloud')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.cloud')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else { - Image($r('app.media.remote_actions_settings')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.gearshape')).fontSize(20).fontColor([MUTED]).width(23).height(23) } } @@ -437,7 +523,7 @@ export struct ConversationView { .fontSize(21) .fontColor(INK) .textAlign(TextAlign.Center) - .backgroundColor('#F2F1EE') + .backgroundColor(SOFT) .borderRadius(16) Column({ space: 3 }) { Text(RemoteI18n.t('chat.pickImage')) @@ -459,7 +545,7 @@ export struct ConversationView { .width('100%') .height(48) .padding({ left: 12, right: 12 }) - .backgroundColor('#FAFAF8') + .backgroundColor(CARD) .borderRadius(14) .border({ width: 1, color: LINE }) .onClick(() => { @@ -503,15 +589,13 @@ export struct ConversationView { .fontWeight(FontWeight.Medium) .fontColor(MUTED) } else if (kind === 'globe') { - Image($r('app.media.gpt_home_globe_glyph')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.website')) + .fontSize(21) + .fontColor([MUTED]) } else if (kind === 'image') { - Image($r('app.media.gpt_home_image_glyph')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.picture')) + .fontSize(21) + .fontColor([MUTED]) } else { this.FileGlyph() } @@ -544,7 +628,14 @@ export struct ConversationView { this.showSuggestionsWhenEmpty, this.isBusy, this.timelineItems - ); + ) && (this.supportsSearch || this.supportsImages || this.supportsFiles); + } + + private shouldCenterInlineStatus(): boolean { + return this.inlineStatusText.length > 0 && + !this.isBusy && + !this.shouldShowSuggestions() && + !ConversationViewContract.hasRealTimelineItem(this.timelineItems); } private visibleTimelineItems(): ChatTimelineItem[] { @@ -558,7 +649,7 @@ export struct ConversationView { return ConnectionStatusPresenter.detail(this.connectionState, this.statusText, '', ''); } - private statusColor(): string { + private statusColor(): ResourceColor { if (this.canStop && this.connectionState === 'connected') { return GREEN; } @@ -587,7 +678,7 @@ export struct ConversationView { return this.surface === ChatSurface.Remote && this.connectionState !== 'connected'; } - private connectionColor(): string { + private connectionColor(): ResourceColor { const tone = ConnectionStatusPresenter.tone(this.connectionState); if (tone === 'ok') { return GREEN; 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 0782b9ed54..1c6bae8410 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 @@ -6,12 +6,19 @@ import { ConversationIntentType } from './ConversationIntent'; import { ConversationUiQuestionAnswer } from './ConversationUiModels'; +import { ComposerPresentation } from './ComposerBar'; @ComponentV2 export struct ConversationViewHost { @Param viewState: ConversationViewState = new ConversationViewState(); + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; @Param showSidebarButton: boolean = true; @Param showBackButton: boolean = true; + @Param showSidebarRestoreButton: boolean = false; + @Param composerPresentation: ComposerPresentation = ComposerPresentation.Compact; + @Param contentHorizontalOffset: number = 0; + @Param onRestoreSidebar: () => void = () => {}; @Param onIntent: (intent: ConversationIntent) => void = (_intent: ConversationIntent) => {}; build() { @@ -38,15 +45,20 @@ export struct ConversationViewHost { downloadingFilePath: this.viewState.downloadingFilePath, downloadedFilePath: this.viewState.downloadedFilePath, fileDownloadStatus: this.viewState.fileDownloadStatus, + activeFilePreviewPath: this.activeFilePreviewPath, + activeFilePreviewLoading: this.activeFilePreviewLoading, selectedImages: this.viewState.selectedImages, isVoiceListening: this.viewState.isVoiceListening, chatInput: this.viewState.chatInput, isSessionPinned: this.viewState.isSessionPinned, showSidebarButton: this.showSidebarButton, showBackButton: this.showBackButton, + showSidebarRestoreButton: this.showSidebarRestoreButton, + composerPresentation: this.composerPresentation, + contentHorizontalOffset: this.contentHorizontalOffset, onOpenSidebar: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.OpenSidebar)), + onRestoreSidebar: this.onRestoreSidebar, onBack: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Back)), - onNewSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.NewSession)), onTogglePinSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.TogglePin)), onArchiveSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Archive)), onDeleteSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Delete)), @@ -61,6 +73,8 @@ export struct ConversationViewHost { onRenameSession: (title: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.RenameSession, title)), onCopyMessage: (text: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.CopyMessage, text)), onRetryMessage: (text: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.RetryMessage, text)), + onOpenFilePreview: (path: string, label: string) => + this.dispatch(ConversationIntents.openFilePreview(path, label)), onSelectModel: (id: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.SelectModel, id)), onPickImages: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.PickImages)), onRemoveImage: (id: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.RemoveImage, id)), 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 new file mode 100644 index 0000000000..d965d5b08e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets @@ -0,0 +1,401 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; +import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; + +@ComponentV2 +struct WorkspaceFilterSection { + @Param options: RecentWorkspaceEntry[] = []; + @Param selectedValue: string = ''; + @Event onSelect: (value: string) => void = (_value: string) => {}; + @Local currentValue: string = ''; + + aboutToAppear(): void { + this.currentValue = this.selectedValue; + } + + build() { + Column() { + Row({ space: 12 }) { + SymbolGlyph(this.currentValue.length === 0 ? $r('sys.symbol.checkmark_circle_fill') : $r('sys.symbol.circle')) + .fontSize(20) + .fontColor([this.currentValue.length === 0 ? INK : MUTED]) + .width(22) + Text(RemoteI18n.t('viewSettings.allWorkspaces')) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + } + .width('100%') + .height(46) + .padding({ left: 10, right: 10 }) + .backgroundColor(this.currentValue.length === 0 ? CARD : '#00000000') + .border({ width: { bottom: 1 }, color: LINE }) + .onClick(() => this.selectWorkspace('')) + ForEach(this.options, (item: RecentWorkspaceEntry) => { + Row({ space: 12 }) { + SymbolGlyph(ConversationSessionFilterPolicy.workspacePathsEqual(this.currentValue, item.path) + ? $r('sys.symbol.checkmark_circle_fill') : $r('sys.symbol.circle')) + .fontSize(20) + .fontColor([ConversationSessionFilterPolicy.workspacePathsEqual(this.currentValue, item.path) ? INK : MUTED]) + .width(22) + Text(item.name || this.basename(item.path)) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .height(46) + .padding({ left: 10, right: 10 }) + .backgroundColor(ConversationSessionFilterPolicy.workspacePathsEqual(this.currentValue, item.path) + ? CARD : '#00000000') + .border({ width: { bottom: 1 }, color: LINE }) + .onClick(() => this.selectWorkspace(item.path)) + }, (item: RecentWorkspaceEntry): string => item.path) + } + .width('100%') + } + + private selectWorkspace(value: string): void { + this.currentValue = value; + setTimeout(() => this.onSelect(value), 0); + } + + private basename(path: string): string { + const parts = path.split('/'); + return parts.length > 0 ? parts[parts.length - 1] : path; + } +} + +@ComponentV2 +export struct ConversationViewSettings { + @Param sessions: RemoteSession[] = []; + @Param workspaceName: string = ''; + @Param workspacePath: string = ''; + @Param workspaceKind: string = 'normal'; + @Param recentWorkspaces: RecentWorkspaceEntry[] = []; + @Param sortMode: string = 'project'; + @Param workspaceFilter: string = ''; + @Param agentFilter: string = ''; + @Param statusFilter: string = ''; + @Param showWorkspaceMetadata: boolean = false; + @Param showUpdatedMetadata: boolean = false; + @Param showStatusMetadata: boolean = false; + @Local selectedSortMode: string = ''; + @Local selectedWorkspaceFilter: string = ''; + @Local selectedAgentFilter: string = ''; + @Local selectedStatusFilter: string = ''; + @Local selectionRevision: number = 0; + @Event onSortModeChange: (mode: string) => void = (_mode: string) => {}; + @Event onWorkspaceFilterChange: (value: string) => void = (_value: string) => {}; + @Event onAgentFilterChange: (value: string) => void = (_value: string) => {}; + @Event onStatusFilterChange: (value: string) => void = (_value: string) => {}; + @Event onWorkspaceMetadataChange: (value: boolean) => void = (_value: boolean) => {}; + @Event onUpdatedMetadataChange: (value: boolean) => void = (_value: boolean) => {}; + @Event onStatusMetadataChange: (value: boolean) => void = (_value: boolean) => {}; + @Event onClose: () => void = () => {}; + + aboutToAppear(): void { + this.syncSelectionState(); + } + + build() { + Column() { + Row() { + Column({ space: 3 }) { + Text(RemoteI18n.t('viewSettings.title')) + .fontSize(18) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text(RemoteI18n.t('viewSettings.subtitle')) + .fontSize(12) + .fontColor(MUTED) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(17) + .fontColor([MUTED]) + .width(20) + .height(20) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.onClose()) + } + .width('100%') + .height(64) + .padding({ left: 20, right: 10 }) + + Divider().color(LINE) + + Scroll() { + Column({ space: 0 }) { + this.SectionTitle(RemoteI18n.t('viewSettings.grouping')) + this.SortRow('project', RemoteI18n.t('remote.menu.byProject'), this.selectionRevision) + this.SortRow('time', RemoteI18n.t('remote.menu.byTime'), this.selectionRevision) + this.SortRow('chat', RemoteI18n.t('remote.menu.chatFirst'), this.selectionRevision) + this.SectionTitle(RemoteI18n.t('viewSettings.filters')) + this.FilterLabel(RemoteI18n.t('viewSettings.workspace')) + WorkspaceFilterSection({ + options: this.workspaceFilterOptions(), + selectedValue: this.workspaceFilter, + onSelect: (value: string) => this.selectWorkspaceFilter(value) + }) + this.FilterLabel(RemoteI18n.t('viewSettings.agentType')) + this.ChoiceRow('', RemoteI18n.t('viewSettings.allAgentTypes'), this.selectedAgentFilter.length === 0, + (value: string) => this.selectAgentFilter(value), this.selectionRevision) + ForEach(this.agentFilterOptions(), (value: string) => { + this.ChoiceRow(value, this.agentFilterLabel(value), this.selectedAgentFilter === value, + (nextValue: string) => this.selectAgentFilter(nextValue), this.selectionRevision) + }, (value: string): string => value) + this.FilterLabel(RemoteI18n.t('viewSettings.status')) + this.ChoiceRow('', RemoteI18n.t('viewSettings.allStatuses'), this.selectedStatusFilter.length === 0, + (value: string) => this.selectStatusFilter(value), this.selectionRevision) + ForEach(this.statusFilterOptions(), (value: string) => { + this.ChoiceRow(value, this.statusFilterLabel(value), this.selectedStatusFilter === value, + (nextValue: string) => this.selectStatusFilter(nextValue), this.selectionRevision) + }, (value: string): string => value) + this.SectionTitle(RemoteI18n.t('viewSettings.metadata')) + this.ToggleRow(RemoteI18n.t('viewSettings.workspace'), this.showWorkspaceMetadata, + this.onWorkspaceMetadataChange) + this.ToggleRow(RemoteI18n.t('viewSettings.updated'), this.showUpdatedMetadata, + this.onUpdatedMetadataChange) + this.ToggleRow(RemoteI18n.t('viewSettings.status'), this.showStatusMetadata, + this.onStatusMetadataChange) + } + .width('100%') + .padding({ left: 20, right: 20, bottom: 24 }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + .borderRadius(16) + } + + @Builder + private FilterLabel(label: string) { + Text(label) + .width('100%') + .height(34) + .padding({ left: 10, top: 10 }) + .fontSize(12) + .fontColor(MUTED) + } + + @Builder + private SectionTitle(title: string) { + Text(title) + .width('100%') + .height(38) + .padding({ left: 4, top: 12 }) + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + } + + @Builder + private SortRow(mode: string, label: string, _revision: number) { + Row({ space: 12 }) { + SymbolGlyph(this.selectedSortMode === mode ? $r('sys.symbol.checkmark_circle_fill') : $r('sys.symbol.circle')) + .fontSize(20) + .fontColor([this.selectedSortMode === mode ? INK : MUTED]) + Text(label) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + } + .width('100%') + .height(48) + .padding({ left: 10, right: 10 }) + .backgroundColor(this.selectedSortMode === mode ? CARD : '#00000000') + .opacity(_revision % 2 === 0 ? 1 : 0.999) + .border({ width: { bottom: 1 }, color: LINE }) + .onClick(() => this.selectSortMode(mode)) + } + + @Builder + private ChoiceRow(value: string, label: string, selected: boolean, action: (value: string) => void, + _revision: number) { + Row({ space: 12 }) { + SymbolGlyph(selected ? $r('sys.symbol.checkmark_circle_fill') : $r('sys.symbol.circle')) + .fontSize(20) + .fontColor([selected ? INK : MUTED]) + .width(22) + Text(label) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .height(46) + .padding({ left: 10, right: 10 }) + .backgroundColor(selected ? CARD : '#00000000') + .opacity(_revision % 2 === 0 ? 1 : 0.999) + .border({ width: { bottom: 1 }, color: LINE }) + .onClick(() => action(value)) + } + + @Builder + private ToggleRow(label: string, value: boolean, action: (value: boolean) => void) { + Row({ space: 12 }) { + Text(label) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + Toggle({ type: ToggleType.Switch, isOn: value }) + .selectedColor(INK) + .onChange(action) + } + .width('100%') + .height(52) + .padding({ left: 10, right: 6 }) + .border({ width: { bottom: 1 }, color: LINE }) + } + + private workspaceFilterOptions(): RecentWorkspaceEntry[] { + const result: RecentWorkspaceEntry[] = []; + if (this.workspacePath.length > 0) { + result.push({ path: this.workspacePath, name: this.workspaceName, lastOpened: '', workspaceKind: 'normal' }); + } + this.recentWorkspaces.forEach((item: RecentWorkspaceEntry) => { + if (item.path.length > 0 && !result.some((entry: RecentWorkspaceEntry) => { + return ConversationSessionFilterPolicy.workspacePathsEqual(entry.path, item.path); + })) { + result.push(item); + } + }); + this.sessions.forEach((session: RemoteSession) => { + const path = session.workspacePath || ''; + if (path.length > 0 && !result.some((entry: RecentWorkspaceEntry) => { + return ConversationSessionFilterPolicy.workspacePathsEqual(entry.path, path); + })) { + result.push({ + path, + name: session.workspaceName || this.basename(path), + lastOpened: session.updatedAt, + workspaceKind: 'normal' + }); + } + }); + return result; + } + + private syncSelectionState(): void { + this.selectedSortMode = this.sortMode; + this.selectedWorkspaceFilter = this.workspaceFilter; + this.selectedAgentFilter = this.agentFilter; + this.selectedStatusFilter = this.statusFilter; + } + + private selectSortMode(mode: string): void { + this.selectedSortMode = mode; + this.selectionRevision += 1; + } + + private selectWorkspaceFilter(value: string): void { + RemoteLogger.info(`view-settings select workspace path=${value.length > 0 ? value : ''}`); + this.selectedWorkspaceFilter = value; + this.selectionRevision += 1; + this.onWorkspaceFilterChange(value); + } + + private selectAgentFilter(value: string): void { + this.selectedAgentFilter = value; + this.selectionRevision += 1; + } + + private selectStatusFilter(value: string): void { + this.selectedStatusFilter = value; + this.selectionRevision += 1; + } + + private applySelectionAndClose(): void { + RemoteLogger.info(`view-settings apply workspace=${this.selectedWorkspaceFilter.length > 0 ? + this.selectedWorkspaceFilter : ''}`); + this.onSortModeChange(this.selectedSortMode); + this.onWorkspaceFilterChange(this.selectedWorkspaceFilter); + this.onAgentFilterChange(this.selectedAgentFilter); + this.onStatusFilterChange(this.selectedStatusFilter); + this.onClose(); + } + + private agentFilterOptions(): string[] { + const values: string[] = []; + this.sessions.forEach((session: RemoteSession) => { + const value = this.agentGroup(session); + if (value.length > 0 && values.indexOf(value) < 0) { + values.push(value); + } + }); + return ['chat', 'code', 'cowork'].filter((value: string) => values.indexOf(value) >= 0); + } + + private statusFilterOptions(): string[] { + const values: string[] = []; + this.sessions.forEach((session: RemoteSession) => { + const value = (session.status || '').trim().toLowerCase(); + if (value.length > 0 && values.indexOf(value) < 0) { + values.push(value); + } + }); + return values.sort(); + } + + private agentGroup(session: RemoteSession): string { + const value = (session.agentType || '').toLowerCase(); + if (value === 'claw' || value === 'assistant' || value === 'chat' || + this.isAssistantWorkspace(session.workspacePath || '')) { + return 'chat'; + } + return value === 'cowork' ? 'cowork' : 'code'; + } + + private agentFilterLabel(value: string): string { + if (value === 'chat') { + return RemoteI18n.t('remote.create.chat'); + } + return value === 'cowork' ? 'Cowork' : 'Code'; + } + + private statusFilterLabel(value: string): string { + if (value === 'active' || value === 'running') { + return RemoteI18n.t('common.running'); + } + if (value === 'ready' || value === 'idle') { + return RemoteI18n.t('common.ready'); + } + if (value === 'archived') { + return RemoteI18n.t('sidebar.archived'); + } + return value; + } + + private basename(path: string): string { + const parts = path.split('/'); + return parts.length > 0 ? parts[parts.length - 1] : path; + } + + private isAssistantWorkspace(path: string): boolean { + if (path.length === 0) { + return this.workspaceKind.toLowerCase() === 'assistant'; + } + if (path === this.workspacePath && this.workspaceKind.toLowerCase() === 'assistant') { + return true; + } + return this.recentWorkspaces.some((item: RecentWorkspaceEntry) => { + return item.path === path && item.workspaceKind.toLowerCase() === 'assistant'; + }); + } +} 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 a68348cfeb..7ad51ec5d1 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,5 +1,5 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, SOFT, SUBTLE } from './Theme'; +import { CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SOFT, SUBTLE } from './Theme'; @Component export struct CreateSessionSheet { @@ -87,8 +87,8 @@ export struct CreateSessionSheet { .height(56) .fontSize(17) .fontWeight(FontWeight.Medium) - .fontColor(CARD) - .backgroundColor(ACCENT) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) .borderRadius(14) .enabled(!this.isBusy) .onClick(() => { 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 fddc268c53..72fb3c48cc 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,5 +1,4 @@ -const AVATAR_BACKGROUND: string = '#ECEEF1'; -const AVATAR_FOREGROUND: string = '#626268'; +import { MUTED, SOFT } from './Theme'; @Component export struct DefaultAccountAvatar { @@ -9,11 +8,11 @@ export struct DefaultAccountAvatar { Stack({ alignContent: Alignment.Center }) { SymbolGlyph($r('sys.symbol.person')) .fontSize(this.avatarSize * 0.52) - .fontColor([AVATAR_FOREGROUND]) + .fontColor([MUTED]) } .width(this.avatarSize) .height(this.avatarSize) - .backgroundColor(AVATAR_BACKGROUND) + .backgroundColor(SOFT) .borderRadius(this.avatarSize / 2) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets new file mode 100644 index 0000000000..de45baf077 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets @@ -0,0 +1,523 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { + CodeSyntaxHighlightCache, + CodeSyntaxHighlighter, + CodeSyntaxToken, + CodeSyntaxTokenKind +} from '../../services/CodeSyntaxHighlighter'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { + FilePreviewPhase, + FilePreviewRendererKind, + FilePreviewState +} from '../state/FilePreviewState'; +import { MarkdownContent } from './MarkdownContent'; +import { + CARD, + CODE_COMMENT, + CODE_CONSTANT, + CODE_FUNCTION, + CODE_KEYWORD, + CODE_LINE_NUMBER, + CODE_NUMBER, + CODE_PROPERTY, + CODE_STRING, + CODE_TARGET_BG, + CODE_TYPE, + INK, + LINE, + MUTED, + PAGE_BG, + SOFT +} from './Theme'; + +@ComponentV2 +export struct FilePreviewSurface { + private readonly textScroller: Scroller = new Scroller(); + private readonly markdownScroller: Scroller = new Scroller(); + private readonly syntaxHighlightCache: CodeSyntaxHighlightCache = new CodeSyntaxHighlightCache(); + private restoreScrollTimerId: number = 0; + @Local imageOriginalSizeTarget: string = ''; + @Param state: FilePreviewState = new FilePreviewState(); + @Param remoteAvailable: boolean = true; + @Param downloadPath: string = ''; + @Param downloadedPath: string = ''; + @Param downloadStatus: string = ''; + @Event onClose: () => void = () => {}; + @Event onRefresh: () => void = () => {}; + @Event onDownload: (path: string) => void = (_path: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; + + aboutToDisappear(): void { + this.clearRestoreScrollTimer(); + } + + build() { + Column() { + this.Header() + this.DownloadStatus() + this.Body() + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + Header() { + Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(22) + .fontColor([INK]) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => { + this.onClose(); + }) + + Column({ space: 2 }) { + Text(this.state.fileName || this.state.target.displayName || RemoteI18n.t('filePreview.title')) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.headerDetail()) + .fontSize(11) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + if (this.isImageReady()) { + Stack({ alignContent: Alignment.Center }) { + Text('1:1') + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor(this.isImageOriginalSize() ? CODE_FUNCTION : INK) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t( + this.isImageOriginalSize() ? 'filePreview.fitImage' : 'filePreview.actualImageSize' + )) + .onClick(() => { + this.toggleImageSize(); + }) + } + + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.arrow_clockwise')) + .fontSize(20) + .fontColor([this.canUseRemoteAction() ? INK : MUTED]) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('common.refresh')) + .opacity(this.canUseRemoteAction() ? 1 : 0.45) + .onClick(() => { + if (this.canUseRemoteAction()) { + this.onRefresh(); + } + }) + + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.arrow_down_to_line')) + .fontSize(20) + .fontColor([this.canDownload() ? INK : MUTED]) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('chat.download')) + .opacity(this.canDownload() ? 1 : 0.45) + .onClick(() => { + if (this.canDownload()) { + this.onDownload(this.state.target.rawReference || this.state.target.remotePath); + } + }) + } + .width('100%') + .height(68) + .padding({ left: 8, right: 8, top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + .border({ width: { bottom: 1 }, color: LINE }) + .backgroundColor(PAGE_BG) + } + + @Builder + Body() { + if (this.state.phase === FilePreviewPhase.Loading && !this.remoteAvailable) { + this.OfflineLoadingState() + } else if (this.state.phase === FilePreviewPhase.Loading) { + this.LoadingState() + } else if (this.state.phase === FilePreviewPhase.Error) { + this.ErrorState() + } else if (this.state.phase === FilePreviewPhase.Unsupported) { + this.UnsupportedState() + } else if (this.state.phase === FilePreviewPhase.Ready && + this.state.rendererKind === FilePreviewRendererKind.Image) { + this.ImagePreview() + } else if (this.state.phase === FilePreviewPhase.Ready && + this.state.rendererKind === FilePreviewRendererKind.Markdown) { + this.MarkdownPreview() + } else if (this.state.phase === FilePreviewPhase.Ready) { + this.TextPreview() + } else { + this.EmptyState() + } + } + + @Builder + DownloadStatus() { + if (this.downloadStatus.length > 0 && this.downloadMatchesTarget()) { + Row() { + Text(this.downloadStatus) + .width('100%') + .fontSize(12) + .fontColor(MUTED) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .padding({ left: 14, right: 14, top: 8, bottom: 8 }) + .backgroundColor(SOFT) + .border({ width: { bottom: 1 }, color: LINE }) + } + } + + @Builder + LoadingState() { + Column({ space: 12 }) { + LoadingProgress() + .width(28) + .height(28) + .color(MUTED) + Text(RemoteI18n.t('filePreview.loading')) + .fontSize(14) + .fontColor(MUTED) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + OfflineLoadingState() { + Column({ space: 10 }) { + Text(RemoteI18n.t('filePreview.loadFailed')) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text(RemoteI18n.t('filePreview.offline')) + .fontSize(13) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + } + .width('100%') + .layoutWeight(1) + .padding({ left: 32, right: 32 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + ErrorState() { + Column({ space: 12 }) { + Text(RemoteI18n.t('filePreview.loadFailed')) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text(this.state.errorText) + .fontSize(13) + .lineHeight(19) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .maxLines(4) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.state.errorRetryable) { + Button(RemoteI18n.t('common.retry')) + .height(42) + .fontSize(14) + .fontColor(CARD) + .backgroundColor(INK) + .borderRadius(21) + .enabled(this.remoteAvailable) + .opacity(this.remoteAvailable ? 1 : 0.45) + .onClick(() => { + this.onRefresh(); + }) + } + } + .width('100%') + .layoutWeight(1) + .padding({ left: 32, right: 32 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + UnsupportedState() { + Column({ space: 10 }) { + SymbolGlyph($r('sys.symbol.doc')) + .fontSize(38) + .fontColor([MUTED]) + Text(RemoteI18n.t('filePreview.unsupported')) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text(this.metadataDetail()) + .fontSize(13) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + Button(RemoteI18n.t('chat.download')) + .height(42) + .fontSize(14) + .fontColor(CARD) + .backgroundColor(INK) + .borderRadius(21) + .enabled(this.remoteAvailable) + .opacity(this.remoteAvailable ? 1 : 0.45) + .onClick(() => { + this.onDownload(this.state.target.rawReference || this.state.target.remotePath); + }) + } + .width('100%') + .layoutWeight(1) + .padding({ left: 32, right: 32 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + TextPreview() { + Column() { + if (this.state.truncated) { + Text(RemoteI18n.f('filePreview.truncated', RemoteUiState.formatBytes(this.state.loadedBytes))) + .width('100%') + .fontSize(11) + .fontColor(MUTED) + .padding({ left: 14, right: 14, top: 7, bottom: 7 }) + .backgroundColor(SOFT) + .border({ width: { bottom: 1 }, color: LINE }) + } + Scroll(this.textScroller) { + Text() { + ForEach(this.syntaxTokens(), (token: CodeSyntaxToken) => { + Span(token.text) + .fontColor(this.syntaxTokenColor(token.kind)) + .textBackgroundStyle({ + color: this.isTargetLine(token.lineNumber) ? CODE_TARGET_BG : '#00000000' + }) + }, (token: CodeSyntaxToken) => token.id) + } + .fontSize(12) + .lineHeight(19) + .fontColor(INK) + .fontFamily('monospace') + .textSelectable(TextSelectableMode.SELECTABLE_UNFOCUSABLE) + .padding({ left: 14, right: 20, top: 14, bottom: 24 }) + .constraintSize({ minWidth: '100%' }) + } + .scrollable(ScrollDirection.FREE) + .scrollBar(BarState.Auto) + .layoutWeight(1) + .width('100%') + .onAppear(() => { + this.restoreTextScroll(); + }) + .onDidScroll((xOffset: number, yOffset: number, _scrollState: ScrollState) => { + this.state.recordScroll(xOffset, yOffset); + }) + } + .layoutWeight(1) + .width('100%') + .backgroundColor(SOFT) + } + + @Builder + MarkdownPreview() { + Scroll(this.markdownScroller) { + MarkdownContent({ + text: this.state.textContent, + onCopyText: (_text: string) => {}, + onOpenLink: (reference: string, label: string) => { + this.onOpenLink(reference, label); + } + }) + .padding({ left: 20, right: 20, top: 16, bottom: 28 }) + } + .scrollable(ScrollDirection.Vertical) + .scrollBar(BarState.Auto) + .layoutWeight(1) + .width('100%') + .onAppear(() => { + this.restoreMarkdownScroll(); + }) + .onDidScroll((xOffset: number, yOffset: number, _scrollState: ScrollState) => { + this.state.recordScroll(xOffset, yOffset); + }) + } + + @Builder + ImagePreview() { + Stack({ alignContent: Alignment.Center }) { + Image(`data:${this.state.mimeType};base64,${this.state.contentBase64}`) + .width('100%') + .height('100%') + .objectFit(this.isImageOriginalSize() ? ImageFit.None : ImageFit.Contain) + .onClick(() => { + this.toggleImageSize(); + }) + .onError((_error: ImageError) => { + this.imageOriginalSizeTarget = ''; + this.state.phase = FilePreviewPhase.Error; + this.state.errorText = RemoteI18n.t('filePreview.imageDecodeFailed'); + this.state.errorRetryable = true; + }) + } + .layoutWeight(1) + .width('100%') + .padding(18) + .backgroundColor(SOFT) + } + + @Builder + EmptyState() { + Column() { + } + .layoutWeight(1) + .width('100%') + } + + private canUseRemoteAction(): boolean { + return this.remoteAvailable && this.state.visible && this.state.phase !== FilePreviewPhase.Loading && + (this.state.phase !== FilePreviewPhase.Error || this.state.errorRetryable) && + this.state.target.isValid(); + } + + private canDownload(): boolean { + return this.remoteAvailable && this.state.visible && !this.isDownloadingTarget() && this.state.target.isValid(); + } + + private isDownloadingTarget(): boolean { + return this.downloadPath.length > 0 && this.downloadMatchesTarget() && this.downloadedPath.length === 0; + } + + private downloadMatchesTarget(): boolean { + const target = this.state.target.rawReference || this.state.target.remotePath; + return target.length > 0 && (target === this.downloadPath || target === this.downloadedPath); + } + + private isImageReady(): boolean { + return this.state.phase === FilePreviewPhase.Ready && + this.state.rendererKind === FilePreviewRendererKind.Image; + } + + private isImageOriginalSize(): boolean { + return this.imageOriginalSizeTarget.length > 0 && + this.imageOriginalSizeTarget === this.state.target.remotePath; + } + + private toggleImageSize(): void { + this.imageOriginalSizeTarget = this.isImageOriginalSize() ? '' : this.state.target.remotePath; + } + + private headerDetail(): string { + if (!this.remoteAvailable) { + return RemoteI18n.t('filePreview.offline'); + } + if (this.state.phase === FilePreviewPhase.Loading) { + return RemoteI18n.t('filePreview.loading'); + } + return this.metadataDetail(); + } + + private metadataDetail(): string { + const size = this.state.fileSize > 0 ? RemoteUiState.formatBytes(this.state.fileSize) : ''; + if (this.state.mimeType.length > 0 && size.length > 0) { + return `${this.state.mimeType} · ${size}`; + } + return this.state.mimeType || size || this.state.target.remotePath; + } + + private syntaxTokens(): CodeSyntaxToken[] { + return this.syntaxHighlightCache.tokensFor( + this.state.textContent, + this.state.fileName || this.state.target.remotePath + ); + } + + private syntaxTokenColor(kind: CodeSyntaxTokenKind): ResourceColor { + if (kind === CodeSyntaxTokenKind.LineNumber) { + return CODE_LINE_NUMBER; + } + if (kind === CodeSyntaxTokenKind.Keyword) { + return CODE_KEYWORD; + } + if (kind === CodeSyntaxTokenKind.String) { + return CODE_STRING; + } + if (kind === CodeSyntaxTokenKind.Number) { + return CODE_NUMBER; + } + if (kind === CodeSyntaxTokenKind.Comment) { + return CODE_COMMENT; + } + if (kind === CodeSyntaxTokenKind.Function) { + return CODE_FUNCTION; + } + if (kind === CodeSyntaxTokenKind.Type) { + return CODE_TYPE; + } + if (kind === CodeSyntaxTokenKind.Constant) { + return CODE_CONSTANT; + } + if (kind === CodeSyntaxTokenKind.Property) { + return CODE_PROPERTY; + } + return INK; + } + + private isTargetLine(lineNumber: number): boolean { + if (lineNumber <= 0 || this.state.target.lineStart <= 0) { + return false; + } + const end = this.state.target.lineEnd > 0 ? this.state.target.lineEnd : this.state.target.lineStart; + return lineNumber >= this.state.target.lineStart && lineNumber <= end; + } + + private restoreTextScroll(): void { + this.clearRestoreScrollTimer(); + this.restoreScrollTimerId = setTimeout(() => { + this.restoreScrollTimerId = 0; + const xOffset = this.state.initialScrollX(); + const yOffset = this.state.initialScrollY(); + this.textScroller.scrollTo({ xOffset, yOffset, animation: false }); + this.state.recordScroll(xOffset, yOffset); + }, 30); + } + + private restoreMarkdownScroll(): void { + this.clearRestoreScrollTimer(); + this.restoreScrollTimerId = setTimeout(() => { + this.restoreScrollTimerId = 0; + const xOffset = this.state.initialScrollX(); + const yOffset = this.state.hasRecordedScroll ? this.state.initialScrollY() : 0; + this.markdownScroller.scrollTo({ xOffset, yOffset, animation: false }); + this.state.recordScroll(xOffset, yOffset); + }, 30); + } + + private clearRestoreScrollTimer(): void { + if (this.restoreScrollTimerId !== 0) { + clearTimeout(this.restoreScrollTimerId); + this.restoreScrollTimerId = 0; + } + } +} 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 0eb34698f1..06f477b898 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,56 +1,85 @@ -import { CARD, INK, LINE, MUTED } from './Theme'; +import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; @Component 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) => {}; build() { Row({ space: 10 }) { - Text('▤') + Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + if (this.previewLoading) { + LoadingProgress() + .width(17) + .height(17) + .color(FILE_LINK) + } else { + Text('▤') + .fontSize(16) + .fontColor(INK) + } + } .width(34) .height(34) - .fontSize(16) - .fontColor(INK) - .textAlign(TextAlign.Center) - .backgroundColor('#F3F2EE') + .backgroundColor(SOFT) .borderRadius(12) - Column({ space: 3 }) { - Text(this.label) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.status) - .fontSize(11) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) + Column({ space: 3 }) { + Text(this.label) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.status) + .fontSize(11) + .fontColor(FILE_LINK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) } .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - Text(this.buttonLabel) - .fontSize(12) - .fontColor(this.disabled ? MUTED : INK) - .padding({ left: 10, right: 10, top: 6, bottom: 6 }) - .backgroundColor('#F0EFEB') - .borderRadius(14) - .border({ width: 1, color: LINE }) - .onClick(() => { - if (!this.disabled) { - this.onDownload(this.path); - } - }) + .height(44) + .accessibilityText(`${this.previewLabel} ${this.label}`) + .onClick(() => { + this.onPreview(this.path, this.label); + }) + Stack({ alignContent: Alignment.Center }) { + if (this.disabled) { + LoadingProgress() + .width(18) + .height(18) + .color(MUTED) + } else { + SymbolGlyph($r('sys.symbol.arrow_down_to_line')) + .fontSize(19) + .fontColor([INK]) + } + } + .width(44) + .height(44) + .accessibilityText(this.buttonLabel) + .opacity(this.disabled ? 0.55 : 1) + .onClick(() => { + if (!this.disabled) { + this.onDownload(this.path); + } + }) } .width('100%') .padding(12) - .backgroundColor(CARD) + .backgroundColor(this.selected ? SOFT : CARD) .borderRadius(14) - .border({ width: 1, color: LINE }) + .border({ width: 1, color: this.selected ? FILE_LINK : LINE }) } } 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 06e3cfb177..a088f54d88 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,65 +1,124 @@ -import { CARD, INK, PAGE_BG } from './Theme'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE, PAGE_BG } from './Theme'; +import { CompactMenuButton } from './CompactMenuButton'; +import { SidebarToggleButton } from './SidebarToggleButton'; -@Component +@ComponentV2 export struct GeneralChatHeader { - onOpenSidebar: () => void = () => {}; - @Prop showActions: boolean = false; - @Prop showSidebarButton: boolean = true; - onNewSession: () => void = () => {}; - onOpenActions: () => void = () => {}; + @Param title: string = ''; + @Param showActions: boolean = false; + @Param showSidebarButton: boolean = true; + @Param showBackButton: boolean = false; + @Param showSidebarRestoreButton: boolean = false; + @Param showActionsMenu: boolean = false; + @BuilderParam actionsMenu: () => void = this.EmptyBuilder; + @Event onOpenSidebar: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; + @Event onBack: () => void = () => {}; + @Event onOpenActions: () => void = () => {}; + @Event onActionsMenuStateChange: (visible: boolean) => void = (_visible: boolean) => {}; build() { - Row() { - if (this.showSidebarButton) { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.gpt_home_menu_glyph')) - .width(26) - .height(26) - .objectFit(ImageFit.Contain) - } - .width(48) - .height(48) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) - .onClick(() => { - this.onOpenSidebar(); - }) - } - Text('BitFun') + Row({ space: 8 }) { + this.LeadingControl() + + Text(this.title || 'BitFun') .fontSize(17) .fontWeight(FontWeight.Medium) .fontColor(INK) - .height(48) - .padding({ left: this.showSidebarButton ? 18 : 0, right: 18 }) - .margin({ left: this.showSidebarButton ? 12 : 0 }) - Blank() - if (this.showActions) { - Row() { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_edit')) - .width(24).height(24).objectFit(ImageFit.Contain) - } - .width(44).height(48) - .onClick(() => this.onNewSession()) - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_more')) - .width(25).height(8).objectFit(ImageFit.Contain) - } - .width(44).height(48) - .onClick(() => this.onOpenActions()) - } - .width(90).height(48) - .padding({ left: 1, right: 1 }) - .backgroundColor('#FFFFFF') - .borderRadius(24) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) - } + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.Center) + + this.TrailingControl() } .width('100%') - .height(52) + .height(64) .alignItems(VerticalAlign.Center) - .padding({ left: 24, right: 24 }) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) .backgroundColor(PAGE_BG) } + + @Builder + private LeadingControl() { + if (this.showSidebarRestoreButton) { + SidebarToggleButton({ + restore: true, + controlSize: 48, + onToggle: this.onRestoreSidebar + }) + } else if (this.showBackButton) { + Stack({ alignContent: Alignment.Center }) { + Image($r('app.media.remote_ref_back')) + .width(15) + .height(23) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) + } + .width(44) + .height(44) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(22) + .shadow({ radius: 10, color: LINE, offsetY: 3 }) + .accessibilityText(RemoteI18n.t('common.back')) + .onClick(() => { + this.onBack(); + }) + } else if (this.showSidebarButton) { + CompactMenuButton({ + onOpen: () => { + this.onOpenSidebar(); + } + }) + } else { + Blank().width(44).height(44) + } + } + + @Builder + private TrailingControl() { + if (this.showActions) { + Stack({ alignContent: Alignment.Center }) { + Image($r('app.media.remote_ref_more')) + .width(23) + .height(7) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) + } + .width(44) + .height(44) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(22) + .shadow({ radius: 10, color: LINE, offsetY: 3 }) + .accessibilityText(RemoteI18n.t('sidebar.more')) + .bindPopup(this.showActionsMenu, { + builder: () => { + this.actionsMenu(); + }, + placement: Placement.BottomRight, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 8, + onStateChange: (event) => { + this.onActionsMenuStateChange(event.isVisible); + } + }) + .onClick(() => { + this.onOpenActions(); + }) + } else { + Blank().width(44).height(44) + } + } + + @Builder + private EmptyBuilder() { + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets index 24e7d708a6..486aad6a67 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 @@ -1,15 +1,22 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { MarkdownParser, ParsedMarkdownBlock, ParsedMarkdownInline, ParsedMarkdownListItem } from '../../services/MarkdownParser'; -import { CARD, INK, LINE, MUTED } from './Theme'; +import { + MarkdownParseCache, + ParsedMarkdownBlock, + ParsedMarkdownInline, + ParsedMarkdownListItem +} from '../../services/MarkdownParser'; +import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; @Component 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) => {}; build() { Column({ space: 5 }) { - ForEach(MarkdownParser.parse(this.text), (block: ParsedMarkdownBlock) => { + ForEach(this.parseCache.blocksFor(this.text), (block: ParsedMarkdownBlock) => { this.MarkdownBlockView(block) }, (block: ParsedMarkdownBlock) => block.id) } @@ -44,7 +51,7 @@ export struct MarkdownContent { } @Builder - InlineText(inlines: ParsedMarkdownInline[], fontSize: number, lineHeight: number, color: string, bold: boolean) { + InlineText(inlines: ParsedMarkdownInline[], fontSize: number, lineHeight: number, color: ResourceColor, bold: boolean) { Text() { ForEach(inlines, (inline: ParsedMarkdownInline) => { this.InlineSpan(inline, color, bold) @@ -57,7 +64,7 @@ export struct MarkdownContent { } @Builder - InlineSpan(inline: ParsedMarkdownInline, color: string, bold: boolean) { + InlineSpan(inline: ParsedMarkdownInline, color: ResourceColor, bold: boolean) { if (inline.type === 'strong') { Span(inline.text) .fontWeight(FontWeight.Bold) @@ -75,7 +82,11 @@ export struct MarkdownContent { } else if (inline.type === 'link') { Span(inline.text) .fontWeight(bold ? FontWeight.Bold : FontWeight.Regular) - .fontColor('#1D64C8') + .fontColor(FILE_LINK) + .decoration({ type: TextDecorationType.Underline, color: FILE_LINK }) + .onClick(() => { + this.onOpenLink(inline.url, inline.text); + }) } else { Span(inline.text) .fontWeight(bold ? FontWeight.Bold : FontWeight.Regular) @@ -120,7 +131,7 @@ export struct MarkdownContent { .scrollBar(BarState.Off) .width('100%') .padding({ left: 9, right: 9, top: 8, bottom: 8 }) - .backgroundColor('#F3F2EE') + .backgroundColor(SOFT) .borderRadius(12) .border({ width: 1, color: LINE }) } @@ -155,7 +166,7 @@ export struct MarkdownContent { } .width('100%') .padding({ left: 9, right: 9, top: 8, bottom: 8 }) - .backgroundColor('#F3F2EE') + .backgroundColor(SOFT) .borderRadius(12) .border({ width: 1, color: LINE }) } 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 96f47f6214..3c92d19156 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 @@ -1,11 +1,24 @@ +import { KeyboardAvoidMode } from '@kit.ArkUI'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, GREEN, INK, LINE, MUTED, RED, SOFT, SUBTLE } from './Theme'; +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 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 = { + version: 0, + models: [], + default_models: {} + }; + @Prop selectedModelId: string = ''; onClose: () => void = () => {}; onSaved: (apiUrl: string, modelName: string, hasApiKey: boolean) => void = () => {}; onTest: ( @@ -38,8 +51,12 @@ export struct ModelServiceSettingsPanel { @State isTesting: boolean = false; @State feedbackText: string = ''; @State feedbackIsError: boolean = false; + @State focusedFieldKind: string = ''; + @State showLocalEditor: boolean = false; aboutToAppear(): void { + this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); + this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); this.draftApiUrl = this.apiUrl; this.draftModelName = this.modelName; this.draftApiKey = ''; @@ -47,6 +64,13 @@ export struct ModelServiceSettingsPanel { this.isTesting = false; this.feedbackText = ''; this.feedbackIsError = false; + this.showLocalEditor = false; + } + + aboutToDisappear(): void { + this.clearFocusScrollTimer(); + this.clearBlurResetTimer(); + this.getUIContext().setKeyboardAvoidMode(this.previousKeyboardAvoidMode); } build() { @@ -63,70 +87,11 @@ export struct ModelServiceSettingsPanel { Column({ space: 0 }) { this.Header() - Scroll() { - Column({ space: 20 }) { - this.ConfigField( - RemoteI18n.t('settings.modelService.apiUrl'), - RemoteI18n.t('settings.modelService.apiUrlPlaceholder'), - 'url' - ) - this.ApiKeyField() - this.ConfigField( - RemoteI18n.t('settings.modelService.modelName'), - RemoteI18n.t('settings.modelService.modelPlaceholder'), - 'model' - ) - Row({ space: 12 }) { - Button(this.isTesting ? RemoteI18n.t('common.loading') : RemoteI18n.t('settings.modelService.testConnection')) - .layoutWeight(1) - .height(50) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .backgroundColor(this.isTesting ? MUTED : SOFT) - .borderRadius(25) - .enabled(!this.isSaving && !this.isTesting && this.hasTestableApiKey()) - .onClick(() => { - this.testConnection(); - }) - Button(this.isSaving ? RemoteI18n.t('common.loading') : RemoteI18n.t('common.save')) - .layoutWeight(1) - .height(50) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(CARD) - .backgroundColor(this.isSaving ? MUTED : ACCENT) - .borderRadius(25) - .enabled(!this.isSaving && !this.isTesting) - .onClick(() => { - this.save(); - }) - } - .width('100%') - - if (!this.hasTestableApiKey()) { - Text(RemoteI18n.t('settings.modelService.testNeedsKey')) - .fontSize(12) - .lineHeight(18) - .fontColor(SUBTLE) - .width('100%') - } - - if (this.feedbackText.length > 0) { - Text(this.feedbackText) - .fontSize(13) - .lineHeight(19) - .fontColor(this.feedbackIsError ? RED : GREEN) - .width('100%') - } - } - .width('100%') - .padding({ left: 22, right: 22, top: 18, bottom: 30 }) - .justifyContent(FlexAlign.Start) + if (this.showLocalEditor) { + this.LocalEditor() + } else { + this.ModelOverview() } - .width('100%') - .layoutWeight(1) - .scrollBar(BarState.Off) } .width('100%') .height('78%') @@ -140,16 +105,35 @@ export struct ModelServiceSettingsPanel { @Builder Header() { Row() { - Text(RemoteI18n.t('settings.modelService.title')) + if (this.showLocalEditor) { + Button() { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(20) + .fontColor([INK]) + } + .width(42) + .height(42) + .padding(0) + .type(ButtonType.Circle) + .backgroundColor(SOFT) + .margin({ right: 12 }) + .onClick(() => { + if (!this.isSaving && !this.isTesting) { + this.showLocalEditor = false; + this.resetEditorScroll(); + } + }) + } + Text(RemoteI18n.t(this.showLocalEditor ? + 'settings.modelService.localTitle' : 'settings.modelService.manageTitle')) .fontSize(21) .fontWeight(FontWeight.Bold) .fontColor(INK) Blank() Button() { - Image($r('app.media.settings_close_x')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(20) + .fontColor([INK]) } .width(42) .height(42) @@ -169,6 +153,242 @@ export struct ModelServiceSettingsPanel { .alignItems(VerticalAlign.Center) } + @Builder + ModelOverview() { + Scroll() { + Column({ space: 20 }) { + Column({ space: 8 }) { + this.SectionHeader(RemoteI18n.t('settings.modelService.currentModel'), '') + this.CurrentModelRow() + } + .width('100%') + + Column({ space: 8 }) { + this.SectionHeader(RemoteI18n.t('settings.modelService.accountModels'), '') + this.AccountModelsSummaryRow() + } + .width('100%') + + Column({ space: 8 }) { + this.SectionHeader(RemoteI18n.t('settings.modelService.localModel'), '') + this.LocalModelRow() + } + .width('100%') + } + .width('100%') + .padding({ left: 22, right: 22, top: 18, bottom: 30 }) + .justifyContent(FlexAlign.Start) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + + @Builder + LocalEditor() { + Scroll(this.contentScroller) { + Column({ space: 20 }) { + this.ConfigField( + RemoteI18n.t('settings.modelService.apiUrl'), + RemoteI18n.t('settings.modelService.apiUrlPlaceholder'), + 'url' + ) + this.ApiKeyField() + this.ConfigField( + RemoteI18n.t('settings.modelService.modelName'), + RemoteI18n.t('settings.modelService.modelPlaceholder'), + 'model' + ) + Row({ space: 12 }) { + Button(this.isTesting ? RemoteI18n.t('common.loading') : RemoteI18n.t('settings.modelService.testConnection')) + .layoutWeight(1) + .height(50) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .backgroundColor(this.isTesting ? MUTED : SOFT) + .borderRadius(25) + .enabled(!this.isSaving && !this.isTesting && this.hasTestableApiKey()) + .onClick(() => { + this.testConnection(); + }) + Button(this.isSaving ? RemoteI18n.t('common.loading') : RemoteI18n.t('common.save')) + .layoutWeight(1) + .height(50) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(this.isSaving ? MUTED : ACCENT) + .borderRadius(25) + .enabled(!this.isSaving && !this.isTesting) + .onClick(() => { + this.save(); + }) + } + .width('100%') + + if (!this.hasTestableApiKey()) { + Text(RemoteI18n.t('settings.modelService.testNeedsKey')) + .fontSize(12) + .lineHeight(18) + .fontColor(SUBTLE) + .width('100%') + } + + if (this.feedbackText.length > 0) { + Text(this.feedbackText) + .fontSize(13) + .lineHeight(19) + .fontColor(this.feedbackIsError ? RED : GREEN) + .width('100%') + } + } + .width('100%') + .padding({ + left: 22, + right: 22, + top: 18, + bottom: this.focusedFieldKind.length > 0 ? 360 : 30 + }) + .justifyContent(FlexAlign.Start) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + + @Builder + SectionHeader(title: string, detail: string) { + Row() { + Text(title) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Blank() + if (detail.length > 0) { + Text(detail) + .fontSize(12) + .fontColor(SUBTLE) + } + } + .width('100%') + .padding({ left: 4, right: 4 }) + } + + @Builder + CurrentModelRow() { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.checkmark_circle_fill')) + .fontSize(23) + .fontColor([this.currentModel() ? INK : MUTED]) + .width(28) + .height(28) + Column({ space: 3 }) { + Text(this.currentModelLabel()) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.currentModel()) { + Text(this.modelSourceLabel(this.currentModel())) + .fontSize(12) + .fontColor(MUTED) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height(68) + .padding({ left: 16, right: 16 }) + .backgroundColor(SOFT) + .borderRadius(8) + } + + @Builder + AccountModelsSummaryRow() { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.cloud')) + .fontSize(21) + .fontColor([this.accountModels().length > 0 ? MUTED : SUBTLE]) + .width(28) + .height(28) + Column({ space: 3 }) { + Text(RemoteI18n.t('settings.modelService.accountModelSummary')) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.accountModels().length > 0 ? + RemoteI18n.f('settings.modelService.syncedCount', String(this.accountModels().length)) : + RemoteI18n.t('settings.modelService.accountEmpty')) + .fontSize(12) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + if (this.isAccountModelSelected()) { + SymbolGlyph($r('sys.symbol.checkmark_circle_fill')) + .fontSize(18) + .fontColor([INK]) + } + } + .width('100%') + .height(62) + .padding({ left: 16, right: 16 }) + .backgroundColor(SOFT) + .borderRadius(8) + } + + @Builder + LocalModelRow() { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.wrench_and_screwdriver')) + .fontSize(21) + .fontColor([MUTED]) + .width(28) + .height(28) + Column({ space: 3 }) { + Text(this.hasCompleteLocalModel() ? this.modelName : RemoteI18n.t('settings.modelService.notConfigured')) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.hasCompleteLocalModel()) { + Text(RemoteI18n.t('settings.modelService.localSource')) + .fontSize(12) + .fontColor(MUTED) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + if (this.isLocalModelSelected()) { + SymbolGlyph($r('sys.symbol.checkmark_circle_fill')) + .fontSize(18) + .fontColor([INK]) + } + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + .width(18) + .height(18) + } + .width('100%') + .height(62) + .padding({ left: 16, right: 16 }) + .backgroundColor(SOFT) + .borderRadius(8) + .onClick(() => { + this.showLocalEditor = true; + this.resetEditorDraft(); + }) + } + @Builder ConfigField(label: string, placeholder: string, kind: string) { Column({ space: 8 }) { @@ -189,6 +409,12 @@ export struct ModelServiceSettingsPanel { .backgroundColor(SOFT) .borderRadius(8) .padding({ left: 14, right: 14 }) + .onFocus(() => { + this.scrollFocusedFieldIntoView(kind); + }) + .onBlur(() => { + this.scheduleFocusedFieldReset(); + }) .onChange((value: string) => { if (kind === 'url') { this.draftApiUrl = value; @@ -232,6 +458,12 @@ export struct ModelServiceSettingsPanel { .backgroundColor(SOFT) .borderRadius(8) .padding({ left: 14, right: 10 }) + .onFocus(() => { + this.scrollFocusedFieldIntoView('key'); + }) + .onBlur(() => { + this.scheduleFocusedFieldReset(); + }) .onChange((value: string) => { this.draftApiKey = value; if (value.length > 0) { @@ -258,6 +490,78 @@ export struct ModelServiceSettingsPanel { .alignItems(HorizontalAlign.Start) } + private accountModels(): RemoteModelConfig[] { + return this.modelCatalog.models.filter((model: RemoteModelConfig): boolean => { + return model.enabled && model.id.startsWith('cloud:'); + }); + } + + private currentModel(): RemoteModelConfig | undefined { + const candidates = [ + this.selectedModelId, + this.modelCatalog.session_model_id || '', + this.modelCatalog.default_models.primary || '' + ]; + for (let index = 0; index < candidates.length; index += 1) { + const modelId = candidates[index]; + const model = this.modelCatalog.models.find((item: RemoteModelConfig): boolean => { + return item.id === modelId && item.enabled; + }); + if (model) { + return model; + } + } + return undefined; + } + + private currentModelLabel(): string { + const model = this.currentModel(); + return model ? this.modelLabel(model) : RemoteI18n.t('settings.modelService.notConfigured'); + } + + private modelLabel(model: RemoteModelConfig): string { + return model.model_name || model.name || model.id; + } + + private modelSourceLabel(model?: RemoteModelConfig): string { + if (!model) { + return ''; + } + return model.id === GENERAL_CHAT_LOCAL_MODEL_ID ? + RemoteI18n.t('settings.modelService.localSource') : + RemoteI18n.t('settings.modelService.accountSource'); + } + + private isAccountModelSelected(): boolean { + const current = this.currentModel(); + return current !== undefined && current.id.startsWith('cloud:'); + } + + private isLocalModelSelected(): boolean { + return this.currentModel()?.id === GENERAL_CHAT_LOCAL_MODEL_ID; + } + + private hasCompleteLocalModel(): boolean { + return this.apiUrl.trim().length > 0 && this.modelName.trim().length > 0 && this.hasApiKey; + } + + private resetEditorDraft(): void { + this.draftApiUrl = this.apiUrl; + this.draftModelName = this.modelName; + this.draftApiKey = ''; + this.clearApiKey = false; + this.isTesting = false; + this.clearFeedback(); + this.resetEditorScroll(); + } + + private resetEditorScroll(): void { + this.clearFocusScrollTimer(); + this.clearBlurResetTimer(); + this.focusedFieldKind = ''; + this.contentScroller.scrollTo({ xOffset: 0, yOffset: 0, animation: false }); + } + private apiKeyPlaceholder(): string { return this.hasApiKey && !this.clearApiKey ? RemoteI18n.t('settings.modelService.apiKeyKeepPlaceholder') : @@ -287,7 +591,8 @@ export struct ModelServiceSettingsPanel { this.draftModelName.trim(), !this.clearApiKey && (this.draftApiKey.trim().length > 0 || this.hasApiKey) ); - this.onClose(); + this.showLocalEditor = false; + this.resetEditorScroll(); } private async testConnection(): Promise { @@ -320,4 +625,38 @@ export struct ModelServiceSettingsPanel { this.feedbackText = ''; this.feedbackIsError = false; } + + private scrollFocusedFieldIntoView(kind: string): void { + this.clearFocusScrollTimer(); + this.clearBlurResetTimer(); + this.focusedFieldKind = kind; + const yOffset = kind === 'key' ? 118 : (kind === 'model' ? 244 : 0); + this.focusScrollTimerId = setTimeout(() => { + this.contentScroller.scrollTo({ xOffset: 0, yOffset, animation: true }); + this.focusScrollTimerId = 0; + }, 280); + } + + private scheduleFocusedFieldReset(): void { + this.clearBlurResetTimer(); + this.blurResetTimerId = setTimeout(() => { + this.focusedFieldKind = ''; + this.contentScroller.scrollTo({ xOffset: 0, yOffset: 0, animation: true }); + this.blurResetTimerId = 0; + }, 180); + } + + private clearFocusScrollTimer(): void { + if (this.focusScrollTimerId !== 0) { + clearTimeout(this.focusScrollTimerId); + this.focusScrollTimerId = 0; + } + } + + private clearBlurResetTimer(): void { + if (this.blurResetTimerId !== 0) { + clearTimeout(this.blurResetTimerId); + this.blurResetTimerId = 0; + } + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets deleted file mode 100644 index a1ae208a45..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets +++ /dev/null @@ -1,320 +0,0 @@ -import { AssistantEntry, RecentWorkspaceEntry } from '../../model/RemoteModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, GREEN, INK, LINE, MUTED, RED, SUBTLE } from './Theme'; - -@ComponentV2 -export struct RemoteActionsSheet { - @Param desktopName: string = ''; - @Param workspaceName: string = 'BitFun'; - @Param workspacePath: string = ''; - @Param assistantId: string = ''; - @Param connectionState: string = 'idle'; - @Param isBusy: boolean = false; - @Param sortMode: string = 'project'; - @Param recentWorkspaces: RecentWorkspaceEntry[] = []; - @Param assistants: AssistantEntry[] = []; - @Param showWorkspacePicker: boolean = false; - @Param showAssistantPicker: boolean = false; - @Local selectedSortMode: string = 'project'; - @Event onClose: () => void = () => {}; - @Event onDismiss: () => void = () => {}; - @Event onRefresh: () => void = () => {}; - @Event onShowWorkspaces: () => void = () => {}; - @Event onShowAssistants: () => void = () => {}; - @Event onSelectWorkspace: (path: string) => void = (_path: string) => {}; - @Event onSelectAssistant: (path: string) => void = (_path: string) => {}; - @Event onCancelWorkspacePicker: () => void = () => {}; - @Event onCancelAssistantPicker: () => void = () => {}; - @Event onReconnect: () => void = () => {}; - @Event onDisconnect: () => void = () => {}; - @Event onClearPairing: () => void = () => {}; - @Event onSortModeChange: (mode: string) => void = (_mode: string) => {}; - @Event onAddConnection: () => void = () => {}; - @Event onOpenSettings: () => void = () => {}; - - aboutToAppear(): void { - this.selectedSortMode = this.sortMode; - } - - build() { - Column() { - Scroll() { - Column({ space: 0 }) { - if (this.showWorkspacePicker) { - this.WorkspacePicker() - } else if (this.showAssistantPicker) { - this.AssistantPicker() - } else { - this.RemoteActionRows() - } - } - .width('100%') - .padding({ left: 16, right: 16, top: 14, bottom: 14 }) - } - .layoutWeight(1) - .width('100%') - .scrollBar(BarState.Off) - } - .width('100%') - .height('100%') - .backgroundColor(CARD) - .border({ width: 1, color: '#0D000000' }) - .borderRadius(20) - .shadow({ radius: 20, color: '#1A000000', offsetY: 8 }) - } - - @Builder - private RemoteActionRows() { - Column({ space: 0 }) { - this.SectionTitle(RemoteI18n.t('remote.menu.organize')) - this.IconRow('remote_actions_folder', RemoteI18n.t('remote.menu.byProject'), 'project', () => { - this.selectSortMode('project'); - }) - this.IconRow('remote_actions_clock', RemoteI18n.t('remote.menu.byTime'), 'time', () => { - this.selectSortMode('time'); - }) - this.IconRow('remote_actions_chat', RemoteI18n.t('remote.menu.chatFirst'), 'chat', () => { - this.selectSortMode('chat'); - }) - Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) - this.SectionTitle(RemoteI18n.t('remote.menu.manage')) - this.IconRow('remote_actions_cloud', RemoteI18n.t('remote.menu.cloudTasks'), '', () => { - this.getUIContext().getPromptAction().showToast({ - message: RemoteI18n.t('remote.menu.comingSoon'), - duration: 1800 - }); - }) - this.IconRow('remote_actions_link', RemoteI18n.t('remote.menu.addConnection'), '', () => { - this.onAddConnection(); - this.onDismiss(); - }) - this.IconRow('remote_actions_settings', RemoteI18n.t('remote.menu.settings'), '', () => { - this.onDismiss(); - this.onOpenSettings(); - }) - } - .width('100%') - .backgroundColor('#00000000') - } - - @Builder - private SectionTitle(title: string) { - Text(title) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor('#8E8E93') - .width('100%') - .height(28) - .padding({ left: 8 }) - .textAlign(TextAlign.Start) - } - - @Builder - private IconRow(icon: string, label: string, sortMode: string, action: () => void) { - Row({ space: 10 }) { - if (sortMode.length > 0 && this.selectedSortMode === sortMode) { - Image($r('app.media.remote_actions_check')) - .width(20) - .height(20) - .objectFit(ImageFit.Contain) - } else { - Blank().width(20) - } - this.ActionIcon(icon) - Text(label) - .fontSize(15) - .fontColor(INK) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - .height(48) - .padding({ left: 8, right: 8 }) - .borderRadius(10) - .backgroundColor(sortMode.length > 0 && this.selectedSortMode === sortMode ? '#F3F3F3' : '#00000000') - .onClick(action) - } - - @Builder - private ActionIcon(icon: string) { - if (icon === 'remote_actions_folder') { - Image($r('app.media.remote_actions_folder')).width(23).height(23).objectFit(ImageFit.Contain) - } else if (icon === 'remote_actions_clock') { - Image($r('app.media.remote_actions_clock')).width(23).height(23).objectFit(ImageFit.Contain) - } else if (icon === 'remote_actions_chat') { - Image($r('app.media.remote_actions_chat')).width(23).height(23).objectFit(ImageFit.Contain) - } else if (icon === 'remote_actions_cloud') { - Image($r('app.media.remote_actions_cloud')).width(23).height(23).objectFit(ImageFit.Contain) - } else if (icon === 'remote_actions_link') { - Image($r('app.media.remote_actions_link')).width(23).height(23).objectFit(ImageFit.Contain) - } else { - Image($r('app.media.remote_actions_settings')).width(23).height(23).objectFit(ImageFit.Contain) - } - } - - @Builder - private InfoRow(label: string, value: string) { - Row({ space: 14 }) { - Text(label) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - Text(value) - .fontSize(14) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .constraintSize({ maxWidth: 180 }) - } - .width('100%') - .height(54) - .padding({ left: 18, right: 18 }) - .border({ width: { bottom: 1 }, color: LINE }) - } - - @Builder - private ActionRow(label: string, action: () => void, destructive: boolean = false) { - Row() { - Text(label) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(destructive ? RED : INK) - Blank() - Text('›') - .fontSize(21) - .fontColor(SUBTLE) - } - .width('100%') - .height(54) - .padding({ left: 18, right: 18 }) - .border({ width: { bottom: 1 }, color: LINE }) - .onClick(action) - } - - @Builder - private WorkspacePicker() { - Column({ space: 0 }) { - this.PickerHeader(RemoteI18n.t('home.recentWorkspaces'), () => { - this.onCancelWorkspacePicker(); - }) - if (this.recentWorkspaces.length === 0) { - this.PickerEmpty(this.isBusy ? RemoteI18n.t('common.loading') : RemoteI18n.t('home.noRecentWorkspaces')) - } else { - ForEach(this.recentWorkspaces, (item: RecentWorkspaceEntry) => { - this.PickerRow(item.name || this.basename(item.path), item.path, item.path === this.workspacePath, () => { - if (item.path !== this.workspacePath) { - this.onSelectWorkspace(item.path); - } - this.onDismiss(); - }) - }, (item: RecentWorkspaceEntry) => item.path) - } - } - .width('100%') - .backgroundColor(CARD) - .borderRadius(8) - } - - @Builder - private AssistantPicker() { - Column({ space: 0 }) { - this.PickerHeader(RemoteI18n.t('home.assistantWorkspaces'), () => { - this.onCancelAssistantPicker(); - }) - if (this.assistants.length === 0) { - this.PickerEmpty(this.isBusy ? RemoteI18n.t('common.loading') : RemoteI18n.t('home.noAssistants')) - } else { - ForEach(this.assistants, (item: AssistantEntry) => { - this.PickerRow(item.name || this.basename(item.path), item.path, item.assistant_id === this.assistantId, () => { - this.onSelectAssistant(item.path); - this.onDismiss(); - }) - }, (item: AssistantEntry) => item.path) - } - } - .width('100%') - .backgroundColor(CARD) - .borderRadius(8) - } - - @Builder - private PickerHeader(title: string, action: () => void) { - Row() { - Text('‹') - .fontSize(28) - .fontColor(INK) - Text(title) - .fontSize(17) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .margin({ left: 8 }) - } - .width('100%') - .height(54) - .padding({ left: 16, right: 16 }) - .border({ width: { bottom: 1 }, color: LINE }) - .onClick(action) - } - - @Builder - private PickerRow(title: string, detail: string, selected: boolean, action: () => void) { - Row({ space: 12 }) { - Column({ space: 3 }) { - Text(title) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(detail) - .fontSize(12) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - Text(selected ? '✓' : '›') - .fontSize(selected ? 16 : 20) - .fontColor(selected ? GREEN : SUBTLE) - } - .width('100%') - .height(62) - .padding({ left: 18, right: 18 }) - .border({ width: { bottom: 1 }, color: LINE }) - .onClick(action) - } - - @Builder - private PickerEmpty(text: string) { - Text(text) - .width('100%') - .padding({ left: 18, right: 18, top: 22, bottom: 22 }) - .fontSize(14) - .lineHeight(20) - .fontColor(MUTED) - } - - private workspaceTitle(): string { - return this.workspaceName || 'BitFun'; - } - - private assistantTitle(): string { - return this.assistantId || RemoteI18n.t('remote.noAssistant'); - } - - private basename(path: string): string { - if (!path) { - return 'Workspace'; - } - const parts = path.replace(/\\/g, '/').split('/'); - return parts[parts.length - 1] || 'Workspace'; - } - - private selectSortMode(mode: string): void { - this.selectedSortMode = mode; - this.onSortModeChange(mode); - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteBottomBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteBottomBar.ets deleted file mode 100644 index d04e29ba20..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteBottomBar.ets +++ /dev/null @@ -1,71 +0,0 @@ -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, SUBTLE } from './Theme'; - -@ComponentV2 -export struct RemoteBottomBar { - @Param query: string = ''; - @Param isBusy: boolean = false; - @Event onQueryChange: (value: string) => void = (_value: string) => {}; - @Event onSearch: () => void = () => {}; - @Event onCreate: () => void = () => {}; - - build() { - Row({ space: 12 }) { - Row({ space: 8 }) { - Image($r('app.media.remote_ref_search_reference')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) - TextInput({ placeholder: RemoteI18n.t('remote.searchChats'), text: this.query }) - .layoutWeight(1) - .height(48) - .fontSize(16) - .fontColor(INK) - .placeholderColor(SUBTLE) - .padding({ left: 0, right: 4 }) - .backgroundColor('#00000000') - .onChange((value: string) => { - this.onQueryChange(value); - }) - } - .layoutWeight(1) - .height(48) - .padding({ left: 6, right: 10 }) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 20, color: '#12000000', offsetY: 7 }) - .onClick(() => { - if (this.query.trim().length > 0) { - this.onSearch(); - } - }) - - Button() { - Row({ space: 9 }) { - Image($r('app.media.remote_ref_new_chat')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) - Text(RemoteI18n.t('remote.newChat')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(CARD) - } - } - .width(118) - .height(48) - .padding(0) - .backgroundColor(ACCENT) - .borderRadius(24) - .shadow({ radius: 20, color: '#18000000', offsetY: 7 }) - .enabled(!this.isBusy) - .onClick(() => { - this.onCreate(); - }) - } - .width('91%') - .alignSelf(ItemAlign.Center) - .alignItems(VerticalAlign.Center) - .translate({ y: 15 }) - } -} 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 77866b0edd..67b5b38130 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 { ConversationUiModel, ConversationUiModelCatalog, ConversationUiSession } from './ConversationUiModels'; -import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PAGE_BG } from './Theme'; +import { ConversationUiSession } from './ConversationUiModels'; +import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION_TEXT, SOFT } from './Theme'; +import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 export struct RemoteChatHeader { @@ -12,21 +13,16 @@ export struct RemoteChatHeader { }; @Param workspaceBranch: string = ''; @Param desktopName: string = ''; - @Param canStop: boolean = false; - @Param modelCatalog: ConversationUiModelCatalog = { - version: 0, - models: [], - default_models: {} - }; - @Param selectedModelId: string = ''; @Param showBackButton: boolean = true; + @Param showSidebarRestoreButton: boolean = false; + @Param showActionsMenu: boolean = false; + @BuilderParam actionsMenu: () => void = this.EmptyBuilder; @Event onBack: () => void = () => {}; - @Event onNewSession: () => void = () => {}; - @Event onStop: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; + @Event onOpenActions: () => void = () => {}; + @Event onActionsMenuStateChange: (visible: boolean) => void = (_visible: boolean) => {}; @Event onRenameSession: (title: string) => void = (_title: string) => {}; - @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; @Local showTitleEditor: boolean = false; - @Local showModelSelector: boolean = false; @Local renameTitle: string = ''; build() { @@ -35,9 +31,6 @@ export struct RemoteChatHeader { if (this.showTitleEditor) { this.TitleEditor() } - if (this.showModelSelector) { - this.ModelSelector() - } } .width('100%') .backgroundColor(PAGE_BG) @@ -45,29 +38,14 @@ export struct RemoteChatHeader { @Builder HeaderRow() { - Row({ space: 12 }) { - if (this.showBackButton) { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_back')) - .width(16) - .height(25) - .objectFit(ImageFit.Contain) - } - .width(48) - .height(48) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) - .onClick(() => { - this.onBack(); - }) - } + Row({ space: 8 }) { + this.LeadingControl() Column({ space: 3 }) { Text(this.activeSession.title || RemoteI18n.t('chat.remoteSession')) .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor(INK) - .textAlign(TextAlign.Start) + .textAlign(TextAlign.Center) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .onClick(() => { @@ -78,45 +56,16 @@ export struct RemoteChatHeader { Text(this.headerContextTitle()) .fontSize(14) .fontColor(MUTED) - .textAlign(TextAlign.Start) + .textAlign(TextAlign.Center) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('100%') - .justifyContent(FlexAlign.Start) + .justifyContent(FlexAlign.Center) } .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - Row() { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_edit')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) - } - .width(44) - .height(48) - .onClick(() => { - this.onNewSession(); - }) - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_more')) - .width(25) - .height(8) - .objectFit(ImageFit.Contain) - } - .width(44) - .height(48) - .onClick(() => { - this.showModelSelector = !this.showModelSelector; - }) - } - .width(90) - .height(48) - .padding({ left: 1, right: 1 }) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) + .alignItems(HorizontalAlign.Center) + this.ActionsControl() } .width('100%') .alignItems(VerticalAlign.Center) @@ -124,6 +73,75 @@ export struct RemoteChatHeader { .backgroundColor(PAGE_BG) } + @Builder + private LeadingControl() { + if (this.showSidebarRestoreButton) { + SidebarToggleButton({ + restore: true, + controlSize: 48, + onToggle: this.onRestoreSidebar + }) + } else if (this.showBackButton) { + Stack({ alignContent: Alignment.Center }) { + Image($r('app.media.remote_ref_back')) + .width(15) + .height(23) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) + } + .width(44) + .height(44) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(22) + .shadow({ radius: 10, color: LINE, offsetY: 3 }) + .accessibilityText(RemoteI18n.t('common.back')) + .onClick(() => { + this.onBack(); + }) + } else { + Blank().width(44).height(44) + } + } + + @Builder + private ActionsControl() { + Stack({ alignContent: Alignment.Center }) { + Image($r('app.media.remote_ref_more')) + .width(23) + .height(7) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) + } + .width(44) + .height(44) + .backgroundColor(CARD) + .borderRadius(22) + .border({ width: 1, color: LINE }) + .shadow({ radius: 10, color: LINE, offsetY: 3 }) + .accessibilityText(RemoteI18n.t('sidebar.more')) + .bindPopup(this.showActionsMenu, { + builder: () => { + this.actionsMenu(); + }, + placement: Placement.BottomRight, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 8, + onStateChange: (event) => { + this.onActionsMenuStateChange(event.isVisible); + } + }) + .onClick(() => { + this.showTitleEditor = false; + this.onOpenActions(); + }) + } + @Builder TitleEditor() { Row({ space: 8 }) { @@ -143,9 +161,9 @@ export struct RemoteChatHeader { .width(52) .height(42) .fontSize(13) - .fontColor(CARD) + .fontColor(PRIMARY_ACTION_TEXT) .textAlign(TextAlign.Center) - .backgroundColor(this.renameTitle.trim().length > 0 ? ACCENT : '#EDEBE6') + .backgroundColor(this.renameTitle.trim().length > 0 ? ACCENT : SOFT) .borderRadius(14) .onClick(() => { if (this.renameTitle.trim().length > 0) { @@ -159,7 +177,7 @@ export struct RemoteChatHeader { .fontSize(13) .fontColor(INK) .textAlign(TextAlign.Center) - .backgroundColor('#F0EFEB') + .backgroundColor(SOFT) .borderRadius(14) .onClick(() => { this.showTitleEditor = false; @@ -170,96 +188,6 @@ export struct RemoteChatHeader { .backgroundColor(PAGE_BG) } - @Builder - ModelSelector() { - Column({ space: 8 }) { - Row() { - Text(`${RemoteI18n.t('chat.selectModel')} · ${this.displaySelectedModel()}`) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - Text(RemoteI18n.t('common.close')) - .fontSize(12) - .fontColor(MUTED) - .onClick(() => { - this.showModelSelector = false; - }) - } - .width('100%') - if (this.canStop) { - Row() { - Text(RemoteI18n.t('chat.stop')) - .fontSize(14) - .fontColor(INK) - Blank() - Text('■') - .fontSize(12) - .fontColor(INK) - } - .width('100%') - .height(42) - .padding({ left: 12, right: 12 }) - .backgroundColor('#F0EFEB') - .borderRadius(12) - .onClick(() => { - this.showModelSelector = false; - this.onStop(); - }) - } - List({ space: 8 }) { - ForEach(this.enabledModels(), (model: ConversationUiModel) => { - ListItem() { - this.ModelRow(model) - } - }, (model: ConversationUiModel) => model.id) - } - .width('100%') - .height(this.modelListHeight()) - .scrollBar(BarState.Auto) - .edgeEffect(EdgeEffect.Spring) - .divider(null) - } - .width('100%') - .padding({ left: 18, right: 18, top: 10, bottom: 12 }) - .backgroundColor(PAGE_BG) - } - - @Builder - ModelRow(model: ConversationUiModel) { - Row({ space: 10 }) { - Text(this.selectedModelId === model.id ? '●' : '○') - .fontSize(12) - .fontColor(this.selectedModelId === model.id ? GREEN : MUTED) - .width(18) - .textAlign(TextAlign.Center) - Column({ space: 2 }) { - Text(this.primaryModelLabel(model)) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.secondaryModelLabel(model)) - .fontSize(11) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .padding({ left: 12, right: 12, top: 10, bottom: 10 }) - .backgroundColor(this.selectedModelId === model.id ? '#F0EFEB' : CARD) - .borderRadius(14) - .border({ width: 1, color: this.selectedModelId === model.id ? '#D8D4CA' : LINE }) - .onClick(() => { - this.showModelSelector = false; - this.onSelectModel(model.id); - }) - } - private headerContextTitle(): string { if (this.desktopName.length > 0) { return this.desktopName; @@ -268,86 +196,8 @@ export struct RemoteChatHeader { return this.workspaceBranch.length > 0 ? `${brand} · ${this.workspaceBranch}` : brand; } - private enabledModels(): ConversationUiModel[] { - return this.modelCatalog.models.filter((model: ConversationUiModel) => model.enabled); - } - - private modelListHeight(): number { - const rowHeight = 58; - const rowGap = 8; - const visibleRows = Math.min(this.enabledModels().length, 5); - if (visibleRows <= 0) { - return 0; - } - return visibleRows * rowHeight + Math.max(0, visibleRows - 1) * rowGap; - } - - private displaySelectedModel(): string { - const selected = this.selectedModel(); - if (selected) { - return this.primaryModelLabel(selected); - } - return RemoteI18n.t('chat.model'); - } - - private selectedModel(): ConversationUiModel | undefined { - const modelId = this.selectedModelId || this.modelCatalog.session_model_id || this.modelCatalog.default_models.primary || ''; - if (modelId.length === 0) { - return undefined; - } - return this.modelCatalog.models.find((model: ConversationUiModel) => model.id === modelId); - } - - private primaryModelLabel(model: ConversationUiModel): string { - const modelName = this.cleanModelLabel(model.model_name || ''); - if (this.isSpecificModelLabel(modelName)) { - return modelName; - } - const name = this.cleanModelLabel(model.name || ''); - if (this.isSpecificModelLabel(name)) { - return name; - } - const id = this.cleanModelLabel(model.id || ''); - if (id.length > 0) { - return id; - } - return RemoteI18n.t('chat.model'); - } - - private secondaryModelLabel(model: ConversationUiModel): string { - const provider = this.cleanModelLabel(model.provider || ''); - const name = this.cleanModelLabel(model.name || ''); - const primary = this.primaryModelLabel(model); - if (provider.length > 0 && name.length > 0 && name !== primary && name !== provider) { - return `${provider} · ${name}`; - } - if (provider.length > 0 && provider !== primary) { - return provider; - } - if (name.length > 0 && name !== primary) { - return name; - } - return model.id || primary; - } - - private cleanModelLabel(value: string): string { - const trimmed = (value || '').trim(); - if (trimmed.length === 0) { - return ''; - } - const withoutScheme = trimmed.replace(/^openbitfun[:/_-]+/i, '').replace(/^anthropic[:/_-]+/i, ''); - const parts = withoutScheme.split(/[/:]/).filter((part: string) => part.length > 0); - return parts.length > 0 ? parts[parts.length - 1] : withoutScheme; + @Builder + private EmptyBuilder() { } - private isSpecificModelLabel(label: string): boolean { - const normalized = label.toLowerCase(); - return label.length > 0 && - normalized !== 'openbitfun' && - normalized !== 'anthropic' && - normalized !== 'openai' && - normalized !== 'google' && - normalized !== 'azure' && - normalized !== 'bitfun'; - } } 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 3a1cbdf6c7..9ed4245319 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 @@ -1,13 +1,10 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, GREEN, INK, LINE, MUTED } from './Theme'; +import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { RemotePermissionMode } from '../../model/RemoteModels'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; import { BitFunAccountLoginPage } from './BitFunAccountLoginPage'; -const REMOTE_SETTINGS_BG: string = '#F4F4F7'; -const REMOTE_SETTINGS_BLUE: string = '#0A84FF'; - @Component export struct RemoteControlSettingsSheet { @Prop desktopName: string = ''; @@ -74,8 +71,8 @@ export struct RemoteControlSettingsSheet { } .width('100%') .height('100%') - .backgroundColor(REMOTE_SETTINGS_BG) - .borderRadius(this.openAccountOnAppear ? 0 : { topLeft: 34, topRight: 34 }) + .backgroundColor(PAGE_BG) + .borderRadius({ topLeft: 34, topRight: 34 }) } @Builder @@ -117,10 +114,11 @@ export struct RemoteControlSettingsSheet { .fontWeight(FontWeight.Medium) .fontColor(INK) .layoutWeight(1) - Image($r('app.media.settings_chevron_right')) - .width(10) - .height(14) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + .width(18) + .height(18) .opacity(0.52) } .width('100%') @@ -144,7 +142,7 @@ export struct RemoteControlSettingsSheet { Text(RemoteI18n.t('remote.settings.currentControl')) .fontSize(18) .fontWeight(FontWeight.Bold) - .fontColor('#929298') + .fontColor(MUTED) } .width('100%') .height(42) @@ -156,15 +154,16 @@ export struct RemoteControlSettingsSheet { private CurrentControlCard() { Column() { Row({ space: 14 }) { - Image($r('app.media.remote_ref_device')) + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(23) + .fontColor([MUTED]) .width(28) - .height(24) - .objectFit(ImageFit.Contain) + .height(26) .opacity(0.58) Column({ space: 2 }) { Text(RemoteI18n.t('remote.settings.desktopProduct')) .fontSize(14) - .fontColor('#929298') + .fontColor(MUTED) Text(this.connectionTitle()) .fontSize(18) .fontWeight(FontWeight.Medium) @@ -173,7 +172,7 @@ export struct RemoteControlSettingsSheet { .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.connectionDetail()) .fontSize(14) - .fontColor('#929298') + .fontColor(MUTED) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } @@ -192,20 +191,21 @@ export struct RemoteControlSettingsSheet { .margin({ left: 18, right: 18 }) Row({ space: 10 }) { - Image($r('app.media.remote_actions_link')) + SymbolGlyph($r('sys.symbol.link')) + .fontSize(18) + .fontColor([MUTED]) .width(20) .height(20) - .objectFit(ImageFit.Contain) .opacity(0.58) Text(RemoteI18n.t('remote.settings.connectionSource')) .fontSize(14) - .fontColor('#929298') + .fontColor(MUTED) Blank() Text(this.connectionSourceLabel()) .fontSize(13) - .fontColor(REMOTE_SETTINGS_BLUE) + .fontColor(INK) .padding({ left: 10, right: 10, top: 5, bottom: 5 }) - .backgroundColor('#EDF5FF') + .backgroundColor(SOFT) .borderRadius(12) } .width('100%') @@ -223,17 +223,17 @@ export struct RemoteControlSettingsSheet { if (this.isConnectedOrConnecting()) { Text(RemoteI18n.t('remote.settings.disconnect')) .fontSize(14) - .fontColor(REMOTE_SETTINGS_BLUE) + .fontColor(INK) .padding({ left: 10, right: 10, top: 7, bottom: 7 }) - .backgroundColor('#EDF5FF') + .backgroundColor(SOFT) .borderRadius(14) .onClick(() => this.onDisconnect()) } else if (this.hasConnectionProjection()) { Text(RemoteI18n.t('remote.settings.reconnect')) .fontSize(14) - .fontColor(REMOTE_SETTINGS_BLUE) + .fontColor(INK) .padding({ left: 10, right: 10, top: 7, bottom: 7 }) - .backgroundColor('#EDF5FF') + .backgroundColor(SOFT) .borderRadius(14) .onClick(() => this.onReconnect()) } @@ -245,15 +245,16 @@ export struct RemoteControlSettingsSheet { Text(RemoteI18n.t('remote.settings.otherConnectionMethods')) .fontSize(18) .fontWeight(FontWeight.Bold) - .fontColor('#929298') + .fontColor(MUTED) .width('100%') .height(42) .padding({ left: 18, right: 16 }) Row({ space: 12 }) { - Image($r('app.media.remote_actions_link')) + SymbolGlyph($r('sys.symbol.link')) + .fontSize(21) + .fontColor([MUTED]) .width(24) .height(24) - .objectFit(ImageFit.Contain) .opacity(0.62) Column({ space: 2 }) { Text(RemoteI18n.t('remote.settings.qrConnect')) @@ -262,15 +263,16 @@ export struct RemoteControlSettingsSheet { .fontColor(INK) Text(RemoteI18n.t('remote.settings.qrConnectBody')) .fontSize(13) - .fontColor('#929298') + .fontColor(MUTED) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1) - Image($r('app.media.settings_chevron_right')) - .width(10) - .height(14) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + .width(18) + .height(18) .opacity(0.52) } .width('100%') @@ -290,12 +292,12 @@ export struct RemoteControlSettingsSheet { Text(RemoteI18n.t('remote.permissions.title')) .fontSize(18) .fontWeight(FontWeight.Bold) - .fontColor('#929298') + .fontColor(MUTED) Blank() if (this.canManagePermissions()) { Text(RemoteI18n.t('common.refresh')) .fontSize(14) - .fontColor(this.permissionModeBusy ? MUTED : REMOTE_SETTINGS_BLUE) + .fontColor(this.permissionModeBusy ? MUTED : INK) .onClick(() => this.refreshPermissionMode()) } } @@ -337,7 +339,7 @@ export struct RemoteControlSettingsSheet { .width('100%') .height(28) .fontSize(12) - .fontColor(this.permissionModeError.length > 0 ? '#D04A3A' : MUTED) + .fontColor(this.permissionModeError.length > 0 ? RED : MUTED) .padding({ left: 18, right: 18, top: 4 }) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) @@ -356,10 +358,11 @@ export struct RemoteControlSettingsSheet { Row({ space: 12 }) { Stack({ alignContent: Alignment.Center }) { if (this.permissionModeLoaded && this.permissionMode === mode) { - Image($r('app.media.remote_actions_check')) + SymbolGlyph($r('sys.symbol.checkmark_circle_fill')) + .fontSize(19) + .fontColor([INK]) .width(20) .height(20) - .objectFit(ImageFit.Contain) } } .width(22) @@ -394,7 +397,7 @@ export struct RemoteControlSettingsSheet { .width('100%') .fontSize(15) .fontWeight(FontWeight.Bold) - .fontColor('#B9382D') + .fontColor(RED) Text(RemoteI18n.t('remote.permissions.fullAccessWarningBody')) .width('100%') .fontSize(13) @@ -406,15 +409,15 @@ export struct RemoteControlSettingsSheet { .height(42) .fontSize(14) .fontColor(INK) - .backgroundColor('#F0EFEC') + .backgroundColor(SOFT) .borderRadius(21) .onClick(() => { this.confirmFullAccess = false; }) Button(RemoteI18n.t('remote.permissions.confirmFullAccess')) .layoutWeight(1) .height(42) .fontSize(14) - .fontColor(CARD) - .backgroundColor('#C63E3E') + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(RED) .borderRadius(21) .onClick(() => this.applyPermissionMode('full_access')) } @@ -422,8 +425,8 @@ export struct RemoteControlSettingsSheet { } .width('100%') .padding({ left: 16, right: 16, top: 14, bottom: 16 }) - .backgroundColor('#FFF3F1') - .border({ width: 1, color: '#F0B3AA' }) + .backgroundColor(CARD) + .border({ width: 1, color: RED }) .borderRadius(18) .margin({ left: 12, right: 12, bottom: 14 }) } @@ -447,10 +450,11 @@ export struct RemoteControlSettingsSheet { Column({ space: 0 }) { Row() { Button() { - Image($r('app.media.remote_ref_back')) + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(23) + .fontColor([INK]) .width(26) .height(26) - .objectFit(ImageFit.Contain) } .width(44) .height(44) @@ -503,7 +507,7 @@ export struct RemoteControlSettingsSheet { Text(RemoteI18n.t('remote.settings.profileDetails')) .fontSize(18) .fontWeight(FontWeight.Bold) - .fontColor('#929298') + .fontColor(MUTED) .width('100%') .margin({ left: 18, bottom: 8 }) @@ -550,15 +554,17 @@ export struct RemoteControlSettingsSheet { .fontColor(MUTED) .width('100%') - Button(RemoteI18n.t('remote.settings.accountSync')) + Text(RemoteI18n.t('remote.settings.accountSync')) .height(42) .width('100%') .fontSize(15) - .fontColor(REMOTE_SETTINGS_BLUE) - .backgroundColor('#E8F2FF') + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center) .borderRadius(21) - .enabled(!this.cloudSyncBusy) + .opacity(this.cloudSyncBusy ? 0.52 : 1) .onClick(async () => { + if (this.cloudSyncBusy) return; this.cloudSyncBusy = true; this.cloudSyncStatus = ''; try { @@ -584,15 +590,16 @@ export struct RemoteControlSettingsSheet { @Builder private LogoutAction() { Row({ space: 14 }) { - Image($r('app.media.settings_logout_arrow')) + SymbolGlyph($r('sys.symbol.arrow_right_and_square')) + .fontSize(22) + .fontColor([RED]) .width(24) .height(24) - .objectFit(ImageFit.Contain) Text(this.logoutBusy ? RemoteI18n.t('remote.settings.accountLoggingOut') : RemoteI18n.t('remote.settings.accountLogout')) .fontSize(17) .fontWeight(FontWeight.Medium) - .fontColor('#E11D17') + .fontColor(RED) } .width('100%') .height(62) @@ -624,7 +631,7 @@ export struct RemoteControlSettingsSheet { Blank() Text(this.accountDevicesBusy ? RemoteI18n.t('remote.settings.deviceLoading') : RemoteI18n.t('remote.settings.deviceRefresh')) - .fontSize(13).fontColor(this.accountDevicesBusy ? MUTED : REMOTE_SETTINGS_BLUE) + .fontSize(13).fontColor(this.accountDevicesBusy ? MUTED : INK) .onClick(async () => { await this.refreshAccountDevices(); }) @@ -652,8 +659,8 @@ export struct RemoteControlSettingsSheet { @Builder private AccountDeviceRow(device: CloudAccountDevice) { Row({ space: 12 }) { - Image($r('app.media.remote_ref_device')) - .width(24).height(22).objectFit(ImageFit.Contain).opacity(0.58) + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(20).fontColor([MUTED]).width(24).height(22).opacity(0.72) Column({ space: 2 }) { Text(device.deviceName || device.deviceId) .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) @@ -719,10 +726,11 @@ export struct RemoteControlSettingsSheet { @Builder private CloseButton() { Button() { - Image($r('app.media.settings_close_x')) - .width(28) - .height(28) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(21) + .fontColor([INK]) + .width(24) + .height(24) } .width(50) .height(50) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets index a278d258af..a11ab7ee5e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets @@ -3,18 +3,35 @@ import { RecentWorkspaceEntry } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; -import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, SOFT, SUBTLE } from './Theme'; +import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, RED, SOFT, SUBTLE } from './Theme'; +import { ComposerBar, ComposerPresentation } from './ComposerBar'; +import { ConversationUiModelCatalog } from './ConversationUiModels'; +import { REMOTE_CREATE_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; +import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 export struct RemoteCreateSessionView { @Param state: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Param presentation: ComposerPresentation = ComposerPresentation.Create; + @Param showSidebarRestoreButton: boolean = false; + @Param isVoiceListening: boolean = false; + @Param modelCatalog: ConversationUiModelCatalog = { + version: 0, + models: [], + default_models: {} + }; + @Param selectedModelId: string = ''; @Event onBack: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; @Event onToggleDeviceMenu: () => void = () => {}; @Event onToggleWorkspaceMenu: () => void = () => {}; @Event onSelectDevice: (device: CloudAccountDevice) => void = (_device: CloudAccountDevice) => {}; @Event onSelectWorkspace: (workspace?: RecentWorkspaceEntry) => void = (_workspace?: RecentWorkspaceEntry) => {}; @Event onDraftChange: (value: string) => void = (_value: string) => {}; @Event onSend: () => void = () => {}; + @Event onVoiceInput: () => void = () => {}; + @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; + @Local showSelectorSheet: boolean = false; private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; aboutToAppear(): void { @@ -29,85 +46,172 @@ export struct RemoteCreateSessionView { build() { Column() { - Column() { - Row() { - Button() { - Image($r('app.media.remote_ref_back')) - .width(16) - .height(25) - .objectFit(ImageFit.Contain) - } - .width(48) - .height(48) - .padding(0) - .type(ButtonType.Circle) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) - .onClick(() => this.onBack()) - } - .width('100%') - .height(78) - .padding({ left: 18, top: 14 }) - .alignItems(VerticalAlign.Top) - Column() - .width('100%') + if (this.presentation === ComposerPresentation.Floating) { + this.Header() + Blank() .layoutWeight(1) .onClick(() => { this.state.closeMenu(); this.keepComposerFocused(); }) + } else { + this.CompactNavigationSpace() + this.ContextControls() + this.CompactComposerBar() + } + if (this.presentation === ComposerPresentation.Floating) { + this.TaskComposer() } - .width('100%') - .layoutWeight(1) - this.ContextControls() - this.Composer() } .width('100%') .height('100%') .backgroundColor(PAGE_BG) + .bindSheet($$this.showSelectorSheet, this.SelectorSheet(), this.selectorSheetOptions()) + } + + @Builder + CompactComposerBar() { + ComposerBar({ + presentation: ComposerPresentation.Create, + capabilities: REMOTE_CREATE_COMPOSER_CAPABILITIES, + inputId: 'remote-create-composer', + chatInput: this.state.draft, + isBusy: this.state.isSubmitting, + connectionState: 'connected', + isVoiceListening: this.isVoiceListening, + modelCatalog: this.modelCatalog, + selectedModelId: this.selectedModelId, + onSend: () => this.onSend(), + onChatInputChange: (value: string) => this.onDraftChange(value), + onVoiceInput: () => this.onVoiceInput(), + onSelectModel: (modelId: string) => this.onSelectModel(modelId) + }) + } + + @Builder + CompactNavigationSpace() { + Column() { + Row() { + Button() { + Image($r('app.media.remote_ref_back')) + .width(15) + .height(23) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) + } + .width(44) + .height(44) + .padding(0) + .type(ButtonType.Circle) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(22) + .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) + .accessibilityText(RemoteI18n.t('common.back')) + .onClick(() => this.onBack()) + } + .width('100%') + .height(78) + .padding({ left: 18, top: 14 }) + .alignItems(VerticalAlign.Top) + Column() + .width('100%') + .layoutWeight(1) + .onClick(() => { + this.state.closeMenu(); + this.keepComposerFocused(); + }) + } + .width('100%') + .layoutWeight(1) + } + + @Builder + Header() { + Row({ space: 8 }) { + if (this.showSidebarRestoreButton) { + SidebarToggleButton({ + restore: true, + controlSize: 48, + onToggle: this.onRestoreSidebar + }) + } else if (this.presentation !== ComposerPresentation.Floating) { + Button() { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(22) + .fontColor([INK]) + } + .width(48) + .height(48) + .padding(0) + .type(ButtonType.Circle) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(24) + .accessibilityText(RemoteI18n.t('common.back')) + .onClick(() => this.onBack()) + } else { + Blank().width(48).height(48) + } + Text(RemoteI18n.t('remote.create.title')) + .layoutWeight(1) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .textAlign(TextAlign.Center) + Blank().width(48).height(48) + } + .width('100%') + .height(64) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) } @Builder ContextControls() { Column({ space: 2 }) { - this.ContextRow( - 'device', - this.state.selectedDeviceName || RemoteI18n.t('remote.create.noDevice'), - this.state.isLoadingDevices, - () => this.onToggleDeviceMenu() - ) + if (this.presentation === ComposerPresentation.Floating) { + this.ContextRow( + 'device', + this.state.isLoadingDevices, + () => this.onToggleDeviceMenu() + ) + } this.ContextRow( 'workspace', - this.state.selectedWorkspaceName || RemoteI18n.t('remote.create.chat'), this.state.isLoadingWorkspaces, () => this.onToggleWorkspaceMenu() ) } .width('100%') - .padding({ left: 28, right: 28, bottom: 4 }) + .padding({ + left: this.presentation === ComposerPresentation.Floating ? 10 : 28, + right: this.presentation === ComposerPresentation.Floating ? 10 : 28, + top: this.presentation === ComposerPresentation.Floating ? 8 : 0, + bottom: 4 + }) } @Builder - ContextRow(kind: string, title: string, loading: boolean, onClick: () => void) { + ContextRow(kind: string, loading: boolean, onClick: () => void) { Row({ space: 13 }) { - Image(kind === 'device' ? $r('app.media.remote_create_device') : - (this.state.selectedWorkspacePath.length > 0 ? $r('app.media.remote_create_folder') : $r('app.media.remote_create_chat'))) - .width(26) - .height(26) - .objectFit(ImageFit.Contain) - .opacity(loading ? 0.42 : 1) - Text(loading ? RemoteI18n.t('common.loading') : title) + this.ContextGlyph(kind, loading) + Text(loading ? RemoteI18n.t('common.loading') : + (kind === 'device' ? + (this.state.selectedDeviceName || RemoteI18n.t('remote.create.noDevice')) : + (this.state.selectedWorkspaceName || RemoteI18n.t('remote.create.chat')))) .constraintSize({ maxWidth: '74%' }) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(loading ? MUTED : INK) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) - Image($r('app.media.remote_create_switch')) + SymbolGlyph(this.state.openMenu === (kind === 'device' ? 'devices' : 'workspaces') ? + $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + .fontSize(13) + .fontColor([MUTED]) .width(22) .height(30) - .objectFit(ImageFit.Contain) .opacity(loading ? 0.42 : 1) Blank() .layoutWeight(1) @@ -119,9 +223,9 @@ export struct RemoteCreateSessionView { .bindPopup(this.state.openMenu === (kind === 'device' ? 'devices' : 'workspaces'), { builder: () => { if (kind === 'device') { - this.DeviceMenu() + this.DeviceMenu(false) } else { - this.WorkspaceMenu() + this.WorkspaceMenu(false) } }, placement: Placement.Top, @@ -143,7 +247,21 @@ export struct RemoteCreateSessionView { } @Builder - DeviceMenu() { + ContextGlyph(kind: string, loading: boolean) { + if (kind === 'device') { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(22).fontColor([MUTED]).width(26).height(26).opacity(loading ? 0.42 : 1) + } else if (this.state.selectedWorkspacePath.length > 0) { + SymbolGlyph($r('sys.symbol.folder')) + .fontSize(22).fontColor([MUTED]).width(26).height(26).opacity(loading ? 0.42 : 1) + } else { + SymbolGlyph($r('sys.symbol.message')) + .fontSize(22).fontColor([MUTED]).width(26).height(26).opacity(loading ? 0.42 : 1) + } + } + + @Builder + DeviceMenu(asSheet: boolean) { Column() { if (this.state.devices.length === 0 && this.state.isLoadingDevices) { this.MenuMessage(RemoteI18n.t('common.loading')) @@ -155,22 +273,20 @@ export struct RemoteCreateSessionView { }, (device: CloudAccountDevice) => device.deviceId) } } - .width(340) + .width(asSheet ? '100%' : (this.presentation === ComposerPresentation.Floating ? 360 : 340)) .padding({ top: 8, bottom: 8 }) .backgroundColor(CARD) - .borderRadius(24) + .borderRadius(asSheet ? 0 : 16) .border({ width: 1, color: LINE }) - .shadow({ radius: 26, color: '#1A000000', offsetY: 10 }) + .shadow({ radius: asSheet ? 0 : 20, color: asSheet ? '#00000000' : '#1A000000', offsetY: 8 }) } @Builder DeviceMenuRow(device: CloudAccountDevice) { Row({ space: 12 }) { this.SelectionMark(device.deviceId === this.state.selectedDeviceId) - Image($r('app.media.remote_create_device')) - .width(27) - .height(27) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(22).fontColor([MUTED]).width(27).height(27) Text(device.deviceName) .layoutWeight(1) .fontSize(16) @@ -182,20 +298,19 @@ export struct RemoteCreateSessionView { .height(58) .padding({ left: 16, right: 18, top: 8, bottom: 8 }) .onClick(() => { + this.showSelectorSheet = false; this.onSelectDevice(device); this.keepComposerFocused(); }) } @Builder - WorkspaceMenu() { + WorkspaceMenu(asSheet: boolean) { Column() { Row({ space: 12 }) { this.SelectionMark(this.state.selectedWorkspacePath.length === 0) - Image($r('app.media.remote_create_chat')) - .width(27) - .height(27) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.message')) + .fontSize(22).fontColor([MUTED]).width(27).height(27) Text(RemoteI18n.t('remote.create.chat')) .layoutWeight(1) .fontSize(16) @@ -205,6 +320,7 @@ export struct RemoteCreateSessionView { .height(58) .padding({ left: 16, right: 18 }) .onClick(() => { + this.showSelectorSheet = false; this.onSelectWorkspace(undefined); this.keepComposerFocused(); }) @@ -224,26 +340,26 @@ export struct RemoteCreateSessionView { } } .width('100%') - .constraintSize({ maxHeight: 190 }) + .constraintSize({ + maxHeight: this.presentation === ComposerPresentation.Floating ? 190 : 92 + }) .scrollBar(BarState.Off) } } - .width(340) + .width(asSheet ? '100%' : (this.presentation === ComposerPresentation.Floating ? 360 : 340)) .padding({ top: 8, bottom: 8 }) .backgroundColor(CARD) - .borderRadius(24) + .borderRadius(asSheet ? 0 : 16) .border({ width: 1, color: LINE }) - .shadow({ radius: 26, color: '#1A000000', offsetY: 10 }) + .shadow({ radius: asSheet ? 0 : 20, color: asSheet ? '#00000000' : '#1A000000', offsetY: 8 }) } @Builder WorkspaceMenuRow(workspace: RecentWorkspaceEntry) { Row({ space: 12 }) { this.SelectionMark(workspace.path === this.state.selectedWorkspacePath) - Image($r('app.media.remote_create_folder')) - .width(27) - .height(27) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.folder')) + .fontSize(22).fontColor([MUTED]).width(27).height(27) Column({ space: 2 }) { Text(workspace.name) .width('100%') @@ -265,6 +381,7 @@ export struct RemoteCreateSessionView { .height(64) .padding({ left: 16, right: 18 }) .onClick(() => { + this.showSelectorSheet = false; this.onSelectWorkspace(workspace); this.keepComposerFocused(); }) @@ -274,10 +391,9 @@ export struct RemoteCreateSessionView { SelectionMark(selected: boolean) { Stack({ alignContent: Alignment.Center }) { if (selected) { - Image($r('app.media.remote_actions_check')) - .width(20) - .height(20) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(19) + .fontColor([INK]) } } .width(20) @@ -294,14 +410,56 @@ export struct RemoteCreateSessionView { } @Builder - Composer() { + SelectorSheet() { + Column() { + Row() { + Text(this.state.openMenu === 'devices' ? RemoteI18n.t('remote.device') : RemoteI18n.t('remote.workspace')) + .layoutWeight(1) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(16) + .fontColor([MUTED]) + .width(20) + .height(20) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.closeSelectorSheet()) + } + .width('100%') + .height(52) + .padding({ left: 18, right: 8 }) + if (this.state.openMenu === 'devices') { + this.DeviceMenu(true) + } else { + this.WorkspaceMenu(true) + } + } + .width('100%') + .padding({ left: 12, right: 12, bottom: 16 }) + .backgroundColor(CARD) + .borderRadius(16) + } + + @Builder + TaskComposer() { Column({ space: 6 }) { + if (this.presentation === ComposerPresentation.Floating) { + this.ContextControls() + Divider() + .color(LINE) + .margin({ left: 18, right: 18 }) + } if (this.state.errorText.length > 0) { Text(this.state.errorText) .width('100%') .padding({ left: 12, right: 12 }) .fontSize(12) - .fontColor('#C63E3E') + .fontColor(RED) .maxLines(2) } Row({ space: 8 }) { @@ -323,10 +481,9 @@ export struct RemoteCreateSessionView { this.onDraftChange(value); }) Button() { - Image($r('app.media.gpt_composer_send')) - .width(40) - .height(40) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.arrow_up')) + .fontSize(23) + .fontColor([INK]) .opacity(this.canSend() ? 1 : 0.36) } .width(42) @@ -338,15 +495,45 @@ export struct RemoteCreateSessionView { .onClick(() => this.onSend()) } .width('100%') - .height(66) + .height(this.presentation === ComposerPresentation.Floating ? 72 : 66) .padding({ left: 12, right: 7, top: 5, bottom: 5 }) - .backgroundColor(CARD) - .borderRadius(25) - .border({ width: 1, color: SOFT }) - .shadow({ radius: 22, color: '#18000000', offsetY: 7 }) + .backgroundColor(this.presentation === ComposerPresentation.Floating ? '#00000000' : CARD) + .borderRadius(this.presentation === ComposerPresentation.Floating ? 0 : 25) + .border({ + width: this.presentation === ComposerPresentation.Floating ? 0 : 1, + color: this.presentation === ComposerPresentation.Floating ? '#00000000' : SOFT + }) + .shadow({ + radius: this.presentation === ComposerPresentation.Floating ? 0 : 22, + color: this.presentation === ComposerPresentation.Floating ? '#00000000' : '#18000000', + offsetY: this.presentation === ComposerPresentation.Floating ? 0 : 7 + }) } .width('100%') - .padding({ left: 16, right: 16, bottom: 14 }) + .constraintSize({ maxWidth: this.presentation === ComposerPresentation.Floating ? 760 : 10000 }) + .alignSelf(ItemAlign.Center) + .padding({ + left: this.presentation === ComposerPresentation.Floating ? 0 : 16, + right: this.presentation === ComposerPresentation.Floating ? 0 : 16, + top: this.presentation === ComposerPresentation.Floating ? 4 : 0, + bottom: this.presentation === ComposerPresentation.Floating ? 4 : 14 + }) + .backgroundColor(this.presentation === ComposerPresentation.Floating ? CARD : '#00000000') + .borderRadius(this.presentation === ComposerPresentation.Floating ? 18 : 0) + .border({ + width: this.presentation === ComposerPresentation.Floating ? 1 : 0, + color: this.presentation === ComposerPresentation.Floating ? SOFT : '#00000000' + }) + .shadow({ + radius: this.presentation === ComposerPresentation.Floating ? 20 : 0, + color: this.presentation === ComposerPresentation.Floating ? '#18000000' : '#00000000', + offsetY: this.presentation === ComposerPresentation.Floating ? 6 : 0 + }) + .margin({ + left: this.presentation === ComposerPresentation.Floating ? 24 : 0, + right: this.presentation === ComposerPresentation.Floating ? 24 : 0, + bottom: this.presentation === ComposerPresentation.Floating ? 24 : 0 + }) } private canSend(): boolean { @@ -357,4 +544,23 @@ export struct RemoteCreateSessionView { private keepComposerFocused(): void { setTimeout(() => focusControl.requestFocus('remote-create-composer'), 30); } + + private closeSelectorSheet(): void { + this.showSelectorSheet = false; + this.state.closeMenu(); + this.keepComposerFocused(); + } + + private selectorSheetOptions(): SheetOptions { + return { + height: this.state.openMenu === 'devices' ? 420 : 440, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: true, + onWillDismiss: () => { + this.state.closeMenu(); + } + }; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets deleted file mode 100644 index 8c2a746d96..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets +++ /dev/null @@ -1,107 +0,0 @@ -import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; -import { CARD, GREEN, INK, MUTED, RED } from './Theme'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; - -@ComponentV2 -export struct RemoteHeader { - @Param desktopName: string = ''; - @Param connectionState: string = 'idle'; - @Param isLoading: boolean = false; - @Event onOpenSidebar: () => void = () => {}; - @Event onOpenActions: () => void = () => {}; - - build() { - Row() { - this.MenuButton() - Column({ space: 2 }) { - Text(RemoteI18n.t('remote.title')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .maxLines(1) - Row({ space: 6 }) { - if (this.isLoading || this.isConnecting()) { - LoadingProgress() - .width(14) - .height(14) - .color('#B9B9B9') - } else { - Text('') - .width(7) - .height(7) - .backgroundColor(this.connectionColor()) - .borderRadius(4) - } - Image($r('app.media.remote_ref_device')) - .width(19) - .height(15) - .objectFit(ImageFit.Contain) - Text(this.desktopName || RemoteI18n.t('remote.settings.noDesktop')) - .fontSize(13) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Center) - this.MoreButton() - } - .width('100%') - .height(68) - .alignItems(VerticalAlign.Center) - } - - @Builder - private MenuButton() { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_menu')) - .width(24) - .height(15) - .objectFit(ImageFit.Contain) - } - .width(48) - .height(48) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 18, color: '#10000000', offsetY: 8 }) - .onClick(() => { - this.onOpenSidebar(); - }) - } - - @Builder - private MoreButton() { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_more')) - .width(27) - .height(8) - .objectFit(ImageFit.Contain) - } - .width(48) - .height(48) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 18, color: '#10000000', offsetY: 8 }) - .onClick(() => { - this.onOpenActions(); - }) - } - - private connectionColor(): string { - const tone = ConnectionStatusPresenter.tone(this.connectionState); - if (tone === 'ok') { - return GREEN; - } - if (tone === 'error') { - return RED; - } - return MUTED; - } - - private isConnecting(): boolean { - return this.connectionState === 'parsing' || - this.connectionState === 'pairing' || - this.connectionState === 'reconnecting'; - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets deleted file mode 100644 index b60a7e8851..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets +++ /dev/null @@ -1,303 +0,0 @@ -import { RemoteSession } from '../../model/RemoteModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { RemoteActionsSheet } from './RemoteActionsSheet'; -import { RemoteBottomBar } from './RemoteBottomBar'; -import { RemoteHeader } from './RemoteHeader'; -import { RemoteSessionList } from './RemoteSessionList'; -import { RemoteSessionLoadingView } from './RemoteSessionLoadingView'; -import { RemotePageState } from '../state/RemotePageState'; -import { ACCENT, CARD, INK, MUTED, PAGE_BG } from './Theme'; - -@ComponentV2 -export struct RemoteHomeView { - @Param pageState: RemotePageState = new RemotePageState(); - @Param isBusy: boolean = false; - @Param selectedSessionId: string = ''; - @Event onOpenSidebar: () => void = () => {}; - @Event onConnectWorkspace: () => void = () => {}; - @Event onAddConnection: () => void = () => {}; - @Event onOpenRemoteSettings: () => void = () => {}; - @Event onRefresh: () => void = () => {}; - @Event onShowWorkspaces: () => void = () => {}; - @Event onShowAssistants: () => void = () => {}; - @Event onSelectWorkspace: (path: string) => void = (_path: string) => {}; - @Event onSelectAssistant: (path: string) => void = (_path: string) => {}; - @Event onCancelWorkspacePicker: () => void = () => {}; - @Event onCancelAssistantPicker: () => void = () => {}; - @Event onSessionQueryChange: (value: string) => void = (_value: string) => {}; - @Event onSearchSessions: () => void = () => {}; - @Event onLoadMoreSessions: () => void = () => {}; - @Event onReconnect: () => void = () => {}; - @Event onDisconnect: () => void = () => {}; - @Event onClearPairing: () => void = () => {}; - @Event onCreate: (agentType: string) => void = (_agentType: string) => {}; - @Event onCreateAssistantSession: () => void = () => {}; - @Event onCreateInWorkspace: (path: string, agentType: string) => void = (_path: string, _agentType: string) => {}; - @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @Local showRemoteActions: boolean = false; - @Local sortMode: string = 'project'; - - build() { - Stack() { - Column() { - RemoteHeader({ - desktopName: this.pageState.desktopName, - connectionState: this.pageState.connectionState, - isLoading: this.pageState.isLoadingHome, - onOpenSidebar: () => { - this.onOpenSidebar(); - }, - onOpenActions: () => { - this.openRemoteActions(); - } - }) - if (this.isInitialLoading()) { - RemoteSessionLoadingView() - RemoteBottomBar({ - query: this.pageState.sessionQuery, - isBusy: true, - onQueryChange: (value: string) => { - this.onSessionQueryChange(value); - }, - onSearch: () => { - this.onSearchSessions(); - }, - onCreate: () => { - this.onCreateAssistantSession(); - } - }) - } else if (this.canUseRemote()) { - RemoteSessionList({ - sessions: this.pageState.visibleSessions(), - query: this.pageState.sessionQuery, - sortMode: this.sortMode, - workspaceName: this.pageState.workspaceName, - workspacePath: this.pageState.workspacePath, - workspaceKind: this.pageState.workspaceKind, - recentWorkspaces: this.pageState.recentWorkspaces, - hasMoreSessions: this.pageState.hasMoreSessions, - isBusy: this.isBusy || this.pageState.isLoadingSessions, - onCreate: () => { - this.onCreate('code'); - }, - onCreateAssistantSession: () => { - this.onCreateAssistantSession(); - }, - onCreateInWorkspace: (path: string, agentType: string) => { - this.onCreateInWorkspace(path, agentType); - }, - onSelectWorkspace: (path: string) => { - this.onSelectWorkspace(path); - }, - onOpenSession: (session: RemoteSession) => { - this.onOpenSession(session); - }, - onDeleteSession: (session: RemoteSession) => { - this.onDeleteSession(session); - }, - selectedSessionId: this.selectedSessionId, - onLoadMore: () => { - this.onLoadMoreSessions(); - } - }) - RemoteBottomBar({ - query: this.pageState.sessionQuery, - isBusy: this.isBusy, - onQueryChange: (value: string) => { - this.onSessionQueryChange(value); - }, - onSearch: () => { - this.onSearchSessions(); - }, - onCreate: () => { - this.onCreateAssistantSession(); - } - }) - } else { - this.DisconnectedState() - } - } - if (this.showRemoteActions) { - Stack({ alignContent: Alignment.TopEnd }) { - Text('') - .width('100%') - .height('100%') - .backgroundColor('#06000000') - .onClick(() => { - this.dismissRemoteActions(); - }) - Column() { - this.RemoteActionsLayer() - } - .width(330) - .height(390) - .margin({ top: 4, right: 4 }) - .transition(TransitionEffect.translate({ x: 18, y: -12 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseOut })) - } - .width('100%') - .height('100%') - } - } - .width('100%') - .height('100%') - .padding({ left: 12, right: 12, top: 0, bottom: 16 }) - .backgroundColor(PAGE_BG) - } - - @Builder - RemoteActionsLayer() { - RemoteActionsSheet({ - desktopName: this.pageState.desktopName, - workspaceName: this.pageState.workspaceName, - workspacePath: this.pageState.workspacePath, - assistantId: this.pageState.assistantId, - connectionState: this.pageState.connectionState, - isBusy: this.isBusy, - sortMode: this.sortMode, - recentWorkspaces: this.pageState.recentWorkspaces, - assistants: this.pageState.assistants, - showWorkspacePicker: this.pageState.showWorkspacePicker, - showAssistantPicker: this.pageState.showAssistantPicker, - onClose: () => { - this.closeRemoteActions(); - }, - onDismiss: () => { - this.dismissRemoteActions(); - }, - onRefresh: () => { - this.onRefresh(); - }, - onShowWorkspaces: () => { - this.onShowWorkspaces(); - }, - onShowAssistants: () => { - this.onShowAssistants(); - }, - onSelectWorkspace: (path: string) => { - this.onSelectWorkspace(path); - }, - onSelectAssistant: (path: string) => { - this.onSelectAssistant(path); - }, - onCancelWorkspacePicker: () => { - this.onCancelWorkspacePicker(); - }, - onCancelAssistantPicker: () => { - this.onCancelAssistantPicker(); - }, - onReconnect: () => { - this.onReconnect(); - }, - onDisconnect: () => { - this.onDisconnect(); - }, - onClearPairing: () => { - this.onClearPairing(); - }, - onSortModeChange: (mode: string) => { - this.sortMode = mode; - }, - onAddConnection: () => { - this.onAddConnection(); - }, - onOpenSettings: () => { - this.onOpenRemoteSettings(); - } - }) - } - - @Builder - DisconnectedState() { - Column({ space: 10 }) { - this.LargeDesktopGlyph() - Text(RemoteI18n.t('remote.connectTitle')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .margin({ top: 8 }) - Text(RemoteI18n.t('remote.connectText')) - .fontSize(14) - .lineHeight(21) - .fontColor(MUTED) - .textAlign(TextAlign.Center) - .constraintSize({ maxWidth: 280 }) - Button(RemoteI18n.t('connect.connect')) - .width(148) - .height(50) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(CARD) - .backgroundColor(ACCENT) - .borderRadius(25) - .margin({ top: 14 }) - .onClick(() => { - this.onConnectWorkspace(); - }) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ bottom: 72 }) - } - - @Builder - LargeDesktopGlyph() { - Stack() { - Text('') - .width(46) - .height(34) - .borderRadius(6) - .border({ width: 2, color: INK }) - .position({ x: 9, y: 4 }) - Text('') - .width(18) - .height(2) - .backgroundColor(INK) - .position({ x: 23, y: 44 }) - Text('') - .width(34) - .height(2) - .backgroundColor(INK) - .position({ x: 15, y: 51 }) - } - .width(64) - .height(58) - } - - private closeRemoteActions(): void { - this.onCancelWorkspacePicker(); - this.onCancelAssistantPicker(); - this.dismissRemoteActions(); - } - - private openRemoteActions(): void { - this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => { - this.showRemoteActions = true; - }); - } - - private dismissRemoteActions(): void { - this.getUIContext().animateTo({ duration: 180, curve: Curve.EaseOut }, () => { - this.showRemoteActions = false; - }); - } - - private canUseRemote(): boolean { - return this.pageState.connectionState === 'connected'; - } - - private isConnecting(): boolean { - return this.pageState.connectionState === 'parsing' || - this.pageState.connectionState === 'pairing' || - this.pageState.connectionState === 'reconnecting'; - } - - private isInitialLoading(): boolean { - return this.pageState.isLoadingHome || this.isConnecting(); - } - -} 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 712bf31a97..d1d6b2d8c5 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 @@ -1,7 +1,11 @@ import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { TimeFormat } from '../../services/TimeFormat'; -import { CARD, INK, MUTED, RED } from './Theme'; +import { CARD, INK, MUTED, SOFT } from './Theme'; +import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; +import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../state/SessionActionPolicy'; +import { SessionDetailsView } from './SessionDetailsView'; +import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; @ComponentV2 export struct RemoteSessionList { @@ -15,6 +19,13 @@ export struct RemoteSessionList { @Param workspacePath: string = ''; @Param workspaceKind: string = 'normal'; @Param recentWorkspaces: RecentWorkspaceEntry[] = []; + @Param actionPresentation: SessionActionPresentation = SessionActionPresentation.BottomSheet; + @Param workspaceFilter: string = ''; + @Param agentFilter: string = ''; + @Param statusFilter: string = ''; + @Param showWorkspaceMetadata: boolean = false; + @Param showUpdatedMetadata: boolean = false; + @Param showStatusMetadata: boolean = false; @Event onCreate: () => void = () => {}; @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @@ -31,6 +42,10 @@ export struct RemoteSessionList { @Local yesterdayCollapsed: boolean = false; @Local earlierCollapsed: boolean = false; @Local createMenuPath: string = ''; + @Local activeActionSessionId: string = ''; + @Local showSessionActionSheet: boolean = false; + @Local detailsSessionId: string = ''; + @Local showSessionDetails: boolean = false; @Monitor('isBusy', 'workspacePath') onWorkspaceContextChanged(): void { @@ -41,25 +56,44 @@ export struct RemoteSessionList { Column() { Scroll() { Column() { - if (this.sortMode === 'time') { + if (this.filteredSessions().length === 0) { + if (this.hasActiveListFilter()) { + this.FilteredEmptySessions() + } else { + this.EmptySessions() + } + } else if (this.sortMode === 'time') { this.TimeSection() } else if (this.sortMode === 'chat') { - this.ChatSection() - this.ProjectSection() + if (this.visibleChatSessions().length > 0) { + this.ChatSection() + } + if (this.projectEntries().length > 0) { + this.ProjectSection() + } } else { - this.ProjectSection() - this.ChatSection() + if (this.projectEntries().length > 0) { + this.ProjectSection() + } + if (this.visibleChatSessions().length > 0) { + this.ChatSection() + } } } .width('100%') + .alignItems(HorizontalAlign.Start) } .layoutWeight(1) .width('100%') + .align(Alignment.TopStart) .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Off) } .width('100%') .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) + .bindSheet($$this.showSessionDetails, this.SessionDetailsSheet(), this.sessionDetailsSheetOptions()) } @Builder @@ -80,7 +114,7 @@ export struct RemoteSessionList { } } .width('100%') - .margin({ top: 14, bottom: 16 }) + .margin({ top: 10, bottom: 16 }) } @Builder @@ -135,25 +169,23 @@ export struct RemoteSessionList { private TimeGroupHeader(title: string, collapsed: boolean, action: () => void) { Row({ space: 8 }) { Text(title) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) if (collapsed) { - Image($r('app.media.remote_ref_chevron_right')) - .width(16) - .height(16) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) .opacity(0.55) } else { - Image($r('app.media.remote_ref_chevron_centered')) - .width(16) - .height(16) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_down')) + .fontSize(14) + .fontColor([MUTED]) .opacity(0.55) } } .width('100%') - .height(50) + .height(42) .alignItems(VerticalAlign.Center) .onClick(action) } @@ -164,47 +196,51 @@ export struct RemoteSessionList { Row() { Row({ space: 8 }) { Text(RemoteI18n.t('sidebar.projects')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Text(`${this.projectEntries().length}`) .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Text(`${this.projectEntries().length}`) + .fontSize(12) .fontColor(MUTED) } Blank() } .width('100%') - .height(58) + .height(44) .alignItems(VerticalAlign.Center) ForEach(this.visibleProjectEntries(), (project: RecentWorkspaceEntry) => { Column({ space: 2 }) { Row({ space: 10 }) { - Image($r('app.media.remote_ref_folder')) - .width(30) - .height(25) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.folder')) + .fontSize(22) + .fontColor([INK]) + .width(24) + .height(24) Text(project.name || this.basename(project.path)) - .fontSize(18) + .fontSize(15) .fontColor(INK) .layoutWeight(1) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) if (this.isWorkspaceCollapsed(project.path)) { - Image($r('app.media.remote_ref_chevron_right')) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) .width(18) .height(18) - .objectFit(ImageFit.Contain) } else { - Image($r('app.media.remote_ref_chevron_centered')) + SymbolGlyph($r('sys.symbol.chevron_down')) + .fontSize(14) + .fontColor([MUTED]) .width(18) .height(18) - .objectFit(ImageFit.Contain) } if (project.path.length > 0) { - Image($r('app.media.remote_ref_edit')) - .width(25) - .height(25) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(18) + .fontColor([MUTED]) + .width(22) + .height(22) .opacity(0.52) .bindPopup(this.createMenuPath.length > 0 && this.createMenuPath === project.path, { builder: () => { @@ -228,7 +264,7 @@ export struct RemoteSessionList { } } .width('100%') - .height(50) + .height(44) .onClick(() => { this.toggleWorkspace(project.path); }) @@ -241,9 +277,9 @@ export struct RemoteSessionList { }, (project: RecentWorkspaceEntry): string => project.path) if (this.visibleProjectCount < this.projectEntries().length) { Text(RemoteI18n.f('remote.projects.showMore', String(this.nextProjectEntryBatchSize()))) - .fontSize(15) + .fontSize(13) .fontColor(MUTED) - .height(48) + .height(40) .width('100%') .textAlign(TextAlign.Center) .onClick(() => { @@ -252,7 +288,7 @@ export struct RemoteSessionList { } } .width('100%') - .margin({ top: 18 }) + .margin({ top: 12 }) } @Builder @@ -292,36 +328,44 @@ export struct RemoteSessionList { Row() { Row({ space: 8 }) { Text(RemoteI18n.t('remote.chats')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) if (this.chatsCollapsed) { - Image($r('app.media.remote_ref_chevron_right')) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) .width(18) .height(18) - .objectFit(ImageFit.Contain) } else { - Image($r('app.media.remote_ref_chevron_centered')) + SymbolGlyph($r('sys.symbol.chevron_down')) + .fontSize(14) + .fontColor([MUTED]) .width(18) .height(18) - .objectFit(ImageFit.Contain) } } .onClick(() => { this.chatsCollapsed = !this.chatsCollapsed; }) Blank() - Image($r('app.media.remote_ref_edit')) - .width(26) - .height(26) - .objectFit(ImageFit.Contain) - .opacity(0.52) - .onClick(() => { - this.onCreateAssistantSession(); - }) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(18) + .fontColor([MUTED]) + .width(22) + .height(22) + .opacity(0.52) + } + .width(40) + .height(40) + .accessibilityText(RemoteI18n.t('remote.newChat')) + .onClick(() => { + this.onCreateAssistantSession(); + }) } .width('100%') - .height(58) + .height(44) .alignItems(VerticalAlign.Center) if (this.visibleChatSessions().length === 0) { this.EmptySessions() @@ -331,9 +375,9 @@ export struct RemoteSessionList { }) if (this.chatVisibleCount < this.visibleChatSessions().length) { Text(RemoteI18n.f('remote.sessions.showMore', String(this.nextChatBatchSize()))) - .fontSize(15) + .fontSize(13) .fontColor(MUTED) - .height(48) + .height(40) .width('100%') .textAlign(TextAlign.Center) .onClick(() => { @@ -349,24 +393,7 @@ export struct RemoteSessionList { @Builder private projectPreview(path: string) { ForEach(this.visibleProjectSessions(path), (item: RemoteSession) => { - Row() { - Text(item.title || RemoteI18n.t('sidebar.untitled')) - .fontSize(16) - .fontColor(INK) - .fontWeight(this.selectedSessionId === item.id ? FontWeight.Medium : FontWeight.Regular) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .textAlign(TextAlign.Start) - } - .width('100%') - .height(58) - .padding({ left: 0, right: 8 }) - .backgroundColor(this.selectedSessionId === item.id ? '#F3F3F3' : '#00000000') - .borderRadius(12) - .onClick(() => { - this.onOpenSession(item); - }) + this.SessionRow(item, true) }) if (this.projectVisibleCount(path) < this.projectSessions(path).length) { Text(RemoteI18n.f('remote.sessions.showMore', String(this.nextProjectBatchSize(path)))) @@ -392,7 +419,10 @@ export struct RemoteSessionList { entries.push(item); } }); - return entries; + if (!this.hasActiveListFilter()) { + return entries; + } + return entries.filter((item: RecentWorkspaceEntry) => this.projectSessions(item.path).length > 0); } private visibleProjectEntries(): RecentWorkspaceEntry[] { @@ -409,23 +439,11 @@ export struct RemoteSessionList { } private visibleChatSessions(): RemoteSession[] { - const query = this.query.trim().toLowerCase(); - return this.sessions.filter((item: RemoteSession) => { - if (!this.isAssistantSession(item) || item.status === 'archived') { - return false; - } - return query.length === 0 || item.title.toLowerCase().indexOf(query) >= 0; - }); + return this.filteredSessions().filter((item: RemoteSession) => this.isAssistantSession(item)); } private sessionsByTime(): RemoteSession[] { - const query = this.query.trim().toLowerCase(); - return this.sessions.filter((item: RemoteSession) => { - if (item.status === 'archived') { - return false; - } - return query.length === 0 || item.title.toLowerCase().indexOf(query) >= 0; - }).slice().sort((left: RemoteSession, right: RemoteSession) => { + return this.filteredSessions().slice().sort((left: RemoteSession, right: RemoteSession) => { return this.sessionTimestamp(right) - this.sessionTimestamp(left); }); } @@ -466,11 +484,33 @@ export struct RemoteSessionList { } private projectSessions(path: string): RemoteSession[] { + return this.filteredSessions().filter((item: RemoteSession) => { + return !this.isAssistantSession(item) && ConversationSessionFilterPolicy.workspacePathsEqual( + item.workspacePath || this.workspacePath, + path + ); + }); + } + + private filteredSessions(): RemoteSession[] { return this.sessions.filter((item: RemoteSession) => { - return !this.isAssistantSession(item) && (item.workspacePath || this.workspacePath) === path; + return ConversationSessionFilterPolicy.matches( + item, + this.query, + this.workspacePath, + this.workspaceFilter, + this.agentFilter, + this.statusFilter, + this.isAssistantSession(item) + ); }); } + private hasActiveListFilter(): boolean { + return this.query.trim().length > 0 || this.workspaceFilter.length > 0 || + this.agentFilter.length > 0 || this.statusFilter.length > 0; + } + private visibleProjectSessions(path: string): RemoteSession[] { return this.projectSessions(path).slice(0, this.projectVisibleCount(path)); } @@ -520,28 +560,103 @@ export struct RemoteSessionList { } @Builder - private SessionRow(item: RemoteSession) { - Row() { - Text(item.title || RemoteI18n.t('sidebar.untitled')) - .fontSize(18) - .fontWeight(this.selectedSessionId === item.id ? FontWeight.Medium : FontWeight.Regular) - .fontColor(INK) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text('›') - .fontSize(22) - .fontColor('#00000000') + private SessionRow(item: RemoteSession, nested: boolean = false) { + Row({ space: 8 }) { + Column({ space: 2 }) { + Text(item.title || RemoteI18n.t('sidebar.untitled')) + .width('100%') + .fontSize(15) + .fontWeight(this.selectedSessionId === item.id ? FontWeight.Medium : FontWeight.Regular) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.metadataText(item).length > 0) { + Text(this.metadataText(item)) + .width('100%') + .fontSize(11) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + this.SessionMoreButton(item) } .width('100%') - .height(58) - .padding({ left: 12, right: 12 }) + .height(this.metadataText(item).length > 0 ? 56 : 46) + .padding({ left: nested ? 0 : 10, right: 4 }) .alignItems(VerticalAlign.Center) - .backgroundColor(this.selectedSessionId === item.id ? '#F3F3F3' : '#00000000') - .borderRadius(12) + .backgroundColor(this.selectedSessionId === item.id ? SOFT : '#00000000') + .borderRadius(10) .onClick(() => { this.onOpenSession(item); }) + .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(item))) + .bindPopup(this.actionPresentation === SessionActionPresentation.Popover && + this.activeActionSessionId === item.id, { + builder: () => { this.SessionActionPopover() }, + placement: Placement.Right, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 6, + onStateChange: (event) => { + if (!event.isVisible) { + this.closeSessionActions(); + } + } + }) + } + + @Builder + private SessionMoreButton(item: RemoteSession) { + Stack({ alignContent: Alignment.Center }) { + 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) + } + .width(34) + .height(40) + .opacity(0.62) + .accessibilityText(RemoteI18n.t('session.actions')) + .onClick(() => this.openSessionActions(item)) + } + + @Builder + private SessionActionSheet() { + this.SessionActionContent(SessionActionPresentation.BottomSheet) + } + + @Builder + private SessionActionPopover() { + this.SessionActionContent(SessionActionPresentation.Popover) + } + + @Builder + private SessionActionContent(presentation: SessionActionPresentation) { + SessionActionSurface({ + presentation, + sessionTitle: this.actionSessionTitle(), + canViewDetails: this.actionCapabilities().canViewDetails, + canDelete: this.actionCapabilities().canDelete, + onViewDetails: () => this.openActionSessionDetails(), + onDelete: () => this.deleteActionSession(), + onClose: () => this.closeSessionActions() + }) + } + + @Builder + private SessionDetailsSheet() { + SessionDetailsView({ + session: this.detailsSession(), + onClose: () => this.closeSessionDetails() + }) } @Builder @@ -565,34 +680,128 @@ export struct RemoteSessionList { } @Builder - private DeleteReveal(item: RemoteSession) { - Text(RemoteI18n.t('home.deleteSession')) - .fontSize(13) - .fontColor(CARD) - .textAlign(TextAlign.Center) - .width(92) - .height(58) - .backgroundColor(RED) - .onClick(() => { - this.onDeleteSession(item); - }) + private FilteredEmptySessions() { + Column({ space: 8 }) { + Text(this.isBusy ? RemoteI18n.t('home.emptyLoadingTitle') : RemoteI18n.t('remote.emptyTitle')) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text(this.isBusy ? RemoteI18n.t('home.emptyLoadingText') : RemoteI18n.t('remote.emptyText')) + .fontSize(14) + .lineHeight(21) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + } + .width('100%') + .alignItems(HorizontalAlign.Center) + .padding({ left: 24, right: 24, top: 24, bottom: 16 }) } - private sessionSwipeAction(item: RemoteSession): SwipeActionOptions { + private openSessionActions(item: RemoteSession): void { if (this.isBusy || item.id.length === 0) { - return {}; + return; + } + this.activeActionSessionId = item.id; + if (this.actionPresentation === SessionActionPresentation.BottomSheet) { + this.showSessionActionSheet = true; + } + } + + private closeSessionActions(): void { + this.showSessionActionSheet = false; + this.activeActionSessionId = ''; + } + + private actionSession(): RemoteSession | undefined { + return this.sessions.find((item: RemoteSession) => item.id === this.activeActionSessionId); + } + + private actionSessionTitle(): string { + const session = this.actionSession(); + return session ? session.title : ''; + } + + private metadataText(item: RemoteSession): string { + const values: string[] = []; + if (this.showWorkspaceMetadata) { + const workspace = item.workspaceName || item.workspacePath || ''; + if (workspace.length > 0) { + values.push(workspace); + } + } + if (this.showUpdatedMetadata && !Number.isNaN(TimeFormat.timestampMs(item.updatedAt))) { + values.push(TimeFormat.relative(item.updatedAt)); + } + if (this.showStatusMetadata && item.status.length > 0) { + values.push(item.status === 'archived' ? RemoteI18n.t('sidebar.archived') : item.status); + } + return values.join(' · '); + } + + private actionCapabilities(): SessionActionCapabilities { + const session = this.actionSession(); + return SessionActionPolicy.resolve( + SessionActionScope.Remote, + session ? session.agentType : '', + this.isBusy || session === undefined + ); + } + + private deleteActionSession(): void { + const session = this.actionSession(); + if (session) { + this.onDeleteSession(session); } + } + + private openActionSessionDetails(): void { + const session = this.actionSession(); + if (session) { + this.detailsSessionId = session.id; + this.showSessionDetails = true; + } + } + + private closeSessionDetails(): void { + this.showSessionDetails = false; + this.detailsSessionId = ''; + } + + private detailsSession(): RemoteSession { + const session = this.sessions.find((item: RemoteSession) => item.id === this.detailsSessionId); + return session || { + id: '', title: '', agentType: '', status: '', updatedAt: '', createdAt: '', messageCount: 0 + }; + } + + private sessionActionSheetOptions(): SheetOptions { return { - end: { - builder: () => { - this.DeleteReveal(item); - }, - actionAreaDistance: 92, - onAction: () => { - this.onDeleteSession(item); - } - }, - edgeEffect: SwipeEdgeEffect.None + height: 300, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + + private sessionDetailsSheetOptions(): SheetOptions { + if (this.actionPresentation === SessionActionPresentation.BottomSheet) { + return { + height: SheetSize.LARGE, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + return { + height: 560, + width: 560, + preferType: SheetType.CENTER, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false }; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets new file mode 100644 index 0000000000..a8eb8b377d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets @@ -0,0 +1,196 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE, MUTED, RED, SOFT } from './Theme'; + +export enum SessionActionPresentation { + BottomSheet = 'bottom_sheet', + Popover = 'popover' +} + +@ComponentV2 +export struct SessionActionSurface { + @Param presentation: SessionActionPresentation = SessionActionPresentation.BottomSheet; + @Param sessionTitle: string = ''; + @Param archived: boolean = false; + @Param canViewDetails: boolean = false; + @Param canArchive: boolean = false; + @Param canExport: boolean = false; + @Param canDelete: boolean = false; + @Event onArchive: () => void = () => {}; + @Event onViewDetails: () => void = () => {}; + @Event onExport: () => void = () => {}; + @Event onDelete: () => void = () => {}; + @Event onClose: () => void = () => {}; + @Local confirmingDelete: boolean = false; + + build() { + Column() { + if (this.presentation === SessionActionPresentation.BottomSheet) { + Text('') + .width(36) + .height(4) + .backgroundColor(LINE) + .borderRadius(2) + .margin({ bottom: 10 }) + } + + Row({ space: 12 }) { + Column({ space: 3 }) { + Text(RemoteI18n.t('session.actions')) + .width('100%') + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Text(this.sessionTitle || RemoteI18n.t('sidebar.untitled')) + .width('100%') + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(16) + .fontColor([MUTED]) + .width(20) + .height(20) + } + .width(40) + .height(40) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.onClose()) + } + .width('100%') + .height(52) + .alignItems(VerticalAlign.Center) + + Divider().color(LINE).margin({ top: 6, bottom: 8 }) + + if (this.confirmingDelete) { + this.DeleteConfirmation() + } else { + if (this.canViewDetails) { + this.ActionRow('details', RemoteI18n.t('session.viewDetails'), () => { + this.onViewDetails(); + this.onClose(); + }) + } + if (this.canArchive) { + this.ActionRow('archive', this.archived ? RemoteI18n.t('sidebar.unarchive') : + RemoteI18n.t('sidebar.archive'), () => { + this.onArchive(); + this.onClose(); + }) + } + if (this.canExport) { + this.ActionRow('export', RemoteI18n.t('sidebar.exportMarkdown'), () => { + this.onExport(); + this.onClose(); + }) + } + if (this.canDelete) { + if (this.canArchive || this.canExport) { + Divider().color(LINE).margin({ top: 6, bottom: 6 }) + } + this.ActionRow('delete', RemoteI18n.t('common.delete'), () => { + this.confirmingDelete = true; + }, true) + } + } + } + .width(this.presentation === SessionActionPresentation.Popover ? 300 : '100%') + .padding({ left: 16, right: 16, top: 10, bottom: 18 }) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(16) + .shadow({ + radius: this.presentation === SessionActionPresentation.Popover ? 20 : 0, + color: this.presentation === SessionActionPresentation.Popover ? '#1A000000' : '#00000000', + offsetY: this.presentation === SessionActionPresentation.Popover ? 8 : 0 + }) + .alignItems(HorizontalAlign.Center) + } + + @Builder + private ActionRow(kind: string, label: string, action: () => void, destructive: boolean = false) { + Row({ space: 12 }) { + this.ActionIcon(kind, destructive) + Text(label) + .layoutWeight(1) + .fontSize(15) + .fontColor(destructive ? RED : INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .height(46) + .padding({ left: 10, right: 10 }) + .borderRadius(8) + .backgroundColor('#00000000') + .onClick(action) + } + + @Builder + private ActionIcon(kind: string, destructive: boolean) { + if (kind === 'details') { + SymbolGlyph($r('sys.symbol.info_circle')) + .fontSize(19) + .fontColor([MUTED]) + } else if (kind === 'archive') { + SymbolGlyph($r('sys.symbol.archivebox')) + .fontSize(19) + .fontColor([destructive ? RED : MUTED]) + } else if (kind === 'export') { + SymbolGlyph($r('sys.symbol.cloud')) + .fontSize(19) + .fontColor([MUTED]) + } else { + SymbolGlyph($r('sys.symbol.trash')) + .fontSize(19) + .fontColor([RED]) + } + } + + @Builder + private DeleteConfirmation() { + Column({ space: 12 }) { + Text(RemoteI18n.t('sidebar.deleteConfirm')) + .width('100%') + .fontSize(13) + .lineHeight(19) + .fontColor(MUTED) + Row({ space: 10 }) { + Text(RemoteI18n.t('common.cancel')) + .layoutWeight(1) + .height(44) + .fontSize(14) + .fontColor(INK) + .textAlign(TextAlign.Center) + .backgroundColor(SOFT) + .borderRadius(8) + .onClick(() => { + this.confirmingDelete = false; + }) + Text(RemoteI18n.t('common.delete')) + .layoutWeight(1) + .height(44) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(CARD) + .textAlign(TextAlign.Center) + .backgroundColor(RED) + .borderRadius(8) + .onClick(() => { + this.onDelete(); + this.onClose(); + }) + } + .width('100%') + } + .width('100%') + .padding({ top: 4, bottom: 4 }) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets new file mode 100644 index 0000000000..e0c07c0d58 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets @@ -0,0 +1,153 @@ +import { RemoteSession } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { TimeFormat } from '../../services/TimeFormat'; +import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; + +@ComponentV2 +export struct SessionDetailsView { + @Param session: RemoteSession = { + id: '', + title: '', + agentType: '', + status: '', + updatedAt: '', + createdAt: '', + messageCount: 0 + }; + @Event onClose: () => void = () => {}; + + build() { + Column() { + Row({ space: 12 }) { + Column({ space: 3 }) { + Text(RemoteI18n.t('session.details')) + .width('100%') + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Text(this.session.title || RemoteI18n.t('sidebar.untitled')) + .width('100%') + .fontSize(18) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(17) + .fontColor([MUTED]) + .width(20) + .height(20) + } + .width(44) + .height(44) + .backgroundColor(SOFT) + .borderRadius(22) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.onClose()) + } + .width('100%') + .padding({ left: 20, right: 16, top: 18, bottom: 16 }) + + Divider().color(LINE) + + Scroll() { + Column() { + this.DetailRow(RemoteI18n.t('session.agentType'), this.agentTypeLabel()) + if ((this.session.workspaceName || '').length > 0) { + this.DetailRow(RemoteI18n.t('session.workspace'), this.session.workspaceName || '') + } + if ((this.session.workspacePath || '').length > 0) { + this.PathRow(RemoteI18n.t('session.workspacePath'), this.session.workspacePath || '') + } + if (this.validTime(this.session.createdAt)) { + this.DetailRow(RemoteI18n.t('session.createdAt'), TimeFormat.relative(this.session.createdAt)) + } + if (this.validTime(this.session.updatedAt)) { + this.DetailRow(RemoteI18n.t('session.updatedAt'), TimeFormat.relative(this.session.updatedAt)) + } + this.DetailRow(RemoteI18n.t('session.messageCount'), `${Math.max(0, this.session.messageCount)}`) + if (this.session.status.length > 0) { + this.DetailRow(RemoteI18n.t('session.status'), this.statusLabel()) + } + } + .width('100%') + .padding({ left: 20, right: 20, top: 8, bottom: 24 }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + .borderRadius(16) + } + + @Builder + private DetailRow(label: string, value: string) { + Row({ space: 16 }) { + Text(label) + .width(104) + .fontSize(13) + .fontColor(MUTED) + Text(value) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + .textAlign(TextAlign.End) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .constraintSize({ minHeight: 52 }) + .padding({ top: 8, bottom: 8 }) + .border({ width: { bottom: 1 }, color: LINE }) + .alignItems(VerticalAlign.Center) + } + + @Builder + private PathRow(label: string, value: string) { + Column({ space: 8 }) { + Text(label) + .width('100%') + .fontSize(13) + .fontColor(MUTED) + Text(value) + .width('100%') + .fontSize(12) + .lineHeight(18) + .fontColor(INK) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(6) + .copyOption(CopyOptions.LocalDevice) + } + .width('100%') + .padding({ top: 12, bottom: 12 }) + .border({ width: { bottom: 1 }, color: LINE }) + .alignItems(HorizontalAlign.Start) + } + + private validTime(value: string): boolean { + return !Number.isNaN(TimeFormat.timestampMs(value)); + } + + private agentTypeLabel(): string { + return this.session.agentType.length > 0 ? this.session.agentType : RemoteI18n.t('common.unknown'); + } + + private statusLabel(): string { + if (this.session.status === 'archived') { + return RemoteI18n.t('sidebar.archived'); + } + if (this.session.status === 'active') { + return RemoteI18n.t('chat.executing'); + } + return this.session.status; + } +} 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 286e4b22ed..a90edecec2 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 @@ -1,16 +1,20 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, LINE, MUTED } from './Theme'; +import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; +import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { ModelServiceSettingsPanel } from './ModelServiceSettingsPanel'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; -const SETTINGS_SHEET_BG: string = '#F4F4F7'; -const SETTINGS_ROW_VALUE: string = '#8F8F94'; - @Component export struct SettingsSheet { @Prop generalChatApiUrl: string = ''; @Prop generalChatModelName: string = ''; @Prop hasGeneralChatApiKey: boolean = false; + @Prop generalChatModelCatalog: RemoteModelCatalog = { + version: 0, + models: [], + default_models: {} + }; + @Prop selectedGeneralChatModelId: string = ''; @Prop accountUsername: string = ''; @Prop authenticatedUserId: string = ''; @Prop deviceId: string = ''; @@ -75,10 +79,11 @@ export struct SettingsSheet { .scrollBar(BarState.Off) Button() { - Image($r('app.media.settings_close_x')) - .width(28) - .height(28) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(21) + .fontColor([INK]) + .width(24) + .height(24) } .width(50) .height(50) @@ -97,7 +102,7 @@ export struct SettingsSheet { } .width('100%') .height('100%') - .backgroundColor(SETTINGS_SHEET_BG) + .backgroundColor(PAGE_BG) .borderRadius({ topLeft: 34, topRight: 34 }) } @@ -108,12 +113,14 @@ export struct SettingsSheet { Column({ space: 2 }) { Text(RemoteI18n.t('remote.settings.profile')) .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK) - Text(this.accountUsername || RemoteI18n.t('remote.settings.accountNotSignedIn')) - .fontSize(13).fontColor(SETTINGS_ROW_VALUE) + Text(this.accountUsername || (this.authenticatedUserId.length > 0 ? + RemoteI18n.t('remote.settings.accountSignedIn') : + RemoteI18n.t('remote.settings.accountNotSignedIn'))) + .fontSize(13).fontColor(MUTED) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) }.layoutWeight(1).alignItems(HorizontalAlign.Start) - Image($r('app.media.settings_chevron_right')) - .width(10).height(14).objectFit(ImageFit.Contain).opacity(0.52) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14).fontColor([MUTED]).width(18).height(18).opacity(0.72) } .width('100%').height(64).padding({ left: 18, right: 18 }) .backgroundColor(CARD).borderRadius(8).margin({ bottom: 24 }) @@ -134,7 +141,6 @@ export struct SettingsSheet { ModelServiceCard() { Column() { this.SettingsRow( - $r('app.media.settings_apps_grid'), RemoteI18n.t('settings.modelService.title'), this.modelServiceStatus(), true, @@ -176,7 +182,7 @@ export struct SettingsSheet { Blank() Text(value) .fontSize(15) - .fontColor(SETTINGS_ROW_VALUE) + .fontColor(MUTED) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } @@ -187,12 +193,13 @@ export struct SettingsSheet { } @Builder - SettingsRow(icon: Resource, title: string, value: string, showChevron: boolean, action: () => void) { + SettingsRow(title: string, value: string, showChevron: boolean, action: () => void) { Row({ space: 16 }) { - Image(icon) + SymbolGlyph($r('sys.symbol.square_grid_2x2')) + .fontSize(20) + .fontColor([MUTED]) .width(23) .height(23) - .objectFit(ImageFit.Contain) Text(title) .fontSize(16) .fontWeight(FontWeight.Medium) @@ -200,15 +207,16 @@ export struct SettingsSheet { Blank() Text(value) .fontSize(15) - .fontColor(SETTINGS_ROW_VALUE) + .fontColor(MUTED) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .constraintSize({ maxWidth: 130 }) if (showChevron) { - Image($r('app.media.settings_chevron_right')) - .width(10) - .height(14) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + .width(18) + .height(18) } } .width('100%') @@ -224,6 +232,8 @@ export struct SettingsSheet { apiUrl: this.savedGeneralChatApiUrl, modelName: this.savedGeneralChatModelName, hasApiKey: this.savedGeneralChatHasApiKey, + modelCatalog: this.generalChatModelCatalog, + selectedModelId: this.selectedGeneralChatModelId, onClose: () => { this.showModelService = false; }, @@ -254,10 +264,29 @@ export struct SettingsSheet { } private modelServiceStatus(): string { - return this.savedGeneralChatApiUrl.length > 0 && - this.savedGeneralChatModelName.length > 0 && this.savedGeneralChatHasApiKey ? - RemoteI18n.t('settings.modelService.configured') : - RemoteI18n.t('settings.modelService.notConfigured'); + const model = this.selectedGeneralChatModel(); + if (model) { + return model.model_name || model.name || model.id; + } + return RemoteI18n.t('settings.modelService.notConfigured'); + } + + private selectedGeneralChatModel(): RemoteModelConfig | undefined { + const candidates = [ + this.selectedGeneralChatModelId, + this.generalChatModelCatalog.session_model_id || '', + this.generalChatModelCatalog.default_models.primary || '' + ]; + for (let index = 0; index < candidates.length; index += 1) { + const modelId = candidates[index]; + const model = this.generalChatModelCatalog.models.find((item: RemoteModelConfig): boolean => { + return item.id === modelId && item.enabled; + }); + if (model) { + return model; + } + } + return undefined; } private syncGeneralChatConfig(apiUrl: string, modelName: string, hasApiKey: boolean): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarToggleButton.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarToggleButton.ets new file mode 100644 index 0000000000..0845df285f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarToggleButton.ets @@ -0,0 +1,49 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE, MUTED } from './Theme'; + +@ComponentV2 +export struct SidebarToggleButton { + @Param restore: boolean = false; + @Param controlSize: number = 44; + @Event onToggle: () => void = () => {}; + + build() { + Stack({ alignContent: Alignment.Center }) { + Row() { + Text('') + .width(6) + .height(14) + .backgroundColor(INK) + .opacity(0.18) + .borderRadius({ topLeft: 2, bottomLeft: 2 }) + Divider() + .vertical(true) + .height(14) + .color(INK) + .opacity(0.7) + Blank() + .layoutWeight(1) + } + .width(20) + .height(18) + .padding({ left: 2, right: 2 }) + .alignItems(VerticalAlign.Center) + .border({ width: 1.5, color: INK }) + .borderRadius(3) + } + .width(this.controlSize) + .height(this.controlSize) + .backgroundColor(this.restore ? CARD : '#00000000') + .border({ width: this.restore ? 1 : 0, color: LINE }) + .borderRadius(this.restore ? this.controlSize / 2 : 8) + .shadow({ + radius: this.restore ? 14 : 0, + color: this.restore ? '#12000000' : '#00000000', + offsetY: this.restore ? 5 : 0 + }) + .accessibilityText(RemoteI18n.t(this.restore ? 'sidebar.restore' : 'sidebar.collapse')) + .onClick(() => { + this.onToggle(); + }) + } +} 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 bc7c28ec1e..227cccd2a2 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 @@ -8,6 +8,7 @@ export struct StreamingMarkdownContent { @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 = ''; private targetText: string = ''; private timerId: number = 0; @@ -34,6 +35,9 @@ export struct StreamingMarkdownContent { text: this.renderedText, onCopyText: (_body: string) => { this.onCopyText(this.text); + }, + onOpenLink: (reference: string, label: string) => { + this.onOpenLink(reference, label); } }) } 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 431585d68d..6adc633b73 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 @@ -1,10 +1,28 @@ -export const PAGE_BG: string = '#FDFDFB'; -export const INK: string = '#171717'; -export const MUTED: string = '#706F6A'; -export const SUBTLE: string = '#A5A39B'; -export const LINE: string = '#E9E7E2'; -export const CARD: string = '#FFFFFF'; -export const ACCENT: string = '#111111'; -export const SOFT: string = '#F4F3F0'; -export const GREEN: string = '#27C46A'; -export const RED: string = '#E04F4F'; +export const PAGE_BG: ResourceColor = $r('app.color.page_bg'); +export const INK: ResourceColor = $r('app.color.ink'); +export const MUTED: ResourceColor = $r('app.color.muted'); +export const SUBTLE: ResourceColor = $r('app.color.subtle'); +export const LINE: ResourceColor = $r('app.color.line'); +export const CARD: ResourceColor = $r('app.color.card'); +export const ACCENT: ResourceColor = $r('app.color.accent'); +export const FILE_LINK: ResourceColor = $r('app.color.file_link'); +export const PRIMARY_ACTION: ResourceColor = $r('app.color.primary_action'); +export const PRIMARY_ACTION_TEXT: ResourceColor = $r('app.color.primary_action_text'); +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 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'); +export const RED: ResourceColor = $r('app.color.red'); +export const CODE_LINE_NUMBER: ResourceColor = $r('app.color.code_line_number'); +export const CODE_KEYWORD: ResourceColor = $r('app.color.code_keyword'); +export const CODE_STRING: ResourceColor = $r('app.color.code_string'); +export const CODE_NUMBER: ResourceColor = $r('app.color.code_number'); +export const CODE_COMMENT: ResourceColor = $r('app.color.code_comment'); +export const CODE_FUNCTION: ResourceColor = $r('app.color.code_function'); +export const CODE_TYPE: ResourceColor = $r('app.color.code_type'); +export const CODE_CONSTANT: ResourceColor = $r('app.color.code_constant'); +export const CODE_PROPERTY: ResourceColor = $r('app.color.code_property'); +export const CODE_TARGET_BG: ResourceColor = $r('app.color.code_target_bg'); 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 28bda5663f..bd1316fb2c 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,6 +1,7 @@ import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, LINE, MUTED, RED } from './Theme'; +import { ToolFileReference, ToolFileReferenceResolver } from '../../services/ToolFileReferenceResolver'; +import { ACCENT, CARD, FILE_LINK, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; interface QuestionPreview { header?: string; @@ -58,6 +59,8 @@ export struct ToolStatusList { @Event onCancelTool: (toolId: string) => void = (_toolId: string) => {}; @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Event onOpenFilePreview: (path: string, label: string) => void = + (_path: string, _label: string) => {}; @Local questionAnswerToolId: string = ''; @Local questionAnswerText: string = ''; @Local toolInputEditToolId: string = ''; @@ -107,21 +110,23 @@ export struct ToolStatusList { this.ToolStatusIcon(tool) Text(this.toolLineLabel(tool)) .fontSize(13) - .fontColor(this.hasToolError(tool) ? RED : MUTED) + .fontColor(this.hasToolError(tool) ? RED : + (this.toolFilePath(tool).length > 0 ? FILE_LINK : MUTED)) .layoutWeight(1) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) + .onClick(() => { + if (this.toolFilePath(tool).length > 0) { + this.openToolFile(tool); + } else { + this.toggleToolExpanded(tool, index); + } + }) this.TrailingChevron(tool, index) } .width('100%') .height(28) .alignItems(VerticalAlign.Center) - .onClick(() => { - if (this.canExpandTool(tool)) { - const key = this.toolKey(tool, index); - this.expandedToolKey = this.expandedToolKey === key ? '' : key; - } - }) if (this.canExpandTool(tool) && this.expandedToolKey === this.toolKey(tool, index)) { if (this.toolInputPreview(tool).length > 0) { @@ -139,7 +144,7 @@ export struct ToolStatusList { Row({ space: 8 }) { Text(RemoteI18n.t('chat.approve')) .fontSize(12) - .fontColor(CARD) + .fontColor(PRIMARY_ACTION_TEXT) .textAlign(TextAlign.Center) .height(32) .layoutWeight(1) @@ -154,7 +159,7 @@ export struct ToolStatusList { .textAlign(TextAlign.Center) .height(32) .layoutWeight(1) - .backgroundColor('#F0EFEB') + .backgroundColor(SOFT) .borderRadius(16) .border({ width: 1, color: LINE }) .onClick(() => { @@ -176,7 +181,7 @@ export struct ToolStatusList { .textAlign(TextAlign.Center) .height(32) .padding({ left: 14, right: 14 }) - .backgroundColor('#F0EFEB') + .backgroundColor(SOFT) .borderRadius(16) .border({ width: 1, color: LINE }) .onClick(() => { @@ -214,7 +219,14 @@ export struct ToolStatusList { @Builder TrailingChevron(tool: ConversationUiToolStatus, index: number) { if (this.canExpandTool(tool)) { - this.ChevronIcon(this.expandedToolKey === this.toolKey(tool, index) ? 'down' : 'right') + Stack({ alignContent: Alignment.Center }) { + this.ChevronIcon(this.expandedToolKey === this.toolKey(tool, index) ? 'down' : 'right') + } + .width(32) + .height(28) + .onClick(() => { + this.toggleToolExpanded(tool, index); + }) } else { Text('') .width(14) @@ -387,17 +399,17 @@ export struct ToolStatusList { .width(3.5) .height(3.5) .borderRadius(2) - .backgroundColor('#2F80ED') + .backgroundColor(ACCENT) Text('') .width(3.5) .height(3.5) .borderRadius(2) - .backgroundColor('#2F80ED') + .backgroundColor(ACCENT) Text('') .width(3.5) .height(3.5) .borderRadius(2) - .backgroundColor('#2F80ED') + .backgroundColor(ACCENT) } .width(16) .height(16) @@ -494,7 +506,7 @@ export struct ToolStatusList { .fontSize(12) .fontColor(INK) .lineHeight(17) - .backgroundColor('#FBFAF7') + .backgroundColor(SOFT) .borderRadius(14) .padding(10) .border({ width: 1, color: this.toolInputErrorForTool(tool.id || '').length > 0 ? RED : LINE }) @@ -539,11 +551,11 @@ export struct ToolStatusList { Row({ space: 8 }) { Text(RemoteI18n.t('chat.submitAnswer')) .fontSize(12) - .fontColor(this.canSubmitQuestion(tool.id || '') ? CARD : MUTED) + .fontColor(this.canSubmitQuestion(tool.id || '') ? PRIMARY_ACTION_TEXT : MUTED) .textAlign(TextAlign.Center) .height(32) .layoutWeight(1) - .backgroundColor(this.canSubmitQuestion(tool.id || '') ? ACCENT : '#EDEBE6') + .backgroundColor(this.canSubmitQuestion(tool.id || '') ? ACCENT : SOFT) .borderRadius(16) .onClick(() => { if (this.canSubmitQuestion(tool.id || '')) { @@ -592,9 +604,9 @@ export struct ToolStatusList { this.isRunningTool(tool); } - private toolRowBg(tool: ConversationUiToolStatus, index: number): string { + private toolRowBg(tool: ConversationUiToolStatus, index: number): ResourceColor { if (this.isEmphasizedToolRow(tool, index)) { - return '#FBFAF7'; + return SOFT; } return '#00000000'; } @@ -1110,108 +1122,53 @@ export struct ToolStatusList { return this.operationLabel(tool); } + private toolFileReference(tool: ConversationUiToolStatus): ToolFileReference | undefined { + return ToolFileReferenceResolver.resolve(tool.name || '', tool.tool_input, tool.input_preview || ''); + } + + private toolFilePath(tool: ConversationUiToolStatus): string { + return this.toolFileReference(tool)?.path || ''; + } + + private openToolFile(tool: ConversationUiToolStatus): void { + const reference = this.toolFileReference(tool); + if (reference) { + this.onOpenFilePreview(reference.path, reference.label); + } + } + + private toggleToolExpanded(tool: ConversationUiToolStatus, index: number): void { + if (!this.canExpandTool(tool)) { + return; + } + const key = this.toolKey(tool, index); + this.expandedToolKey = this.expandedToolKey === key ? '' : key; + } + private canExpandTool(tool: ConversationUiToolStatus): boolean { return this.hasToolError(tool) || this.isPendingConfirmation(tool) || this.isQuestionTool(tool) || this.isRunningTool(tool) || this.isCompletedTool(tool) || this.isCancelledTool(tool); } - private toolTypeColor(tool: ConversationUiToolStatus): string { - if (this.isQuestionLikeTool(tool)) { - return '#A15C00'; - } - if (this.isTodoTool(tool)) { - return '#21A85A'; - } - if (this.isTaskTool(tool)) { - return '#2D7DDF'; - } - if (this.isGitTool(tool)) { - return '#6C5CE7'; - } + private toolTypeColor(tool: ConversationUiToolStatus): ResourceColor { if (this.isDeleteTool(tool)) { return RED; } - if (this.isDiffTool(tool)) { - return '#7E62D9'; - } - if (this.isPatchTool(tool)) { - return '#C2542D'; - } - if (this.isFileCreateTool(tool)) { - return '#21A85A'; - } - if (this.isFileMutationTool(tool)) { - return '#C2542D'; - } - if (this.isFileReadTool(tool)) { - return '#7E62D9'; - } - if (this.isSearchTool(tool)) { - return '#D28A16'; - } - if (this.isWebTool(tool)) { - return '#2D7DDF'; - } - if (this.isCommandTool(tool)) { - return '#1E7A70'; + if (this.isTodoTool(tool) || this.isFileCreateTool(tool)) { + return GREEN; } return MUTED; } - private toolTypeBg(tool: ConversationUiToolStatus): string { - if (this.isQuestionLikeTool(tool)) { - return '#FFF4DE'; - } - if (this.isTodoTool(tool)) { - return '#EAF7EF'; - } - if (this.isTaskTool(tool)) { - return '#EAF3FF'; - } - if (this.isGitTool(tool)) { - return '#F0EDFF'; - } - if (this.isDeleteTool(tool)) { - return '#FFECEA'; - } - if (this.isDiffTool(tool)) { - return '#F0EDFF'; - } - if (this.isPatchTool(tool)) { - return '#FFF0EA'; - } - if (this.isFileCreateTool(tool)) { - return '#EAF7EF'; - } - if (this.isFileMutationTool(tool)) { - return '#FFF0EA'; - } - if (this.isFileReadTool(tool)) { - return '#F0EDFF'; - } - if (this.isSearchTool(tool)) { - return '#FFF4DE'; - } - if (this.isWebTool(tool)) { - return '#EAF3FF'; - } - if (this.isCommandTool(tool)) { - return '#E8F6F4'; - } - return '#F5F4F0'; + private toolTypeBg(_tool: ConversationUiToolStatus): ResourceColor { + return SOFT; } - private toolTypeBorderColor(tool: ConversationUiToolStatus): string { + private toolTypeBorderColor(tool: ConversationUiToolStatus): ResourceColor { if (this.hasToolError(tool)) { - return '#F0B3AA'; - } - if (this.isPendingConfirmation(tool) || this.isQuestionTool(tool)) { - return '#E5C98E'; - } - if (this.isRunningTool(tool)) { - return '#9FC5F8'; + return RED; } - return '#00000000'; + return LINE; } private toolStatusIcon(tool: ConversationUiToolStatus): string { @@ -1231,20 +1188,10 @@ export struct ToolStatusList { return '•'; } - private toolStatusColor(tool: ConversationUiToolStatus): string { + private toolStatusColor(tool: ConversationUiToolStatus): ResourceColor { if (this.hasToolError(tool)) { return RED; } - const normalized = (tool.status || '').toLowerCase(); - if (normalized === 'running' || normalized === 'active') { - return '#A15C00'; - } - if (normalized === 'pending_confirmation' || normalized === 'needs_confirmation') { - return '#A15C00'; - } - if (normalized === 'cancelled' || normalized === 'canceled' || normalized === 'rejected') { - return MUTED; - } return MUTED; } @@ -1256,24 +1203,12 @@ export struct ToolStatusList { this.isCancelledTool(tool); } - private summaryTypeColor(entry: ToolRenderEntry): string { - if (entry.searchCount > 0 && entry.readCount === 0) { - return '#D28A16'; - } - if (entry.readCount > 0 && entry.searchCount === 0) { - return '#7E62D9'; - } + private summaryTypeColor(_entry: ToolRenderEntry): ResourceColor { return MUTED; } - private summaryTypeBg(entry: ToolRenderEntry): string { - if (entry.searchCount > 0 && entry.readCount === 0) { - return '#FFF4DE'; - } - if (entry.readCount > 0 && entry.searchCount === 0) { - return '#F0EDFF'; - } - return '#F5F4F0'; + private summaryTypeBg(_entry: ToolRenderEntry): ResourceColor { + return SOFT; } private toolPreview(preview: string): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/host/AppRootHostAdapter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/host/AppRootHostAdapter.ets index 70952627aa..23f70cddcf 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/host/AppRootHostAdapter.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/host/AppRootHostAdapter.ets @@ -1,8 +1,11 @@ +import { common } from '@kit.AbilityKit'; + export interface AppRootHostPort { attach(context: Context, uiContext: UIContext): void; context(): Context; animate(duration: number, callback: () => void): void; showToast(message: string, duration: number): boolean; + openExternalLink?(link: string): Promise; } export class ArkUiAppRootHostAdapter implements AppRootHostPort { @@ -34,4 +37,17 @@ export class ArkUiAppRootHostAdapter implements AppRootHostPort { return false; } } + + async openExternalLink(link: string): Promise { + const value = link.trim(); + if (!/^https?:\/\//i.test(value)) { + return false; + } + try { + await (this.hostContext as common.UIAbilityContext).openLink(value); + return true; + } catch (_err) { + return false; + } + } } 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 index eac6552399..b4b50b623b 100644 --- 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 @@ -20,13 +20,15 @@ import { GeneralChatConfigSnapshot, GeneralChatConfigStore, GeneralChatConfigUpdate, - GeneralChatConfigValidator + 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, @@ -49,6 +51,8 @@ import { 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'; @@ -72,6 +76,7 @@ import { AppRootPresentation, AppRootPresentationActions, ConnectPresentationActions, + FilePreviewPresentationActions, RemoteCreatePresentationActions, RemoteHomePresentationActions, SettingsPresentationActions, @@ -100,6 +105,8 @@ 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 @@ -141,6 +148,9 @@ export class AppRootRuntime { 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(); @@ -256,7 +266,7 @@ export class AppRootRuntime { await this.reconnectActiveRemote(); }, async (session: SessionSummary): Promise => { - this.remotePageState.setActiveSession(session); + this.applyRemoteActiveSession(session); await this.loadActiveMessages(); } ) @@ -333,7 +343,7 @@ export class AppRootRuntime { this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); }, onActiveSession: (session: SessionSummary) => { - this.remotePageState.setActiveSession(session); + this.applyRemoteActiveSession(session); }, onStatusText: (statusText: string) => { this.setRemoteStatusText(statusText); @@ -399,7 +409,7 @@ export class AppRootRuntime { this.syncChatTimelineFromStore(); }, onActiveSession: (session: SessionSummary) => { - this.remotePageState.setActiveSession(session); + this.applyRemoteActiveSession(session); }, onSessionTitleChanged: (sessionId: string, title: string) => { this.remoteSessionController.updateSessionTitle(sessionId, title); @@ -431,6 +441,13 @@ export class AppRootRuntime { 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, @@ -450,7 +467,7 @@ export class AppRootRuntime { { canPoll: (sessionId: string) => { return this.activeSession.sessionId === sessionId && - this.isRoute(AppRoute.RemoteChat) && + this.isRemoteConversationContext(sessionId) && this.ensureRemoteAvailable(); }, onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { @@ -496,7 +513,7 @@ export class AppRootRuntime { (): boolean => this.isBusy, (busy: boolean): void => this.setRemoteBusy(busy), (sessionId: string): void => this.routeCreatedRemoteSession(sessionId), - (): void => this.replaceRoute(AppRoute.RemoteHome), + (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), (): void => this.stopPolling(), (): void => this.startPolling(), (sessionId: string): void => this.resetChatTimeline(sessionId), @@ -511,7 +528,7 @@ export class AppRootRuntime { sessionId, this.ensureRemoteAvailable(), (activeSessionId: string): boolean => { - return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + return this.isRemoteConversationContext(activeSessionId); } ); }, @@ -520,7 +537,7 @@ export class AppRootRuntime { await this.remoteChatCommandController.loadMessages( sessionId, (activeSessionId: string): boolean => { - return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + return this.isRemoteConversationContext(activeSessionId); } ); }, @@ -576,7 +593,7 @@ export class AppRootRuntime { async (): Promise => { await this.loadRecentWorkspacesInBackground(); }, - (route: AppRoute): void => this.replaceRoute(route), + (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), (): void => this.appShellState.setConnectSheetVisible(false), (): void => this.appShellState.setConnectSheetVisible(true) ); @@ -614,6 +631,7 @@ export class AppRootRuntime { 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(); }, @@ -624,6 +642,8 @@ export class AppRootRuntime { (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(); }, @@ -634,9 +654,13 @@ export class AppRootRuntime { (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); }, (): void => { this.openRemoteCreateSession(); }, + (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( @@ -646,6 +670,8 @@ export class AppRootRuntime { (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( @@ -663,12 +689,11 @@ export class AppRootRuntime { (session: RemoteSession): void => { this.deleteHomeSession(session); } ), new SettingsPresentationActions( - (): void => this.appShellState.setSettingsVisible(false), + (): void => this.appShellState.leaveSettings(), (): void => this.openAddConnectionFromSettings(), (): void => { this.disconnect(false); }, (): void => { this.reconnect(); }, (): void => { - this.appShellState.setSettingsVisible(false); - setTimeout(() => this.appShellState.openSettings('account'), 180); + this.appShellState.openSettings('account'); }, (relayUrl: string, username: string, password: string): Promise => this.loginCloudAccount(relayUrl, username, password), @@ -688,7 +713,7 @@ export class AppRootRuntime { // Keep connection progress on the same RemoteHome surface as the // connected state instead of showing a separate loading sheet. this.appShellState.setConnectSheetVisible(false); - this.replaceRoute(AppRoute.RemoteHome); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); this.connect(false, password || ''); }, (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, @@ -700,6 +725,12 @@ export class AppRootRuntime { (): 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; @@ -732,6 +763,7 @@ export class AppRootRuntime { await this.generalChatBootstrapController.restore(this.host.context()); await this.cloudAccountSessionStore.init(this.host.context()); await this.restoreCloudAccountSession(); + await this.refreshGeneralChatModelCatalog(); await this.restoreIdentity(); } @@ -756,6 +788,7 @@ export class AppRootRuntime { this.stopGeneralChatStream(true, 'failed'); this.generalChatDraftLifecycleController.cancel(); this.remoteFileDownloadController.cancel(); + this.remoteFilePreviewController.close(); this.voiceInputLifecycleController.cancel(`${this.currentRoute()}`, () => { this.setAllVoiceListening(false); }); @@ -770,6 +803,9 @@ export class AppRootRuntime { } visibleChatInput(): string { + if (this.currentRoute() === AppRoute.RemoteCreate) { + return this.remoteCreateState.draft; + } return AppRootRouteState.chatInput(this.currentRoute(), this.generalChatPageState, this.remotePageState); } @@ -778,10 +814,17 @@ export class AppRootRuntime { } 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); } @@ -802,6 +845,10 @@ export class AppRootRuntime { } setVoiceListeningForRoute(route: AppRoute, isVoiceListening: boolean): void { + if (route === AppRoute.RemoteCreate) { + this.remoteCreateState.isVoiceListening = isVoiceListening; + return; + } AppRootRouteState.setVoiceListening( route, isVoiceListening, @@ -816,6 +863,15 @@ export class AppRootRuntime { } 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(), @@ -845,6 +901,7 @@ export class AppRootRuntime { } private routeCreatedRemoteSession(sessionId: string): void { + this.closeFilePreview(); if (this.isRoute(AppRoute.RemoteCreate)) { this.appShellViewModel.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); return; @@ -852,7 +909,26 @@ export class AppRootRuntime { 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(); @@ -869,6 +945,14 @@ export class AppRootRuntime { 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); @@ -898,8 +982,13 @@ export class AppRootRuntime { 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); @@ -970,6 +1059,16 @@ export class AppRootRuntime { ); } + 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; @@ -1016,6 +1115,7 @@ export class AppRootRuntime { } async disconnect(clearPairing: boolean): Promise { + this.invalidateFilePreviewTarget(); await this.remoteConnectionViewModel.disconnect(clearPairing); } @@ -1099,10 +1199,12 @@ export class AppRootRuntime { } async selectWorkspace(path: string): Promise { + this.closeFilePreview(); await this.remoteWorkspaceViewModel.selectWorkspace(path); } async selectAssistant(path: string): Promise { + this.closeFilePreview(); await this.remoteWorkspaceViewModel.selectAssistant(path); } @@ -1155,7 +1257,7 @@ export class AppRootRuntime { } if (RemoteUiState.canUseRemote(this.connectionState)) { this.appShellState.setConnectSheetVisible(false); - this.pushRoute(AppRoute.RemoteHome); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); return; } this.appShellState.setConnectSheetVisible(true); @@ -1180,14 +1282,54 @@ export class AppRootRuntime { RemoteUiState.canUseRemote(this.connectionState), activeRemoteSessionId ); - const targetSessionId = target.hasSessionParam() ? target.routeParam().sessionId : ''; - this.appShellViewModel.replaceRouteWithoutAnimation(target.name, targetSessionId); - if (target.name === AppRoute.RemoteChat) { + 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); } @@ -1213,6 +1355,7 @@ export class AppRootRuntime { 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; @@ -1228,12 +1371,33 @@ export class AppRootRuntime { 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')); @@ -1248,6 +1412,7 @@ export class AppRootRuntime { } 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); } @@ -1260,6 +1425,7 @@ export class AppRootRuntime { } async logoutCloudAccount(): Promise { + this.invalidateFilePreviewTarget(); if (this.remotePageState.controlTargetType === 'account_device') { this.remoteActivityViewModel.invalidate(); this.stopPolling(); @@ -1273,6 +1439,8 @@ export class AppRootRuntime { } this.cloudAccountSession = undefined; this.cloudAccountRelayUrl = ''; + this.generalChatConfigStore.replaceAccountModels([]); + await this.refreshGeneralChatModelCatalog(); await this.cloudAccountSessionStore.clear(); this.remotePageState.setAccountUserId(''); this.remotePageState.setAccountUsername(''); @@ -1341,6 +1509,7 @@ export class AppRootRuntime { } private async expireCloudAccountSession(): Promise { + this.invalidateFilePreviewTarget(); this.cloudAccountSession = undefined; this.cloudAccountRelayUrl = ''; await this.cloudAccountSessionStore.clear(); @@ -1383,10 +1552,11 @@ export class AppRootRuntime { this.connectionState === ConnectionState.Connected) { this.appShellState.setConnectSheetVisible(false); if (navigateHome) { - this.replaceRoute(AppRoute.RemoteHome); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); } return; } + this.invalidateFilePreviewTarget(); this.remoteActivityViewModel.invalidate(); this.remoteConnectionCoordinator.invalidate(); this.stopPolling(); @@ -1428,7 +1598,7 @@ export class AppRootRuntime { this.appShellState.setSettingsVisible(false); this.appShellState.setConnectSheetVisible(false); if (navigateHome) { - this.replaceRoute(AppRoute.RemoteHome); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); } await this.cloudAccountSessionStore.save({ relayUrl: this.cloudAccountRelayUrl, @@ -1463,6 +1633,7 @@ export class AppRootRuntime { } openHomeSession(session: RemoteSession): void { + this.closeFilePreview(); if (session.agentType === 'chat') { this.openGeneralSession(session); return; @@ -1470,6 +1641,15 @@ export class AppRootRuntime { 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); @@ -1528,8 +1708,8 @@ export class AppRootRuntime { async (sessionId: string): Promise => { return this.generalChatDraftLifecycleController.restore(sessionId); }, - (sessionId: string) => { - this.replaceRoute(AppRoute.ChatHome, sessionId); + (_sessionId: string) => { + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); } ); } @@ -1548,8 +1728,8 @@ export class AppRootRuntime { async (): Promise => { await this.generalChatDraftLifecycleController.clearHomeNow(); }, - (sessionId: string) => { - this.replaceRoute(AppRoute.ChatHome, sessionId); + (_sessionId: string) => { + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); } ); if (!created) { @@ -1579,6 +1759,7 @@ export class AppRootRuntime { } closeActiveChat(): void { + this.closeFilePreview(); this.stopVoiceInput(false); if (this.isRoute(AppRoute.GeneralChat)) { this.persistVisibleGeneralChatDraft(); @@ -1626,8 +1807,63 @@ export class AppRootRuntime { this.downloadFile(path); } - async createSession(agentType: string): Promise { - await this.remoteSessionViewModel.createSession(agentType); + 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 { @@ -1636,7 +1872,7 @@ export class AppRootRuntime { } const deviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; const deviceName = this.remotePageState.controlTargetDeviceName || this.remotePageState.desktopName; - this.remoteCreateState.prepare(deviceId, deviceName); + this.remoteCreateState.prepare(deviceId, deviceName, this.remotePageState.selectedModelId); if (deviceId.length > 0) { this.remoteCreateState.setDevices([{ deviceId, @@ -1647,9 +1883,12 @@ export class AppRootRuntime { 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); } @@ -1661,6 +1900,23 @@ export class AppRootRuntime { ]); } + 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 { @@ -1694,11 +1950,21 @@ export class AppRootRuntime { } 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'); } @@ -1743,20 +2009,41 @@ export class AppRootRuntime { 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(); - const workspacePath = this.remoteCreateState.selectedWorkspacePath; try { - if (workspacePath.length > 0) { - await this.remoteSessionViewModel.createSessionInWorkspace(workspacePath, this.workspacePath, instruction); + 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('Claw', instruction); + 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'); @@ -1769,8 +2056,20 @@ export class AppRootRuntime { } } - async createSessionInWorkspace(path: string, agentType: string = 'code'): Promise { - await this.remoteSessionViewModel.createSessionInWorkspace(path, this.workspacePath, '', agentType); + 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 { @@ -1794,8 +2093,23 @@ export class AppRootRuntime { return merged; } - async openSession(item: RemoteSession): Promise { - await this.remoteSessionViewModel.openSession(item, this.workspacePath); + 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 { @@ -1804,17 +2118,23 @@ export class AppRootRuntime { async loadActiveMessages(): Promise { await this.remoteSessionViewModel.loadActiveMessages((activeSessionId: string): boolean => { - return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + return this.isRemoteConversationContext(activeSessionId); }); } async loadModelCatalog(sessionId: string): Promise { await this.remoteSessionViewModel.loadModelCatalog(sessionId, (activeSessionId: string): boolean => { - return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + 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); } @@ -1946,7 +2266,7 @@ export class AppRootRuntime { this.generalChatPageState.clearComposer(); this.generalChatPageState.clearActiveSession(); this.resetGeneralChatTimeline(''); - this.replaceRoute(AppRoute.ChatHome); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); } onVisibleChatInputChange(route: AppRoute, value: string): void { @@ -2164,7 +2484,7 @@ export class AppRootRuntime { } applyChatSessionSnapshot(snapshot: RemoteChatPollingSnapshot): void { - if (snapshot.sessionId !== this.activeSession.sessionId || !this.isRoute(AppRoute.RemoteChat)) { + if (!this.isRemoteConversationContext(snapshot.sessionId)) { return; } this.chatTimelineStore.applySnapshot(snapshot); 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 b504065398..b9009f449f 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 @@ -1,10 +1,18 @@ +import { AppRoute } from '../navigation/AppRouteContract'; + @ObservedV2 export class AppShellState { + /** + * Mirror of the navigation stack top. NavPathStack is not observable, so + * surfaces that live outside Navigation — the drawer above all — have no way + * to follow the route without this traced copy. + */ + @Trace activeRoute: AppRoute = AppRoute.ChatHome; @Trace showSidebar: boolean = false; @Trace showSettings: boolean = false; - @Trace showAccount: boolean = false; @Trace settingsMode: string = 'general'; @Trace showConnectSheet: boolean = false; + private accountReturnMode: string = ''; setSidebarVisible(visible: boolean): void { this.showSidebar = visible; @@ -13,14 +21,28 @@ export class AppShellState { setSettingsVisible(visible: boolean): void { this.showSettings = visible; if (!visible) { - this.showAccount = false; + this.accountReturnMode = ''; } } openSettings(mode: string): void { + if (mode === 'account') { + this.accountReturnMode = this.showSettings ? this.settingsMode : ''; + } else { + this.accountReturnMode = ''; + } this.settingsMode = mode; - this.showSettings = mode !== 'account'; - this.showAccount = mode === 'account'; + this.showSettings = true; + } + + leaveSettings(): void { + if (this.settingsMode === 'account' && this.accountReturnMode.length > 0) { + this.settingsMode = this.accountReturnMode; + this.accountReturnMode = ''; + this.showSettings = true; + return; + } + this.setSettingsVisible(false); } setConnectSheetVisible(visible: boolean): void { @@ -30,7 +52,7 @@ export class AppShellState { closeGlobalSurfaces(): void { this.showSidebar = false; this.showSettings = false; - this.showAccount = false; + this.accountReturnMode = ''; this.showConnectSheet = false; } } 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/state/AppShellViewModel.ets index a656908488..53bf57347e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets @@ -34,9 +34,10 @@ export class AppShellViewModel { } if (spec.hasSessionParam()) { this.navigationStack.pushPath({ name: spec.name, param: spec.routeParam() }, animated); - return; + } else { + this.navigationStack.pushPath({ name: spec.name }, animated); } - this.navigationStack.pushPath({ name: spec.name }, animated); + this.syncActiveRoute(); } replaceRoute(route: AppRoute, sessionId: string = ''): void { @@ -44,13 +45,18 @@ export class AppShellViewModel { if (route !== AppRoute.ChatHome) { this.pushRoute(route, sessionId); } + this.syncActiveRoute(); } replaceRouteWithoutAnimation(route: AppRoute, sessionId: string = ''): void { + if (this.currentRoute() === route && sessionId.length === 0) { + return; + } this.navigationStack.clear(false); if (route !== AppRoute.ChatHome) { this.pushRoute(route, sessionId, false); } + this.syncActiveRoute(); } replaceCurrentRoute(route: AppRoute, sessionId: string = ''): void { @@ -63,19 +69,29 @@ export class AppShellViewModel { name: route, param: AppRouteContract.routeParam(sessionId) }, false); - return; + } else { + this.navigationStack.replacePath({ name: route }, false); } - this.navigationStack.replacePath({ name: route }, false); + this.syncActiveRoute(); } popRoute(fallback: AppRoute): void { if (this.navigationStack.getAllPathName().length > 0) { this.navigationStack.pop(); + this.syncActiveRoute(); return; } this.replaceRoute(fallback); } + /** + * Republish the stack top as traced state. Every navigation goes through this + * view model, so this is the one place the mirror can be kept honest. + */ + private syncActiveRoute(): void { + this.state.activeRoute = this.currentRoute(); + } + backAction(route: AppRoute): AppNavigationBackAction { return AppRouteContract.backAction(route, this.state.showSidebar); } 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/state/ConversationIntentDispatcher.ets index 44a277409d..0c2c8bbf7e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets @@ -2,6 +2,7 @@ import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteMo import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; import { ConversationIntent, ConversationIntentType } from '../components/ConversationIntent'; import { toRemoteQuestionAnswer } from '../components/ConversationUiModels'; +import { FilePreviewRequest } from './FilePreviewTarget'; export class ConversationIntentDispatcherHooks { readonly openSidebar: () => void; @@ -29,6 +30,7 @@ export class ConversationIntentDispatcherHooks { readonly selectModel: (modelId: string) => Promise; readonly pickImages: () => Promise; readonly removeImage: (id: string) => void; + readonly openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void; readonly downloadFile: (path: string) => void; readonly send: () => Promise; readonly voiceInput: () => Promise; @@ -45,7 +47,8 @@ export class ConversationIntentDispatcherHooks { 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, downloadFile: (path: string) => void, + 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; @@ -56,7 +59,8 @@ export class ConversationIntentDispatcherHooks { 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.downloadFile = downloadFile; this.send = send; this.voiceInput = voiceInput; this.inputChanged = inputChanged; + this.openFilePreview = openFilePreview; this.downloadFile = downloadFile; this.send = send; + this.voiceInput = voiceInput; this.inputChanged = inputChanged; } } @@ -101,6 +105,8 @@ export class ConversationIntentDispatcher { case ConversationIntentType.SelectModel: void this.hooks.selectModel(intent.value); return; case ConversationIntentType.PickImages: void this.hooks.pickImages(); return; case ConversationIntentType.RemoveImage: this.hooks.removeImage(intent.value); return; + case ConversationIntentType.OpenFilePreview: + if (intent.filePreviewRequest) this.hooks.openFilePreview(route, intent.filePreviewRequest); return; case ConversationIntentType.DownloadFile: this.hooks.downloadFile(intent.value); return; case ConversationIntentType.Send: void this.hooks.send(); return; case ConversationIntentType.VoiceInput: void this.hooks.voiceInput(); return; 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/state/ConversationLayoutPolicy.ets index d238bc58d7..f6a011f6d8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets @@ -14,19 +14,25 @@ export class ConversationLayoutGeometry { readonly isExtraWide: boolean; readonly detailContentOffset: number; readonly detailContentWidth: number; + readonly collapsedDetailContentOffset: number; + readonly collapsedDetailContentWidth: number; constructor( masterPaneWidth: number, masterDetailGap: number, isExtraWide: boolean, detailContentOffset: number, - detailContentWidth: number + detailContentWidth: number, + collapsedDetailContentOffset: number, + collapsedDetailContentWidth: number ) { this.masterPaneWidth = masterPaneWidth; this.masterDetailGap = masterDetailGap; this.isExtraWide = isExtraWide; this.detailContentOffset = detailContentOffset; this.detailContentWidth = detailContentWidth; + this.collapsedDetailContentOffset = collapsedDetailContentOffset; + this.collapsedDetailContentWidth = collapsedDetailContentWidth; } } @@ -41,28 +47,38 @@ class ConversationLayoutSegment { } export class ConversationLayoutPolicy { + static readonly TABLET_DEVICE_TYPE: string = 'tablet'; static readonly WIDE_LAYOUT_MIN_WIDTH: number = 720; static readonly FALLBACK_MASTER_PANE_WIDTH: number = 344; static readonly MIN_MASTER_PANE_WIDTH: number = 280; static readonly MIN_DETAIL_PANE_WIDTH: number = 360; static readonly EXTRA_WIDE_MIN_WIDTH: number = 1080; - static useMasterDetail(viewportWidth: number, mediaQueryMatched: boolean, isFolded: boolean): boolean { + static useMasterDetail( + viewportWidth: number, + mediaQueryMatched: boolean, + isFolded: boolean, + deviceType: string, + creases: ConversationLayoutCrease[] + ): boolean { if (isFolded) { return false; } - return mediaQueryMatched || viewportWidth >= ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH; + if (!ConversationLayoutPolicy.hasWideViewport(viewportWidth, mediaQueryMatched)) { + return false; + } + const visibleCreases = ConversationLayoutPolicy.visibleCreases(viewportWidth, creases); + if (visibleCreases.length > 0) { + return visibleCreases.length >= 2; + } + return ConversationLayoutPolicy.isTabletDevice(deviceType); } static resolveWideGeometry( viewportWidth: number, creases: ConversationLayoutCrease[] ): ConversationLayoutGeometry { - const visibleCreases = creases - .filter((crease: ConversationLayoutCrease): boolean => { - return crease.left > 0 && crease.width >= 0 && crease.left + crease.width < viewportWidth; - }) - .sort((left: ConversationLayoutCrease, right: ConversationLayoutCrease): number => left.left - right.left); + const visibleCreases = ConversationLayoutPolicy.visibleCreases(viewportWidth, creases); const firstCrease = visibleCreases.find((crease: ConversationLayoutCrease): boolean => { return crease.left >= ConversationLayoutPolicy.MIN_MASTER_PANE_WIDTH && crease.left + crease.width <= viewportWidth - ConversationLayoutPolicy.MIN_DETAIL_PANE_WIDTH; @@ -73,12 +89,17 @@ export class ConversationLayoutPolicy { const detailStart = masterPaneWidth + masterDetailGap; const detailSegments = ConversationLayoutPolicy.detailSegments(viewportWidth, detailStart, visibleCreases); const contentSegment = ConversationLayoutPolicy.widestSegment(detailSegments); + const collapsedContentSegment = ConversationLayoutPolicy.widestSegment( + ConversationLayoutPolicy.detailSegments(viewportWidth, 0, visibleCreases) + ); return new ConversationLayoutGeometry( masterPaneWidth, masterDetailGap, visibleCreases.length > 1 || viewportWidth >= ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, contentSegment ? contentSegment.left - detailStart : 0, - contentSegment ? contentSegment.width : 0 + contentSegment ? contentSegment.width : 0, + collapsedContentSegment ? collapsedContentSegment.left : 0, + collapsedContentSegment ? collapsedContentSegment.width : 0 ); } @@ -113,4 +134,23 @@ export class ConversationLayoutPolicy { return widest; }, undefined); } + + private static hasWideViewport(viewportWidth: number, mediaQueryMatched: boolean): boolean { + return mediaQueryMatched || viewportWidth >= ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH; + } + + private static isTabletDevice(deviceType: string): boolean { + return deviceType.toLowerCase() === ConversationLayoutPolicy.TABLET_DEVICE_TYPE; + } + + private static visibleCreases( + viewportWidth: number, + creases: ConversationLayoutCrease[] + ): ConversationLayoutCrease[] { + return creases + .filter((crease: ConversationLayoutCrease): boolean => { + return crease.left > 0 && crease.width >= 0 && crease.left + crease.width < viewportWidth; + }) + .sort((left: ConversationLayoutCrease, right: ConversationLayoutCrease): number => left.left - right.left); + } } 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/state/ConversationModelPresentationPolicy.ets new file mode 100644 index 0000000000..9d60fafb3e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets @@ -0,0 +1,82 @@ +import { + ConversationUiModel, + ConversationUiModelCatalog +} from '../components/ConversationUiModels'; + +export class ConversationModelPresentationPolicy { + static enabledModels(catalog: ConversationUiModelCatalog): ConversationUiModel[] { + return catalog.models.filter((model: ConversationUiModel) => model.enabled); + } + + static selectedModel( + catalog: ConversationUiModelCatalog, + selectedModelId: string + ): ConversationUiModel | undefined { + const candidates = [ + selectedModelId, + catalog.session_model_id || '', + catalog.default_models.primary || '' + ]; + for (let index = 0; index < candidates.length; index += 1) { + const modelId = candidates[index]; + if (modelId.length === 0) { + continue; + } + const model = catalog.models.find((item: ConversationUiModel) => item.id === modelId && item.enabled); + if (model) { + return model; + } + } + return undefined; + } + + static primaryLabel(model: ConversationUiModel, fallback: string): string { + const modelName = ConversationModelPresentationPolicy.cleanLabel(model.model_name || ''); + if (ConversationModelPresentationPolicy.isSpecificLabel(modelName)) { + return modelName; + } + const name = ConversationModelPresentationPolicy.cleanLabel(model.name || ''); + if (ConversationModelPresentationPolicy.isSpecificLabel(name)) { + return name; + } + const id = ConversationModelPresentationPolicy.cleanLabel(model.id || ''); + return id.length > 0 ? id : fallback; + } + + static secondaryLabel(model: ConversationUiModel, fallback: string): string { + const provider = ConversationModelPresentationPolicy.cleanLabel(model.provider || ''); + const name = ConversationModelPresentationPolicy.cleanLabel(model.name || ''); + const primary = ConversationModelPresentationPolicy.primaryLabel(model, fallback); + if (provider.length > 0 && name.length > 0 && name !== primary && name !== provider) { + return `${provider} · ${name}`; + } + if (provider.length > 0 && provider !== primary) { + return provider; + } + if (name.length > 0 && name !== primary) { + return name; + } + return model.id || primary; + } + + private static cleanLabel(value: string): string { + const trimmed = (value || '').trim(); + if (trimmed.length === 0) { + return ''; + } + const withoutScheme = trimmed.replace(/^openbitfun[:/_-]+/i, '').replace(/^anthropic[:/_-]+/i, ''); + const parts = withoutScheme.split(/[/:]/).filter((part: string) => part.length > 0); + return parts.length > 0 ? parts[parts.length - 1] : withoutScheme; + } + + private static isSpecificLabel(label: string): boolean { + const normalized = label.toLowerCase(); + return label.length > 0 && + normalized !== 'openbitfun' && + normalized !== 'anthropic' && + normalized !== 'openai' && + normalized !== 'google' && + normalized !== 'azure' && + normalized !== 'bitfun'; + } +} 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/state/ConversationSessionFilterPolicy.ets new file mode 100644 index 0000000000..0ab0183dd2 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets @@ -0,0 +1,51 @@ +import { RemoteSession } from '../../model/RemoteModels'; + +export class ConversationSessionFilterPolicy { + static matches( + session: RemoteSession, + query: string, + fallbackWorkspacePath: string, + workspaceFilter: string, + agentFilter: string, + statusFilter: string, + assistantSession: boolean + ): boolean { + if (session.id.length === 0 || session.status === 'archived') { + return false; + } + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length > 0 && session.title.toLowerCase().indexOf(normalizedQuery) < 0) { + return false; + } + const workspacePath = session.workspacePath || (assistantSession ? '' : fallbackWorkspacePath); + if (workspaceFilter.length > 0 && + !ConversationSessionFilterPolicy.workspacePathsEqual(workspacePath, workspaceFilter)) { + return false; + } + if (agentFilter.length > 0 && ConversationSessionFilterPolicy.agentGroup(session, assistantSession) !== agentFilter) { + return false; + } + const status = (session.status || '').trim().toLowerCase(); + return statusFilter.length === 0 || status === statusFilter; + } + + static agentGroup(session: RemoteSession, assistantSession: boolean): string { + if (assistantSession) { + return 'chat'; + } + return (session.agentType || '').toLowerCase() === 'cowork' ? 'cowork' : 'code'; + } + + static workspacePathsEqual(left: string, right: string): boolean { + return ConversationSessionFilterPolicy.normalizeWorkspacePath(left) === + ConversationSessionFilterPolicy.normalizeWorkspacePath(right); + } + + private static normalizeWorkspacePath(path: string): string { + let value = path.trim(); + while (value.length > 1 && (value.endsWith('/') || value.endsWith('\\'))) { + value = value.slice(0, value.length - 1); + } + return value; + } +} 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 06563fa70d..a7db3241bc 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 @@ -99,6 +99,8 @@ export class ConversationViewState { 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)); 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/state/FilePreviewPlacementPolicy.ets new file mode 100644 index 0000000000..c7da144bda --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets @@ -0,0 +1,185 @@ +import { ConversationLayoutCrease } from './ConversationLayoutPolicy'; + +export enum FilePreviewPlacement { + Hidden = 'hidden', + CompactFullPage = 'compact_full_page', + WideFocusSplit = 'wide_focus_split', + WideTriplePane = 'wide_triple_pane' +} + +export class FilePreviewLayout { + readonly placement: FilePreviewPlacement; + readonly masterPaneWidth: number; + readonly masterConversationGap: number; + readonly conversationPaneWidth: number; + readonly conversationPreviewGap: number; + readonly previewPaneWidth: number; + + constructor( + placement: FilePreviewPlacement, + masterPaneWidth: number = 0, + masterConversationGap: number = 0, + conversationPaneWidth: number = 0, + conversationPreviewGap: number = 0, + previewPaneWidth: number = 0 + ) { + this.placement = placement; + this.masterPaneWidth = masterPaneWidth; + this.masterConversationGap = masterConversationGap; + this.conversationPaneWidth = conversationPaneWidth; + this.conversationPreviewGap = conversationPreviewGap; + this.previewPaneWidth = previewPaneWidth; + } +} + +export class FilePreviewPlacementPolicy { + static readonly MIN_MASTER_WIDTH: number = 280; + static readonly MIN_CONVERSATION_WIDTH: number = 360; + static readonly MIN_PREVIEW_WIDTH: number = 360; + static readonly PANE_DIVIDER_WIDTH: number = 1; + + static resolve( + previewVisible: boolean, + largeScreenLayout: boolean, + viewportWidth: number, + creases: ConversationLayoutCrease[] + ): FilePreviewPlacement { + return FilePreviewPlacementPolicy.resolveLayout( + previewVisible, + largeScreenLayout, + viewportWidth, + creases + ).placement; + } + + static resolveLayout( + previewVisible: boolean, + largeScreenLayout: boolean, + viewportWidth: number, + creases: ConversationLayoutCrease[], + preferredMasterWidth: number = FilePreviewPlacementPolicy.MIN_MASTER_WIDTH + ): FilePreviewLayout { + if (!previewVisible) { + return new FilePreviewLayout(FilePreviewPlacement.Hidden); + } + if (!largeScreenLayout) { + return new FilePreviewLayout( + FilePreviewPlacement.CompactFullPage, + 0, + 0, + Math.max(0, viewportWidth), + 0, + Math.max(0, viewportWidth) + ); + } + const creaseLayout = FilePreviewPlacementPolicy.creaseAlignedTriplePane(viewportWidth, creases); + if (creaseLayout) { + return creaseLayout; + } + const flatLayout = FilePreviewPlacementPolicy.flatTriplePane( + viewportWidth, + creases, + preferredMasterWidth + ); + if (flatLayout) { + return flatLayout; + } + const focusGap = FilePreviewPlacementPolicy.PANE_DIVIDER_WIDTH; + const minimumFocusWidth = FilePreviewPlacementPolicy.MIN_CONVERSATION_WIDTH + + FilePreviewPlacementPolicy.MIN_PREVIEW_WIDTH + focusGap; + if (viewportWidth < minimumFocusWidth) { + return new FilePreviewLayout( + FilePreviewPlacement.CompactFullPage, + 0, + 0, + Math.max(0, viewportWidth), + 0, + Math.max(0, viewportWidth) + ); + } + const focusContentWidth = Math.max(0, viewportWidth - focusGap); + const conversationWidth = Math.floor(focusContentWidth / 2); + return new FilePreviewLayout( + FilePreviewPlacement.WideFocusSplit, + 0, + 0, + conversationWidth, + focusGap, + focusContentWidth - conversationWidth + ); + } + + private static flatTriplePane( + viewportWidth: number, + creases: ConversationLayoutCrease[], + preferredMasterWidth: number + ): FilePreviewLayout | undefined { + if (FilePreviewPlacementPolicy.visibleCreases(viewportWidth, creases).length > 0) { + return undefined; + } + const minimum = FilePreviewPlacementPolicy.MIN_MASTER_WIDTH + + FilePreviewPlacementPolicy.MIN_CONVERSATION_WIDTH + + FilePreviewPlacementPolicy.MIN_PREVIEW_WIDTH + + FilePreviewPlacementPolicy.PANE_DIVIDER_WIDTH * 2; + if (viewportWidth < minimum) { + return undefined; + } + const dividerWidth = FilePreviewPlacementPolicy.PANE_DIVIDER_WIDTH; + const maximumMasterWidth = viewportWidth - dividerWidth * 2 - + FilePreviewPlacementPolicy.MIN_CONVERSATION_WIDTH - + FilePreviewPlacementPolicy.MIN_PREVIEW_WIDTH; + const masterWidth = Math.max( + FilePreviewPlacementPolicy.MIN_MASTER_WIDTH, + Math.min(preferredMasterWidth, maximumMasterWidth) + ); + const detailWidth = viewportWidth - masterWidth - dividerWidth * 2; + const conversationWidth = Math.floor(detailWidth / 2); + return new FilePreviewLayout( + FilePreviewPlacement.WideTriplePane, + masterWidth, + dividerWidth, + conversationWidth, + dividerWidth, + detailWidth - conversationWidth + ); + } + + private static creaseAlignedTriplePane( + viewportWidth: number, + creases: ConversationLayoutCrease[] + ): FilePreviewLayout | undefined { + const visible = FilePreviewPlacementPolicy.visibleCreases(viewportWidth, creases); + if (visible.length < 2) { + return undefined; + } + const first = visible[0]; + const second = visible[1]; + const masterWidth = first.left; + const conversationWidth = second.left - first.left - first.width; + const previewWidth = viewportWidth - second.left - second.width; + if (masterWidth < FilePreviewPlacementPolicy.MIN_MASTER_WIDTH || + conversationWidth < FilePreviewPlacementPolicy.MIN_CONVERSATION_WIDTH || + previewWidth < FilePreviewPlacementPolicy.MIN_PREVIEW_WIDTH) { + return undefined; + } + return new FilePreviewLayout( + FilePreviewPlacement.WideTriplePane, + masterWidth, + first.width, + conversationWidth, + second.width, + previewWidth + ); + } + + private static visibleCreases( + viewportWidth: number, + creases: ConversationLayoutCrease[] + ): ConversationLayoutCrease[] { + return creases + .filter((crease: ConversationLayoutCrease): boolean => { + return crease.left > 0 && crease.width >= 0 && crease.left + crease.width < viewportWidth; + }) + .sort((left: ConversationLayoutCrease, right: ConversationLayoutCrease): number => left.left - right.left); + } +} 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 new file mode 100644 index 0000000000..88351095ff --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets @@ -0,0 +1,107 @@ +import { FilePreviewTarget } from './FilePreviewTarget'; + +export enum FilePreviewPhase { + Idle = 'idle', + Loading = 'loading', + Ready = 'ready', + Unsupported = 'unsupported', + Error = 'error' +} + +export enum FilePreviewRendererKind { + Text = 'text', + Markdown = 'markdown', + Image = 'image', + Unsupported = 'unsupported' +} + +@ObservedV2 +export class FilePreviewState { + @Trace visible: boolean = false; + @Trace phase: FilePreviewPhase = FilePreviewPhase.Idle; + @Trace target: FilePreviewTarget = FilePreviewTarget.empty(); + @Trace fileName: string = ''; + @Trace mimeType: string = ''; + @Trace fileSize: number = 0; + @Trace rendererKind: FilePreviewRendererKind = FilePreviewRendererKind.Unsupported; + @Trace textContent: string = ''; + @Trace contentBase64: string = ''; + @Trace truncated: boolean = false; + @Trace loadedBytes: number = 0; + @Trace errorText: string = ''; + @Trace errorRetryable: boolean = true; + @Trace requestVersion: number = 0; + scrollOffsetX: number = 0; + scrollOffsetY: number = 0; + hasRecordedScroll: boolean = false; + + begin(target: FilePreviewTarget): number { + this.requestVersion += 1; + this.visible = true; + this.phase = FilePreviewPhase.Loading; + this.target = target; + this.fileName = target.displayName; + this.mimeType = ''; + this.fileSize = 0; + this.rendererKind = FilePreviewRendererKind.Unsupported; + this.textContent = ''; + this.contentBase64 = ''; + this.truncated = false; + this.loadedBytes = 0; + this.errorText = ''; + this.errorRetryable = true; + this.resetScroll(); + return this.requestVersion; + } + + close(): void { + this.requestVersion += 1; + this.visible = false; + this.phase = FilePreviewPhase.Idle; + this.target = FilePreviewTarget.empty(); + this.fileName = ''; + this.mimeType = ''; + this.fileSize = 0; + this.rendererKind = FilePreviewRendererKind.Unsupported; + this.textContent = ''; + this.contentBase64 = ''; + this.truncated = false; + this.loadedBytes = 0; + this.errorText = ''; + this.errorRetryable = true; + this.resetScroll(); + } + + isCurrent(version: number, target: FilePreviewTarget): boolean { + return this.visible && this.requestVersion === version && + this.target.remotePath === target.remotePath && + this.target.sessionId === target.sessionId && + this.target.controlTargetEpoch === target.controlTargetEpoch; + } + + recordScroll(xOffset: number, yOffset: number): void { + this.scrollOffsetX = Math.max(0, xOffset); + this.scrollOffsetY = Math.max(0, yOffset); + this.hasRecordedScroll = true; + } + + initialScrollX(): number { + return this.hasRecordedScroll ? this.scrollOffsetX : 0; + } + + initialScrollY(lineHeight: number = 19, contextLines: number = 2): number { + if (this.hasRecordedScroll) { + return this.scrollOffsetY; + } + if (this.target.lineStart <= 1) { + return 0; + } + return Math.max(0, this.target.lineStart - 1 - contextLines) * lineHeight; + } + + private resetScroll(): void { + this.scrollOffsetX = 0; + this.scrollOffsetY = 0; + this.hasRecordedScroll = false; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets new file mode 100644 index 0000000000..7bc0ffb89a --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets @@ -0,0 +1,62 @@ +export class FilePreviewTarget { + readonly kind: string; + readonly rawReference: string; + readonly remotePath: string; + readonly displayName: string; + readonly sessionId: string; + readonly workspacePath: string; + readonly controlTargetEpoch: number; + readonly lineStart: number; + readonly lineEnd: number; + + constructor( + rawReference: string, + remotePath: string, + displayName: string, + sessionId: string, + workspacePath: string, + controlTargetEpoch: number, + lineStart: number = 0, + lineEnd: number = 0 + ) { + this.kind = 'remote_workspace_file'; + this.rawReference = rawReference; + this.remotePath = remotePath; + this.displayName = displayName; + this.sessionId = sessionId; + this.workspacePath = workspacePath; + this.controlTargetEpoch = controlTargetEpoch; + this.lineStart = lineStart; + this.lineEnd = lineEnd; + } + + static empty(): FilePreviewTarget { + return new FilePreviewTarget('', '', '', '', '', 0); + } + + isValid(): boolean { + return this.remotePath.length > 0 && this.sessionId.length > 0; + } +} + +export class FilePreviewTargetContext { + readonly sessionId: string; + readonly workspacePath: string; + readonly controlTargetEpoch: number; + + constructor(sessionId: string, workspacePath: string, controlTargetEpoch: number) { + this.sessionId = sessionId; + this.workspacePath = workspacePath; + this.controlTargetEpoch = controlTargetEpoch; + } +} + +export class FilePreviewRequest { + readonly reference: string; + readonly label: string; + + constructor(reference: string, label: string) { + this.reference = reference; + this.label = label; + } +} 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 f783456fcf..5c4db437fe 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 @@ -1,4 +1,10 @@ -import { ChatMessage, RemoteSession, SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; +import { + ChatMessage, + RemoteModelCatalog, + RemoteSession, + SelectedImageAttachment, + SessionSummary +} from '../../model/RemoteModels'; import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; import { GeneralChatServiceState } from '../../services/general-chat/GeneralChatServiceState'; import { RemoteUiState } from '../../services/RemoteUiState'; @@ -18,6 +24,8 @@ export class GeneralChatPageState { @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[] = []; @@ -89,6 +97,16 @@ export class GeneralChatPageState { this.serviceState = serviceState; } + 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; + } + setStatus(statusText: string): void { this.statusText = statusText; } 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 dd42dad127..27e2d77f75 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 @@ -1,6 +1,18 @@ import { RecentWorkspaceEntry } from '../../model/RemoteModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; +export class RemoteCreateSessionContext { + readonly deviceId: string; + readonly workspacePath: string; + readonly agentType: string; + + constructor(deviceId: string, workspacePath: string, agentType: string = 'Claw') { + this.deviceId = deviceId; + this.workspacePath = workspacePath; + this.agentType = agentType; + } +} + @ObservedV2 export class RemoteCreateSessionState { @Trace draft: string = ''; @@ -14,9 +26,11 @@ export class RemoteCreateSessionState { @Trace isLoadingDevices: boolean = false; @Trace isLoadingWorkspaces: boolean = false; @Trace isSubmitting: boolean = false; + @Trace isVoiceListening: boolean = false; + @Trace selectedModelId: string = ''; @Trace errorText: string = ''; - prepare(deviceId: string, deviceName: string): void { + prepare(deviceId: string, deviceName: string, selectedModelId: string = ''): void { this.draft = ''; this.devices = []; this.workspaces = []; @@ -28,6 +42,8 @@ export class RemoteCreateSessionState { this.isLoadingDevices = false; this.isLoadingWorkspaces = false; this.isSubmitting = false; + this.isVoiceListening = false; + this.selectedModelId = selectedModelId; this.errorText = ''; } @@ -36,13 +52,30 @@ export class RemoteCreateSessionState { this.errorText = ''; } + setSelectedModelId(modelId: string): void { + this.selectedModelId = modelId; + this.errorText = ''; + } + setDevices(devices: CloudAccountDevice[]): void { this.devices = devices.slice(); + const selected = devices.find((device: CloudAccountDevice): boolean => + device.deviceId === this.selectedDeviceId + ); + if (selected) { + this.selectedDeviceName = selected.deviceName; + } this.isLoadingDevices = false; } setWorkspaces(workspaces: RecentWorkspaceEntry[]): void { this.workspaces = workspaces.slice(); + const selected = workspaces.find((workspace: RecentWorkspaceEntry): boolean => + workspace.path === this.selectedWorkspacePath + ); + if (selected) { + this.selectedWorkspaceName = selected.name; + } this.isLoadingWorkspaces = false; } @@ -61,6 +94,10 @@ export class RemoteCreateSessionState { this.errorText = ''; } + submissionContext(): RemoteCreateSessionContext { + return new RemoteCreateSessionContext(this.selectedDeviceId, this.selectedWorkspacePath); + } + clearWorkspace(): void { this.selectedWorkspacePath = ''; this.selectedWorkspaceName = ''; 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/state/RemoteSessionViewModel.ets index afba61c242..fbedef6d7d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets @@ -109,7 +109,12 @@ export class RemoteSessionViewModel { await this.refreshSessions(); } - async createSession(agentType: string, instruction: string = ''): Promise { + async createSession( + agentType: string, + instruction: string = '', + onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat, + modelId: string = '' + ): Promise { await this.sessions.create( agentType, this.hooks.isBusy(), @@ -119,13 +124,14 @@ export class RemoteSessionViewModel { this.pageState.setHasMoreMessages(false); this.hooks.onKnownStateReset(); this.hooks.onResetTimeline(session.sessionId); - this.hooks.onRouteChat(session.sessionId); + onRouteChat(session.sessionId); await this.hooks.onLoadModelCatalog(session.sessionId); await this.refreshSessions(); await this.hooks.onLoadActiveMessages(); this.hooks.onStartPolling(); }, - instruction + instruction, + modelId ); } @@ -133,19 +139,25 @@ export class RemoteSessionViewModel { path: string, currentPath: string, instruction: string = '', - agentType: string = 'code' + agentType: string = 'code', + onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat, + modelId: string = '' ): Promise { if (path.length > 0 && path !== currentPath) { await this.hooks.onSelectWorkspace(path); - await this.createSession(agentType, instruction); + await this.createSession(agentType, instruction, onRouteChat, modelId); return; } if (path.length === 0 || path === currentPath) { - await this.createSession(agentType, instruction); + await this.createSession(agentType, instruction, onRouteChat, modelId); } } - async openSession(item: RemoteSession, currentWorkspacePath: string): Promise { + async openSession( + item: RemoteSession, + currentWorkspacePath: string, + onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat + ): Promise { await this.sessions.open( item, item.workspacePath || currentWorkspacePath, @@ -158,7 +170,7 @@ export class RemoteSessionViewModel { this.pageState.setHasMoreMessages(false); this.files.clear(); this.pageState.clearComposer(); - this.hooks.onRouteChat(item.id); + onRouteChat(item.id); await this.hooks.onLoadModelCatalog(item.id); await this.hooks.onLoadActiveMessages(); if (this.pageState.activeSession.sessionId === session.sessionId) { 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/state/SessionActionPolicy.ets new file mode 100644 index 0000000000..ddfeb71439 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets @@ -0,0 +1,31 @@ +export enum SessionActionScope { + General = 'general', + Remote = 'remote' +} + +export class SessionActionCapabilities { + readonly canViewDetails: boolean; + readonly canArchive: boolean; + readonly canExport: boolean; + readonly canDelete: boolean; + + constructor(canViewDetails: boolean, canArchive: boolean, canExport: boolean, canDelete: boolean) { + this.canViewDetails = canViewDetails; + this.canArchive = canArchive; + this.canExport = canExport; + this.canDelete = canDelete; + } +} + +export class SessionActionPolicy { + static resolve(scope: SessionActionScope, agentType: string, busy: boolean): SessionActionCapabilities { + if (busy) { + return new SessionActionCapabilities(false, false, false, false); + } + if (scope === SessionActionScope.Remote) { + return new SessionActionCapabilities(true, false, false, true); + } + const isGeneralChat = agentType.toLowerCase() === 'chat'; + return new SessionActionCapabilities(true, isGeneralChat, isGeneralChat, true); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets index fc228c8b9b..05a894ef71 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets @@ -13,6 +13,7 @@ export interface ChatTimelineItem { message?: ChatMessage; isStreaming: boolean; isFinalizing: boolean; + showRetryAction?: boolean; } /** Shared render invalidation for both remote polling and general-chat streaming. */ @@ -66,7 +67,8 @@ export class ChatTimelineProjector { type: ChatTimelineProjector.messageItemType(message), message, isStreaming: false, - isFinalizing: false + isFinalizing: false, + showRetryAction: false }; return item; }); @@ -77,7 +79,8 @@ export class ChatTimelineProjector { type: 'optimistic_user_message', message, isStreaming: false, - isFinalizing: false + isFinalizing: false, + showRetryAction: false }; items.push(item); }); @@ -89,7 +92,8 @@ export class ChatTimelineProjector { type: 'assistant_live_turn', message: activeTurn, isStreaming: (activeTurn.status || '').toLowerCase() === 'active', - isFinalizing: (activeTurn.status || '').toLowerCase() === 'completed' + isFinalizing: (activeTurn.status || '').toLowerCase() === 'completed', + showRetryAction: false }; items.push(item); } @@ -99,14 +103,27 @@ export class ChatTimelineProjector { id: 'empty-state', type: 'empty_state', isStreaming: false, - isFinalizing: false + isFinalizing: false, + showRetryAction: false }; items.push(item); } + ChatTimelineProjector.markLatestFailedMessageRetryable(items); return items; } + private static markLatestFailedMessageRetryable(items: ChatTimelineItem[]): void { + for (let index = items.length - 1; index >= 0; index--) { + const message = items[index].message; + if (!message) { + continue; + } + items[index].showRetryAction = (message.status || '').toLowerCase() === 'failed'; + return; + } + } + static pendingMessagesNotPersisted(pendingMessages: ChatMessage[], messages: ChatMessage[]): ChatMessage[] { return pendingMessages.filter((pending: ChatMessage) => { return !messages.some((message: ChatMessage) => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets index cc5545af33..1ae3c6919b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets @@ -82,6 +82,17 @@ interface SyncSessionUpload { version: number; } +interface SyncSettingsEntry { + encrypted_data: string; + nonce: string; + version: number; +} + +export interface CloudSettingsBlob { + plaintext: string; + version: number; +} + /** Default BitFun cloud relay used by the desktop account flow. */ export const DEFAULT_CLOUD_RELAY_URL: string = 'https://remote.openbitfun.com/relay'; @@ -139,6 +150,25 @@ export class CloudAccountClient { return bundles; } + async fetchSettings(relayUrl: string, session: CloudAccountSession): Promise { + let entry: SyncSettingsEntry; + try { + entry = await this.request(relayUrl, '/api/sync/settings', 'GET', undefined, session.token); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 404) { + return undefined; + } + throw err instanceof Error ? err : new Error('Cloud settings request failed.'); + } + if (!entry || !entry.encrypted_data || !entry.nonce) { + return undefined; + } + return { + plaintext: await this.decryptSyncPayload(session.masterKey, entry.encrypted_data, entry.nonce), + version: entry.version + }; + } + async listDevices(relayUrl: string, session: CloudAccountSession): Promise { const devices = await this.request(relayUrl, '/api/devices', 'GET', undefined, session.token); return devices.map((device: CloudAccountDeviceWire): CloudAccountDevice => ({ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CodeSyntaxHighlighter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CodeSyntaxHighlighter.ets new file mode 100644 index 0000000000..e04aaa1055 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CodeSyntaxHighlighter.ets @@ -0,0 +1,422 @@ +export enum CodeSyntaxTokenKind { + Plain = 'plain', + LineNumber = 'line-number', + Keyword = 'keyword', + String = 'string', + Number = 'number', + Comment = 'comment', + Function = 'function', + Type = 'type', + Constant = 'constant', + Property = 'property' +} + +export class CodeSyntaxToken { + readonly id: string; + readonly text: string; + readonly kind: CodeSyntaxTokenKind; + readonly lineNumber: number; + + constructor(id: string, text: string, kind: CodeSyntaxTokenKind, lineNumber: number = 0) { + this.id = id; + this.text = text; + this.kind = kind; + this.lineNumber = lineNumber; + } +} + +export class CodeSyntaxHighlightCache { + private text: string = ''; + private fileName: string = ''; + private tokens: CodeSyntaxToken[] = []; + private initialized: boolean = false; + + tokensFor(text: string, fileName: string): CodeSyntaxToken[] { + if (this.initialized && this.text === text && this.fileName === fileName) { + return this.tokens; + } + this.text = text; + this.fileName = fileName; + this.tokens = CodeSyntaxHighlighter.tokenize(text, fileName); + this.initialized = true; + return this.tokens; + } +} + +const HIGHLIGHT_MAX_CHARACTERS: number = 256 * 1024; +const HIGHLIGHT_MAX_TOKENS: number = 12000; + +/** + * A bounded lexical colorizer for the native preview surface. It deliberately + * avoids building a full syntax tree and falls back to one plain span for + * large inputs so ArkUI does not have to mount thousands of child spans. + */ +export class CodeSyntaxHighlighter { + static tokenize(text: string, fileName: string): CodeSyntaxToken[] { + const extension = CodeSyntaxHighlighter.extension(fileName); + if (!CodeSyntaxHighlighter.supports(extension) || text.length > HIGHLIGHT_MAX_CHARACTERS) { + return CodeSyntaxHighlighter.plain(text); + } + + const tokens: CodeSyntaxToken[] = []; + const lineCount = CodeSyntaxHighlighter.lineCount(text); + const lineNumberWidth = String(lineCount).length; + let lineNumber = 1; + let index = 0; + let tokenIndex = 0; + let needsLineNumber = true; + + while (index < text.length) { + if (needsLineNumber) { + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + CodeSyntaxHighlighter.linePrefix(lineNumber, lineNumberWidth), + CodeSyntaxTokenKind.LineNumber, + lineNumber + )); + needsLineNumber = false; + } + + const character = text.charAt(index); + if (character === '\n') { + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, '\n', CodeSyntaxTokenKind.Plain, lineNumber + )); + index += 1; + lineNumber += 1; + needsLineNumber = true; + continue; + } + + const lineComment = CodeSyntaxHighlighter.lineCommentAt(text, index, extension); + if (lineComment.length > 0) { + const end = CodeSyntaxHighlighter.lineEnd(text, index); + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + text.slice(index, end), + CodeSyntaxTokenKind.Comment, + lineNumber + )); + index = end; + continue; + } + + const blockCommentEnd = CodeSyntaxHighlighter.blockCommentEnd(text, index, extension); + if (blockCommentEnd > index) { + const block = text.slice(index, blockCommentEnd); + CodeSyntaxHighlighter.pushMultilineToken( + tokens, + block, + CodeSyntaxTokenKind.Comment, + lineNumberWidth, + lineNumber, + tokenIndex + ); + const consumedLines = CodeSyntaxHighlighter.newlineCount(block); + tokenIndex = tokens.length; + lineNumber += consumedLines; + needsLineNumber = block.endsWith('\n'); + index = blockCommentEnd; + continue; + } + + if (character === '\'' || character === '"' || character === '`') { + const end = CodeSyntaxHighlighter.stringEnd(text, index, character); + const value = text.slice(index, end); + CodeSyntaxHighlighter.pushMultilineToken( + tokens, + value, + CodeSyntaxTokenKind.String, + lineNumberWidth, + lineNumber, + tokenIndex + ); + const consumedLines = CodeSyntaxHighlighter.newlineCount(value); + tokenIndex = tokens.length; + lineNumber += consumedLines; + needsLineNumber = value.endsWith('\n'); + index = end; + continue; + } + + if (CodeSyntaxHighlighter.isDigit(character)) { + const end = CodeSyntaxHighlighter.numberEnd(text, index); + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + text.slice(index, end), + CodeSyntaxTokenKind.Number, + lineNumber + )); + index = end; + continue; + } + + if (CodeSyntaxHighlighter.isIdentifierStart(character)) { + const end = CodeSyntaxHighlighter.identifierEnd(text, index); + const word = text.slice(index, end); + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + word, + CodeSyntaxHighlighter.identifierKind(text, end, word, extension), + lineNumber + )); + index = end; + continue; + } + + const end = CodeSyntaxHighlighter.plainEnd(text, index); + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + text.slice(index, end), + CodeSyntaxTokenKind.Plain, + lineNumber + )); + index = end; + + if (tokens.length > HIGHLIGHT_MAX_TOKENS) { + return CodeSyntaxHighlighter.plain(text); + } + } + + if (text.length === 0 || needsLineNumber) { + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex}`, + CodeSyntaxHighlighter.linePrefix(lineNumber, lineNumberWidth), + CodeSyntaxTokenKind.LineNumber, + lineNumber + )); + } + return tokens; + } + + private static pushMultilineToken( + tokens: CodeSyntaxToken[], + value: string, + kind: CodeSyntaxTokenKind, + lineNumberWidth: number, + firstLineNumber: number, + firstTokenIndex: number + ): void { + const parts = value.split('\n'); + let lineNumber = firstLineNumber; + let tokenIndex = firstTokenIndex; + parts.forEach((part: string, index: number) => { + if (part.length > 0) { + tokens.push(new CodeSyntaxToken(`syntax-${tokenIndex++}`, part, kind, lineNumber)); + } + if (index < parts.length - 1) { + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, '\n', CodeSyntaxTokenKind.Plain, lineNumber + )); + lineNumber += 1; + if (index < parts.length - 2) { + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + CodeSyntaxHighlighter.linePrefix(lineNumber, lineNumberWidth), + CodeSyntaxTokenKind.LineNumber, + lineNumber + )); + } + } + }); + } + + private static plain(text: string): CodeSyntaxToken[] { + return [new CodeSyntaxToken('syntax-plain', CodeSyntaxHighlighter.numberedText(text), CodeSyntaxTokenKind.Plain)]; + } + + private static numberedText(text: string): string { + const lines = text.split('\n'); + const width = String(lines.length).length; + return lines.map((line: string, index: number): string => { + return `${CodeSyntaxHighlighter.linePrefix(index + 1, width)}${line}`; + }).join('\n'); + } + + private static linePrefix(lineNumber: number, width: number): string { + let value = String(lineNumber); + while (value.length < width) { + value = ` ${value}`; + } + return `${value} `; + } + + private static identifierKind( + text: string, + end: number, + word: string, + extension: string + ): CodeSyntaxTokenKind { + if (CodeSyntaxHighlighter.isKeyword(word, extension)) { + return CodeSyntaxTokenKind.Keyword; + } + if ('|true|false|null|undefined|none|nil|self|this|super|'.indexOf(`|${word.toLowerCase()}|`) >= 0) { + return CodeSyntaxTokenKind.Constant; + } + const next = CodeSyntaxHighlighter.nextNonWhitespace(text, end); + if (next === '(' || next === '!') { + return CodeSyntaxTokenKind.Function; + } + if (next === ':') { + return CodeSyntaxTokenKind.Property; + } + const first = word.charAt(0); + if (first >= 'A' && first <= 'Z') { + return CodeSyntaxTokenKind.Type; + } + return CodeSyntaxTokenKind.Plain; + } + + private static isKeyword(word: string, extension: string): boolean { + const normalized = word.toLowerCase(); + let keywords = '|if|else|for|while|do|switch|case|break|continue|return|throw|try|catch|finally|new|in|of|'; + if ('|js|jsx|ts|tsx|mjs|cjs|ets|vue|svelte|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'const|let|var|function|class|extends|implements|interface|type|enum|import|export|from|as|async|await|yield|default|delete|instanceof|typeof|void|public|private|protected|readonly|static|get|set|declare|namespace|'; + } else if ('|rs|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'fn|let|mut|struct|enum|impl|trait|use|mod|pub|crate|where|match|move|ref|async|await|dyn|unsafe|extern|const|static|type|loop|'; + } else if ('|py|pyw|pyi|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'def|class|import|from|as|lambda|with|async|await|yield|raise|pass|global|nonlocal|assert|del|elif|except|finally|is|not|and|or|'; + } else if ('|go|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'func|package|import|defer|go|select|chan|map|range|struct|interface|type|var|const|fallthrough|'; + } else if ('|java|kt|kts|scala|groovy|c|cpp|cc|cxx|h|hpp|hxx|hh|cs|swift|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'class|struct|interface|enum|namespace|using|import|package|public|private|protected|static|final|virtual|override|abstract|const|var|val|fun|func|operator|template|typename|extends|implements|throws|'; + } else if ('|sh|bash|zsh|fish|ps1|bat|cmd|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'then|fi|elif|done|function|select|until|export|local|readonly|declare|set|unset|'; + } else if ('|sql|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'select|insert|update|delete|create|alter|drop|from|join|inner|left|right|on|where|group|order|by|having|limit|offset|union|all|distinct|into|values|table|index|view|and|or|not|null|'; + } + return keywords.indexOf(`|${normalized}|`) >= 0; + } + + private static lineCommentAt(text: string, index: number, extension: string): string { + if ('|py|pyw|pyi|rb|sh|bash|zsh|fish|yaml|yml|toml|conf|cfg|ini|'.indexOf(`|${extension}|`) >= 0 && + text.charAt(index) === '#') { + return '#'; + } + if (extension === 'sql' && text.slice(index, index + 2) === '--') { + return '--'; + } + if (text.slice(index, index + 2) === '//') { + return '//'; + } + return ''; + } + + private static blockCommentEnd(text: string, index: number, extension: string): number { + if (text.slice(index, index + 4) === '', index + 4); + return htmlEnd >= 0 ? htmlEnd + 3 : text.length; + } + if (extension !== 'py' && text.slice(index, index + 2) === '/*') { + const end = text.indexOf('*/', index + 2); + return end >= 0 ? end + 2 : text.length; + } + return index; + } + + private static stringEnd(text: string, start: number, quote: string): number { + let index = start + 1; + let escaped = false; + while (index < text.length) { + const character = text.charAt(index); + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === quote) { + return index + 1; + } else if (character === '\n' && quote !== '`') { + return index; + } + index += 1; + } + return text.length; + } + + private static numberEnd(text: string, start: number): number { + let index = start + 1; + while (index < text.length) { + const character = text.charAt(index); + if (!CodeSyntaxHighlighter.isDigit(character) && character !== '.' && character !== '_' && + character.toLowerCase() !== 'x' && character.toLowerCase() !== 'b' && + !(character.toLowerCase() >= 'a' && character.toLowerCase() <= 'f')) { + break; + } + index += 1; + } + return index; + } + + private static identifierEnd(text: string, start: number): number { + let index = start + 1; + while (index < text.length && CodeSyntaxHighlighter.isIdentifierPart(text.charAt(index))) { + index += 1; + } + return index; + } + + private static plainEnd(text: string, start: number): number { + let index = start + 1; + while (index < text.length) { + const character = text.charAt(index); + if (character === '\n' || character === '\'' || character === '"' || character === '`' || + CodeSyntaxHighlighter.isDigit(character) || CodeSyntaxHighlighter.isIdentifierStart(character) || + text.slice(index, index + 2) === '//' || text.slice(index, index + 2) === '/*' || + text.slice(index, index + 2) === '--' || character === '#') { + break; + } + index += 1; + } + return index; + } + + private static lineEnd(text: string, start: number): number { + const end = text.indexOf('\n', start); + return end >= 0 ? end : text.length; + } + + private static nextNonWhitespace(text: string, start: number): string { + let index = start; + while (index < text.length && (text.charAt(index) === ' ' || text.charAt(index) === '\t')) { + index += 1; + } + return index < text.length ? text.charAt(index) : ''; + } + + private static lineCount(text: string): number { + return CodeSyntaxHighlighter.newlineCount(text) + 1; + } + + private static newlineCount(text: string): number { + let count = 0; + for (let index = 0; index < text.length; index++) { + if (text.charAt(index) === '\n') { + count += 1; + } + } + return count; + } + + private static isDigit(character: string): boolean { + return character >= '0' && character <= '9'; + } + + private static isIdentifierStart(character: string): boolean { + return (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + character === '_' || character === '$'; + } + + private static isIdentifierPart(character: string): boolean { + return CodeSyntaxHighlighter.isIdentifierStart(character) || CodeSyntaxHighlighter.isDigit(character); + } + + private static extension(fileName: string): string { + const normalized = fileName.replace(/\\/g, '/').split('/').pop() || ''; + const dot = normalized.lastIndexOf('.'); + return dot >= 0 ? normalized.slice(dot + 1).toLowerCase() : ''; + } + + private static supports(extension: string): boolean { + return '|js|jsx|ts|tsx|mjs|cjs|ets|vue|svelte|rs|py|pyw|pyi|rb|go|java|kt|kts|scala|groovy|c|cpp|cc|cxx|h|hpp|hxx|hh|cs|swift|php|css|scss|less|json|jsonc|yaml|yml|toml|xml|html|htm|sh|bash|zsh|fish|ps1|bat|cmd|sql|graphql|gql|proto|'.indexOf(`|${extension}|`) >= 0; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewErrorPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewErrorPolicy.ets new file mode 100644 index 0000000000..ad377af76d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewErrorPolicy.ets @@ -0,0 +1,42 @@ +import { RemoteI18n } from '../i18n/RemoteI18n'; +import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; + +export class FilePreviewErrorResult { + readonly text: string; + readonly retryable: boolean; + + constructor(text: string, retryable: boolean) { + this.text = text; + this.retryable = retryable; + } +} + +export class FilePreviewErrorPolicy { + static resolve(err: Object): FilePreviewErrorResult { + const raw = err instanceof Error ? err.message : JSON.stringify(err); + const text = raw.toLowerCase(); + if (text.indexOf('file not found') >= 0 || text.indexOf('file does not exist') >= 0 || + text.indexOf('not a regular file') >= 0 || text.indexOf('no such file') >= 0) { + return new FilePreviewErrorResult(RemoteI18n.t('filePreview.notFound'), true); + } + if (text.indexOf('could not be resolved') >= 0) { + return new FilePreviewErrorResult(RemoteI18n.t('filePreview.unavailable'), true); + } + if (text.indexOf('access denied') >= 0 || text.indexOf('restricted') >= 0 || + text.indexOf('outside') >= 0) { + return new FilePreviewErrorResult(RemoteI18n.t('filePreview.accessDenied'), false); + } + if (text.indexOf('file too large') >= 0 || text.indexOf('too large') >= 0) { + return new FilePreviewErrorResult(RemoteI18n.t('filePreview.tooLarge'), false); + } + const connectionText = ConnectionErrorPolicy.errorText(err); + if (connectionText !== raw || /[\u3400-\u9fff]/.test(raw)) { + return new FilePreviewErrorResult(connectionText, true); + } + return new FilePreviewErrorResult(RemoteI18n.t('filePreview.loadFailed'), true); + } + + static errorText(err: Object): string { + return FilePreviewErrorPolicy.resolve(err).text; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewPolicy.ets new file mode 100644 index 0000000000..540d347c70 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewPolicy.ets @@ -0,0 +1,13 @@ +export class FilePreviewPolicy { + static readonly TEXT_MAX_BYTES: number = 2 * 1024 * 1024; + static readonly IMAGE_MAX_BYTES: number = 12 * 1024 * 1024; + + static textReadLimit(fileSize: number): number { + return fileSize > 0 ? Math.min(fileSize, FilePreviewPolicy.TEXT_MAX_BYTES) : + FilePreviewPolicy.TEXT_MAX_BYTES; + } + + static canPreviewImage(fileSize: number): boolean { + return fileSize <= FilePreviewPolicy.IMAGE_MAX_BYTES; + } +} 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 new file mode 100644 index 0000000000..a83708868e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets @@ -0,0 +1,155 @@ +import { FilePreviewTarget, FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { RemoteUiState } from './RemoteUiState'; + +export enum FileReferenceKind { + RemoteWorkspaceFile = 'remote_workspace_file', + HttpUrl = 'http_url', + Anchor = 'anchor', + UnsupportedScheme = 'unsupported_scheme', + Invalid = 'invalid' +} + +export class FileReferenceResolution { + readonly kind: FileReferenceKind; + readonly target?: FilePreviewTarget; + + constructor(kind: FileReferenceKind, target?: FilePreviewTarget) { + this.kind = kind; + this.target = target; + } +} + +export class FileTargetResolver { + static matchesRemotePath(reference: string, remotePath: string): boolean { + const raw = FileTargetResolver.cleanReference(reference); + if (raw.length === 0 || remotePath.length === 0 || raw.indexOf('#') === 0) { + return false; + } + const range = FileTargetResolver.extractLineRange(raw); + if (FileTargetResolver.hasUnsupportedScheme(range.path)) { + return false; + } + return RemoteUiState.normalizeRemoteFilePath(range.path) === remotePath; + } + + static resolve(reference: string, label: string, context: FilePreviewTargetContext): FileReferenceResolution { + const raw = FileTargetResolver.cleanReference(reference); + if (raw.length === 0) { + return new FileReferenceResolution(FileReferenceKind.Invalid); + } + const lower = raw.toLowerCase(); + if (lower.indexOf('http://') === 0 || lower.indexOf('https://') === 0) { + return new FileReferenceResolution(FileReferenceKind.HttpUrl); + } + if (raw.indexOf('#') === 0) { + return new FileReferenceResolution(FileReferenceKind.Anchor); + } + const range = FileTargetResolver.extractLineRange(raw); + if (FileTargetResolver.hasUnsupportedScheme(range.path)) { + return new FileReferenceResolution(FileReferenceKind.UnsupportedScheme); + } + + const remotePath = RemoteUiState.normalizeRemoteFilePath(range.path); + if (remotePath.length === 0 || remotePath === '/') { + return new FileReferenceResolution(FileReferenceKind.Invalid); + } + const displayName = label.trim().length > 0 ? label.trim() : FileTargetResolver.basename(remotePath); + return new FileReferenceResolution( + FileReferenceKind.RemoteWorkspaceFile, + new FilePreviewTarget( + raw, + remotePath, + displayName, + context.sessionId, + context.workspacePath, + context.controlTargetEpoch, + range.start, + range.end + ) + ); + } + + private static extractLineRange(reference: string): FileTargetLineRange { + const hashIndex = reference.lastIndexOf('#'); + if (hashIndex > 0) { + const parsed = FileTargetResolver.parseLineMarker(reference.slice(hashIndex + 1)); + if (parsed.start > 0) { + return new FileTargetLineRange(reference.slice(0, hashIndex), parsed.start, parsed.end); + } + } + const colon = reference.match(/^(.+):(\d+)(?:-(\d+))?$/); + if (colon && !FileTargetResolver.isWindowsDrivePrefix(colon[1])) { + return new FileTargetLineRange( + colon[1], + Number.parseInt(colon[2]), + colon[3] ? Number.parseInt(colon[3]) : 0 + ); + } + return new FileTargetLineRange(reference, 0, 0); + } + + private static parseLineMarker(marker: string): FileTargetLineRange { + const match = marker.match(/^L?(\d+)(?:-L?(\d+))?$/i); + if (!match) { + return new FileTargetLineRange('', 0, 0); + } + return new FileTargetLineRange( + '', + Number.parseInt(match[1]), + match[2] ? Number.parseInt(match[2]) : 0 + ); + } + + private static hasUnsupportedScheme(reference: string): boolean { + const scheme = reference.match(/^([A-Za-z][A-Za-z0-9+.-]*):/); + if (!scheme) { + return false; + } + if (scheme[1].length === 1 && reference.length >= 3 && + (reference.charAt(2) === '/' || reference.charAt(2) === '\\')) { + return false; + } + const lower = scheme[1].toLowerCase(); + return lower !== 'computer' && lower !== 'file'; + } + + private static isWindowsDrivePrefix(value: string): boolean { + return value.length === 1 && /[A-Za-z]/.test(value); + } + + private static cleanReference(reference: string): string { + let clean = reference.trim(); + while (clean.length > 0) { + const last = clean.charAt(clean.length - 1); + if (last === ',' || last === '.' || last === ';' || last === ':' || last === ')' || + last === ']' || last === '}' || last === '>' || last === ',' || last === '。' || + last === ';' || last === ':') { + clean = clean.slice(0, clean.length - 1); + } else { + break; + } + } + try { + return decodeURIComponent(clean); + } catch (_err) { + return clean; + } + } + + private static basename(path: string): string { + const parts = path.replace(/\\/g, '/').split('/'); + return parts[parts.length - 1] || path || 'file'; + } +} + +class FileTargetLineRange { + readonly path: string; + readonly start: number; + readonly end: number; + + constructor(path: string, start: number, end: number) { + this.path = path; + this.start = start; + this.end = end; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets index fd910fac71..bfb4ac726e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets @@ -22,6 +22,22 @@ export interface ParsedMarkdownBlock { inlines: ParsedMarkdownInline[]; } +export class MarkdownParseCache { + private text: string = ''; + private blocks: ParsedMarkdownBlock[] = []; + private initialized: boolean = false; + + blocksFor(text: string): ParsedMarkdownBlock[] { + if (this.initialized && this.text === text) { + return this.blocks; + } + this.text = text; + this.blocks = MarkdownParser.parse(text); + this.initialized = true; + return this.blocks; + } +} + export class MarkdownParser { static parse(text: string): ParsedMarkdownBlock[] { const lines = text.replace(/\r\n/g, '\n').split('\n'); 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 new file mode 100644 index 0000000000..a891801a75 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets @@ -0,0 +1,103 @@ +import { FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { FileReferenceKind, FileTargetResolver } from './FileTargetResolver'; +import { + MarkdownParser, + ParsedMarkdownBlock, + ParsedMarkdownInline, + ParsedMarkdownListItem +} from './MarkdownParser'; + +export class MessageFileReference { + readonly id: string; + readonly path: string; + readonly remotePath: string; + readonly label: string; + + constructor(id: string, path: string, remotePath: string, label: string) { + this.id = id; + this.path = path; + this.remotePath = remotePath; + this.label = label; + } +} + +export class MessageFileReferenceProjectionCache { + private source: string = ''; + private references: MessageFileReference[] = []; + private initialized: boolean = false; + + referencesFor(source: string): MessageFileReference[] { + if (this.initialized && this.source === source) { + return this.references; + } + this.source = source; + this.references = MessageFileReferenceProjector.project(source); + this.initialized = true; + return this.references; + } +} + +export class MessageFileReferenceProjector { + private static readonly CONTEXT: FilePreviewTargetContext = + new FilePreviewTargetContext('_message_projection_', '', 0); + + static project(source: string, limit: number = 4): MessageFileReference[] { + const references: MessageFileReference[] = []; + const seenRemotePaths = new Set(); + MarkdownParser.parse(source).forEach((block: ParsedMarkdownBlock) => { + MessageFileReferenceProjector.collectInlines(block.inlines, references, seenRemotePaths, limit); + block.items.forEach((item: ParsedMarkdownListItem) => { + MessageFileReferenceProjector.collectInlines(item.inlines, references, seenRemotePaths, limit); + }); + }); + return references; + } + + private static collectInlines( + inlines: ParsedMarkdownInline[], + references: MessageFileReference[], + seenRemotePaths: Set, + limit: number + ): void { + inlines.forEach((inline: ParsedMarkdownInline) => { + if (references.length >= limit) { + return; + } + if (inline.type === 'link') { + MessageFileReferenceProjector.add(inline.url, references, seenRemotePaths); + return; + } + if (inline.type === 'code') { + return; + } + const found = inline.text.match(/computer:\/\/[^\s)\]}>"']+/g) || []; + found.forEach((reference: string) => { + if (references.length < limit) { + MessageFileReferenceProjector.add(reference, references, seenRemotePaths); + } + }); + }); + } + + private static add( + reference: string, + references: MessageFileReference[], + seenRemotePaths: Set + ): void { + if (reference.trim().toLowerCase().indexOf('computer://') !== 0) { + return; + } + const resolution = FileTargetResolver.resolve(reference, '', MessageFileReferenceProjector.CONTEXT); + if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target || + seenRemotePaths.has(resolution.target.remotePath)) { + return; + } + seenRemotePaths.add(resolution.target.remotePath); + references.push(new MessageFileReference( + `file-${references.length}-${resolution.target.remotePath}`, + resolution.target.rawReference, + resolution.target.remotePath, + resolution.target.displayName + )); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets index d6341d01ec..05e8a8858b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets @@ -4,12 +4,11 @@ import { FileInfo, ReadFileResult } from '../model/RemoteModels'; import { RemoteI18n } from '../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; import { Encoding } from './Encoding'; +import { RemoteWorkspaceFileClient } from './RemoteWorkspaceFileClient'; import { RemoteUiState } from './RemoteUiState'; +import { RemoteLogger } from './RemoteLogger'; -export interface RemoteFileDownloadClient { - getFileInfo(path: string, sessionId?: string): Promise; - readFile(path: string, sessionId?: string, onProgress?: (downloaded: number, total: number) => void): Promise; -} +export interface RemoteFileDownloadClient extends RemoteWorkspaceFileClient {} export interface RemoteFileDownloadScheduler { setTimeout(callback: () => void, delayMs: number): number; @@ -91,9 +90,11 @@ export class RemoteFileDownloadController { } this.cancelClearTimer(); try { + RemoteLogger.info(`file_download start session=${sessionId.length > 0 ? 'set' : 'none'}`); this.onBusy(true); this.setDownloadStatus(path, '', RemoteI18n.t('status.fileInfo')); const info = await this.client.getFileInfo(remotePath, sessionId); + RemoteLogger.info(`file_download metadata name=${info.name} size=${info.size} mime=${info.mimeType}`); this.setDownloadStatus(path, '', RemoteI18n.f('status.prepareDownload', RemoteUiState.formatBytes(info.size))); const result: ReadFileResult = await this.client.readFile(remotePath, sessionId, (downloaded: number, total: number) => { this.setDownloadStatus( @@ -102,7 +103,9 @@ export class RemoteFileDownloadController { `${RemoteUiState.formatBytes(downloaded)} / ${RemoteUiState.formatBytes(total)}` ); }); + RemoteLogger.info(`file_download data_ready name=${result.name} size=${result.size}`); await this.fileSaver.save(result); + RemoteLogger.info(`file_download saved name=${result.name} size=${result.size}`); this.setDownloadStatus( path, path, @@ -110,7 +113,9 @@ export class RemoteFileDownloadController { ); this.onStatusText(this.fileDownloadStatus); } catch (err) { - this.setDownloadStatus(this.downloadingFilePath, this.downloadedFilePath, ConnectionErrorPolicy.errorText(err)); + const errorText = ConnectionErrorPolicy.errorText(err); + RemoteLogger.error(`file_download failed message=${errorText}`); + this.setDownloadStatus(this.downloadingFilePath, this.downloadedFilePath, errorText); this.onStatusText(this.fileDownloadStatus); } finally { this.onBusy(false); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets new file mode 100644 index 0000000000..a3299361f5 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets @@ -0,0 +1,266 @@ +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'; + +export class RemoteFilePreviewController { + private readonly client: RemoteWorkspaceFileClient; + private readonly state: FilePreviewState; + private readonly remoteAvailable: () => boolean; + private readonly currentControlTargetEpoch: () => number; + + constructor( + client: RemoteWorkspaceFileClient, + state: FilePreviewState, + remoteAvailable: () => boolean, + currentControlTargetEpoch: () => number + ) { + this.client = client; + this.state = state; + this.remoteAvailable = remoteAvailable; + this.currentControlTargetEpoch = currentControlTargetEpoch; + } + + async open(target: FilePreviewTarget): Promise { + if (!target.isValid() || !this.remoteAvailable() || + target.controlTargetEpoch !== this.currentControlTargetEpoch()) { + return; + } + const version = this.state.begin(target); + try { + const info = await this.client.getFileInfo(target.remotePath, target.sessionId); + if (!this.isCurrentTarget(version, target) || this.failIfUnavailable()) { + return; + } + this.applyMetadata(info); + const renderer = RemoteFilePreviewController.rendererFor(info.name, info.mimeType); + this.state.rendererKind = renderer; + if (renderer === FilePreviewRendererKind.Unsupported || + (renderer === FilePreviewRendererKind.Image && !FilePreviewPolicy.canPreviewImage(info.size))) { + this.state.phase = FilePreviewPhase.Unsupported; + return; + } + if (renderer === FilePreviewRendererKind.Image) { + const image = await this.client.readFile(target.remotePath, target.sessionId); + if (!this.isCurrentTarget(version, target) || this.failIfUnavailable()) { + return; + } + if (!FilePreviewPolicy.canPreviewImage(image.size)) { + this.state.fileName = image.name; + this.state.fileSize = image.size; + this.state.mimeType = image.mimeType; + this.state.phase = FilePreviewPhase.Unsupported; + return; + } + this.applyImage(image); + return; + } + const limit = FilePreviewPolicy.textReadLimit(info.size); + const chunk = await this.client.readFileChunk(target.remotePath, 0, limit, target.sessionId); + if (!this.isCurrentTarget(version, target) || this.failIfUnavailable()) { + return; + } + this.applyText(chunk, info.size > limit); + } catch (err) { + if (!this.isCurrentTarget(version, target)) { + return; + } + this.state.phase = FilePreviewPhase.Error; + if (this.remoteAvailable()) { + const failure = FilePreviewErrorPolicy.resolve(err); + this.state.errorText = failure.text; + this.state.errorRetryable = failure.retryable; + } else { + this.state.errorText = RemoteI18n.t('filePreview.offline'); + this.state.errorRetryable = true; + } + } + } + + async refresh(): Promise { + if (!this.state.visible || !this.state.target.isValid()) { + return; + } + await this.open(this.state.target); + } + + close(): void { + this.state.close(); + } + + private isCurrentTarget(version: number, target: FilePreviewTarget): boolean { + return this.state.isCurrent(version, target) && + target.controlTargetEpoch === this.currentControlTargetEpoch(); + } + + private failIfUnavailable(): boolean { + if (this.remoteAvailable()) { + return false; + } + this.state.phase = FilePreviewPhase.Error; + this.state.errorText = RemoteI18n.t('filePreview.offline'); + this.state.errorRetryable = true; + return true; + } + + private applyMetadata(info: FileInfo): void { + this.state.fileName = info.name; + this.state.fileSize = info.size; + this.state.mimeType = info.mimeType; + } + + private applyText(chunk: ReadFileChunkResult, truncated: boolean): void { + const bytes = Encoding.base64ToBytes(chunk.contentBase64); + if (RemoteFilePreviewController.isBinary(bytes)) { + this.state.phase = FilePreviewPhase.Unsupported; + this.state.rendererKind = FilePreviewRendererKind.Unsupported; + this.state.loadedBytes = bytes.length; + return; + } + const text = Encoding.bytesToUtf8(bytes); + if (RemoteFilePreviewController.isSuspiciousText(bytes, text)) { + this.state.phase = FilePreviewPhase.Unsupported; + this.state.rendererKind = FilePreviewRendererKind.Unsupported; + this.state.loadedBytes = bytes.length; + return; + } + this.state.textContent = text; + this.state.contentBase64 = ''; + this.state.loadedBytes = bytes.length; + this.state.truncated = truncated || chunk.totalSize > bytes.length; + this.state.phase = FilePreviewPhase.Ready; + } + + private applyImage(result: ReadFileResult): void { + this.state.fileName = result.name; + this.state.fileSize = result.size; + this.state.mimeType = result.mimeType; + this.state.contentBase64 = result.contentBase64; + this.state.textContent = ''; + this.state.loadedBytes = result.size; + this.state.truncated = false; + this.state.phase = FilePreviewPhase.Ready; + } + + static rendererFor(name: string, mimeType: string): FilePreviewRendererKind { + const mime = mimeType.toLowerCase(); + const extension = RemoteFilePreviewController.extension(name); + if (mime.indexOf('image/') === 0 && extension !== 'svg') { + return FilePreviewRendererKind.Image; + } + if (mime === 'text/markdown' || extension === 'md' || extension === 'mdx') { + return FilePreviewRendererKind.Markdown; + } + if (mime.indexOf('text/') === 0 || mime === 'application/json' || mime === 'application/xml' || + RemoteFilePreviewController.isTextExtension(extension) || + RemoteFilePreviewController.isTextFileName(name)) { + return FilePreviewRendererKind.Text; + } + return FilePreviewRendererKind.Unsupported; + } + + private static extension(name: string): string { + const fileName = name.replace(/\\/g, '/').split('/').pop() || ''; + const dot = fileName.lastIndexOf('.'); + return dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : ''; + } + + private static isTextExtension(extension: string): boolean { + return '|js|jsx|ts|tsx|mjs|cjs|py|rs|go|java|kt|c|cpp|h|hpp|cs|rb|php|swift|vue|svelte|css|scss|less|json|jsonc|yaml|yml|toml|xml|csv|tsv|txt|log|sh|bash|zsh|fish|ps1|bat|cmd|sql|graphql|gql|proto|lock|env|ini|cfg|conf|ets|gitignore|editorconfig|'.indexOf(`|${extension}|`) >= 0; + } + + private static isTextFileName(name: string): boolean { + const fileName = name.replace(/\\/g, '/').split('/').pop()?.toLowerCase() || ''; + return fileName === 'dockerfile' || fileName === 'makefile' || fileName === 'justfile' || + fileName === 'gemfile' || fileName === 'rakefile' || fileName === 'procfile' || + fileName === 'license' || fileName === 'readme' || fileName === 'changelog'; + } + + private static isBinary(bytes: Uint8Array): boolean { + const limit = Math.min(bytes.length, 4096); + for (let index = 0; index < limit; index++) { + if (bytes[index] === 0) { + return true; + } + } + return false; + } + + private static isSuspiciousText(bytes: Uint8Array, text: string): boolean { + if (!RemoteFilePreviewController.isValidUtf8(bytes)) { + return true; + } + if (text.indexOf('\uFFFD') >= 0) { + return true; + } + const limit = Math.min(bytes.length, 4096); + let controls = 0; + for (let index = 0; index < limit; index++) { + const value = bytes[index]; + if (value < 32 && value !== 9 && value !== 10 && value !== 13) { + controls += 1; + } + } + return limit > 0 && controls / limit > 0.02; + } + + private static isValidUtf8(bytes: Uint8Array): boolean { + let index = 0; + while (index < bytes.length) { + const first = bytes[index]; + if (first <= 0x7F) { + index += 1; + continue; + } + let continuationCount = 0; + let minimumFirst = 0x80; + let maximumFirst = 0xBF; + if (first >= 0xC2 && first <= 0xDF) { + continuationCount = 1; + } else if (first === 0xE0) { + continuationCount = 2; + minimumFirst = 0xA0; + } else if (first >= 0xE1 && first <= 0xEC) { + continuationCount = 2; + } else if (first === 0xED) { + continuationCount = 2; + maximumFirst = 0x9F; + } else if (first >= 0xEE && first <= 0xEF) { + continuationCount = 2; + } else if (first === 0xF0) { + continuationCount = 3; + minimumFirst = 0x90; + } else if (first >= 0xF1 && first <= 0xF3) { + continuationCount = 3; + } else if (first === 0xF4) { + continuationCount = 3; + maximumFirst = 0x8F; + } else { + return false; + } + if (index + continuationCount >= bytes.length) { + return false; + } + const second = bytes[index + 1]; + if (second < minimumFirst || second > maximumFirst) { + return false; + } + for (let offset = 2; offset <= continuationCount; offset++) { + const continuation = bytes[index + offset]; + if (continuation < 0x80 || continuation > 0xBF) { + return false; + } + } + index += continuationCount + 1; + } + return true; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets index a1fd0f7c64..8bbcd94eff 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets @@ -130,7 +130,8 @@ export class RemoteSessionController { isBusy: boolean, remoteAvailable: boolean, onCreated: (session: SessionSummary) => Promise, - instruction: string = '' + instruction: string = '', + modelId: string = '' ): Promise { if (isBusy || !remoteAvailable) { return; @@ -141,7 +142,8 @@ export class RemoteSessionController { const session = await this.client.createSession({ agentType, title: '', - instruction + instruction, + modelId }); this.callbacks.onActiveSession(session); this.callbacks.onStatusText(RemoteI18n.t('status.sessionCreated')); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index aa12b94d6b..ac6a07d757 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -1,4 +1,4 @@ -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { PairIdentity, RelayHttpClient } from './RelayHttpClient'; import { CloudAccountClient, CloudAccountSession } from './CloudAccountClient'; @@ -182,6 +182,10 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile const command = RemoteCommandFactory.createSession(options, workspacePath); const response = await this.send(command); const sessionId = response.session_id || response.id || ''; + const modelId = options.modelId?.trim() || ''; + if (modelId.length > 0) { + await this.setSessionModel(sessionId, modelId); + } const normalizedAgentType = options.agentType.toLowerCase(); const fallbackTitle = normalizedAgentType === 'claw' || normalizedAgentType === 'assistant' || normalizedAgentType === 'chat' ? 'Assistant Session' : normalizedAgentType === 'cowork' ? 'Cowork Session' : 'Code Session'; @@ -317,6 +321,25 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile }; } + async readFileChunk( + path: string, + offset: number, + limit: number, + sessionId?: string + ): Promise { + const response = await this.send( + RemoteCommandFactory.readFileChunk(path, offset, limit, sessionId) + ); + return { + name: response.name || RemoteResponseMapper.basename(path), + contentBase64: response.chunk_base64 || '', + offset: response.offset || offset, + chunkSize: response.chunk_size || 0, + totalSize: response.total_size || 0, + mimeType: response.mime_type || 'application/octet-stream' + }; + } + async readFile(path: string, sessionId?: string, onProgress?: (downloaded: number, total: number) => void): Promise { const chunkSize = 3 * 1024 * 1024; let offset = 0; @@ -325,14 +348,12 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile let totalSize = 0; const chunks: string[] = []; while (true) { - const command = RemoteCommandFactory.readFileChunk(path, offset, chunkSize, sessionId); - const response = await this.send(command); - const chunk = response.chunk_base64 || ''; - const readSize = response.chunk_size || 0; - chunks.push(chunk); + const response = await this.readFileChunk(path, offset, chunkSize, sessionId); + const readSize = response.chunkSize; + chunks.push(response.contentBase64); fileName = response.name || fileName; - mimeType = response.mime_type || mimeType; - totalSize = response.total_size || totalSize; + mimeType = response.mimeType || mimeType; + totalSize = response.totalSize || totalSize; offset += readSize; if (onProgress) { onProgress(offset, totalSize); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceFileClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceFileClient.ets new file mode 100644 index 0000000000..6a64a622a5 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceFileClient.ets @@ -0,0 +1,12 @@ +import { FileInfo, ReadFileChunkResult, ReadFileResult } from '../model/RemoteModels'; + +/** Stable mobile-side port for files owned by the active remote workspace. */ +export interface RemoteWorkspaceFileClient { + getFileInfo(path: string, sessionId?: string): Promise; + readFileChunk(path: string, offset: number, limit: number, sessionId?: string): Promise; + readFile( + path: string, + sessionId?: string, + onProgress?: (downloaded: number, total: number) => void + ): Promise; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ToolFileReferenceResolver.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ToolFileReferenceResolver.ets new file mode 100644 index 0000000000..2de57cceba --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ToolFileReferenceResolver.ets @@ -0,0 +1,66 @@ +export interface ToolFileInputPayload { + path?: string; + file_path?: string; + filePath?: string; +} + +export class ToolFileReference { + readonly path: string; + readonly label: string; + + constructor(path: string, label: string) { + this.path = path; + this.label = label; + } +} + +export class ToolFileReferenceResolver { + static resolve( + toolName: string, + toolInput?: Object, + inputPreview: string = '' + ): ToolFileReference | undefined { + if (!ToolFileReferenceResolver.supportsTool(toolName)) { + return undefined; + } + const payload = ToolFileReferenceResolver.payload(toolInput, inputPreview); + const path = (payload.file_path || payload.filePath || payload.path || '').trim(); + if (path.length === 0 || path.endsWith('/') || path.endsWith('\\')) { + return undefined; + } + return new ToolFileReference(path, ToolFileReferenceResolver.basename(path)); + } + + private static payload(toolInput?: Object, inputPreview: string = ''): ToolFileInputPayload { + if (toolInput) { + try { + return JSON.parse(JSON.stringify(toolInput)) as ToolFileInputPayload; + } catch (_err) { + } + } + const preview = inputPreview.trim(); + if (preview.length > 0 && preview.indexOf('{') === 0) { + try { + return JSON.parse(preview) as ToolFileInputPayload; + } catch (_err) { + } + } + return {}; + } + + private static supportsTool(toolName: string): boolean { + const normalized = toolName.replace(/[\s-]/g, '_').toLowerCase(); + return '|read|read_file|write|write_file|create|create_file|edit|edit_file|file_edit|strreplace|str_replace|str_replace_editor|replace|replace_file|update_file|'.indexOf(`|${normalized}|`) >= 0; + } + + private static basename(path: string): string { + let normalized = path.replace(/^computer:\/\//, '').replace(/^file:\/\//, '').replace(/\\/g, '/'); + const hash = normalized.lastIndexOf('#'); + if (hash > 0) { + normalized = normalized.slice(0, hash); + } + normalized = normalized.replace(/:\d+(?:-\d+)?$/, ''); + const parts = normalized.split('/'); + return parts[parts.length - 1] || normalized || 'file'; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatCloudConfigPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatCloudConfigPolicy.ets new file mode 100644 index 0000000000..dda3d5aa2c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatCloudConfigPolicy.ets @@ -0,0 +1,119 @@ +import { GeneralChatRuntimeModelConfig } from './GeneralChatConfigStore'; + +interface CloudModelAuthWire { + type?: string; +} + +interface CloudModelWire { + id?: string; + name?: string; + provider?: string; + model_name?: string; + base_url?: string; + api_key?: string; + enabled?: boolean; + category?: string; + auth?: CloudModelAuthWire; +} + +interface CloudDefaultModelsWire { + primary?: string; +} + +interface CloudAiConfigWire { + models?: CloudModelWire[]; + default_models?: CloudDefaultModelsWire; +} + +interface CloudGlobalConfigWire { + ai?: CloudAiConfigWire; +} + +interface CloudSettingsPayloadWire { + config?: CloudGlobalConfigWire; + ai?: CloudAiConfigWire; +} + +/** Maps the shared encrypted account settings payload to the smaller HarmonyOS chat contract. */ +export class GeneralChatCloudConfigPolicy { + static models(payload: string): GeneralChatRuntimeModelConfig[] { + let parsed: CloudSettingsPayloadWire; + try { + parsed = JSON.parse(payload) as CloudSettingsPayloadWire; + } catch (_err) { + return []; + } + const ai = parsed.config?.ai || parsed.ai; + const models = ai?.models || []; + const primaryId = ai?.default_models?.primary || ''; + const ordered: CloudModelWire[] = []; + if (primaryId.length > 0) { + models.forEach((model: CloudModelWire) => { + if (model.id === primaryId) { + GeneralChatCloudConfigPolicy.pushCompatible(ordered, model); + } + }); + } + models.forEach((model: CloudModelWire) => { + if (model.category === 'general_chat') { + GeneralChatCloudConfigPolicy.pushCompatible(ordered, model); + } + }); + models.forEach((model: CloudModelWire) => { + GeneralChatCloudConfigPolicy.pushCompatible(ordered, model); + }); + return ordered.map((model: CloudModelWire): GeneralChatRuntimeModelConfig => { + const sourceId = GeneralChatCloudConfigPolicy.stringValue(model.id).trim() || + `${GeneralChatCloudConfigPolicy.stringValue(model.provider)}:${GeneralChatCloudConfigPolicy.stringValue(model.model_name)}`; + const modelName = GeneralChatCloudConfigPolicy.stringValue(model.model_name).trim(); + return { + modelId: `cloud:${sourceId}`, + name: GeneralChatCloudConfigPolicy.stringValue(model.name).trim() || modelName, + provider: GeneralChatCloudConfigPolicy.stringValue(model.provider).trim() || 'account', + apiUrl: GeneralChatCloudConfigPolicy.normalizedApiUrl(model), + apiKey: GeneralChatCloudConfigPolicy.stringValue(model.api_key).trim(), + modelName + }; + }); + } + + static selectModel(payload: string): GeneralChatRuntimeModelConfig | undefined { + const models = GeneralChatCloudConfigPolicy.models(payload); + return models.length > 0 ? models[0] : undefined; + } + + private static pushCompatible(target: CloudModelWire[], model: CloudModelWire): void { + if (!GeneralChatCloudConfigPolicy.isCompatible(model)) { + return; + } + const id = GeneralChatCloudConfigPolicy.stringValue(model.id); + const duplicate = target.some((candidate: CloudModelWire): boolean => { + return GeneralChatCloudConfigPolicy.stringValue(candidate.id) === id; + }); + if (!duplicate) { + target.push(model); + } + } + + private static isCompatible(model: CloudModelWire): boolean { + if (model.enabled !== true || model.auth?.type === 'subscription') { + return false; + } + return GeneralChatCloudConfigPolicy.stringValue(model.base_url).trim().length > 0 && + GeneralChatCloudConfigPolicy.stringValue(model.model_name).trim().length > 0 && + GeneralChatCloudConfigPolicy.stringValue(model.api_key).trim().length > 0; + } + + private static normalizedApiUrl(model: CloudModelWire): string { + const baseUrl = GeneralChatCloudConfigPolicy.stringValue(model.base_url).trim().replace(/\/+$/, ''); + const provider = GeneralChatCloudConfigPolicy.stringValue(model.provider).trim().toLowerCase(); + if (provider === 'anthropic' && baseUrl.indexOf('openbitfun.com') < 0 && !baseUrl.endsWith('/v1/messages')) { + return `${baseUrl}/v1/messages`; + } + return baseUrl; + } + + private static stringValue(value?: string): string { + return typeof value === 'string' ? value : ''; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets index 3df2ce907d..84f49f2316 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets @@ -1,5 +1,6 @@ import { preferences } from '@kit.ArkData'; import { huks } from '@kit.UniversalKeystoreKit'; +import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { Encoding } from '../Encoding'; import { GeneralChatTokenProvider } from './GeneralChatHttpTransport'; @@ -10,6 +11,8 @@ const MODEL_NAME_KEY = 'model_name'; const API_KEY_CIPHER_KEY = 'api_key_cipher'; const API_KEY_IV_KEY = 'api_key_iv'; const HUKS_ALIAS = 'bitfun_general_chat_api_key'; +const SELECTED_MODEL_ID_KEY = 'selected_model_id'; +export const GENERAL_CHAT_LOCAL_MODEL_ID = 'local-general-chat'; export interface GeneralChatConfigSnapshot { apiUrl: string; @@ -24,6 +27,22 @@ export interface GeneralChatConfigUpdate { clearApiKey: boolean; } +export interface GeneralChatRuntimeModelConfig { + modelId: string; + name: string; + provider: string; + apiUrl: string; + modelName: string; + apiKey: string; +} + +export class GeneralChatModelSelectionPolicy { + static shouldActivateSavedLocalModel(catalog: RemoteModelCatalog): boolean { + const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; + return !catalog.models.some((model: RemoteModelConfig): boolean => model.id === selectedModelId); + } +} + export class GeneralChatConfigValidator { static validate(update: GeneralChatConfigUpdate, hasExistingApiKey: boolean): string { const apiUrl = update.apiUrl.trim(); @@ -53,9 +72,13 @@ export class GeneralChatConfigValidator { export class GeneralChatConfigStore implements GeneralChatTokenProvider { private store?: preferences.Preferences; + private accountModels: GeneralChatRuntimeModelConfig[] = []; + private preferredModelId: string = GENERAL_CHAT_LOCAL_MODEL_ID; + private catalogRevision: number = 1; async init(context: Context): Promise { this.store = await preferences.getPreferences(context, STORE_NAME); + this.preferredModelId = await this.getString(SELECTED_MODEL_ID_KEY) || GENERAL_CHAT_LOCAL_MODEL_ID; return this.snapshot(); } @@ -112,6 +135,92 @@ export class GeneralChatConfigStore implements GeneralChatTokenProvider { return this.decryptApiKey(cipherText, iv); } + replaceAccountModels(models: GeneralChatRuntimeModelConfig[]): void { + this.accountModels = models.map((model: GeneralChatRuntimeModelConfig): GeneralChatRuntimeModelConfig => ({ + modelId: model.modelId, + name: model.name, + provider: model.provider, + apiUrl: model.apiUrl, + modelName: model.modelName, + apiKey: model.apiKey + })); + this.catalogRevision += 1; + } + + async modelCatalog(): Promise { + const local = await this.snapshot(); + const models: RemoteModelConfig[] = []; + if (GeneralChatConfigStore.isComplete(local)) { + models.push({ + id: GENERAL_CHAT_LOCAL_MODEL_ID, + name: local.modelName, + provider: 'local', + base_url: local.apiUrl, + model_name: local.modelName, + enabled: true, + capabilities: ['text_chat'] + }); + } + this.accountModels.forEach((model: GeneralChatRuntimeModelConfig) => { + models.push({ + id: model.modelId, + name: model.name || model.modelName, + provider: model.provider || 'account', + base_url: model.apiUrl, + model_name: model.modelName, + enabled: true, + capabilities: ['text_chat'] + }); + }); + const selectedModelId = this.effectiveSelectedModelId(models); + return { + version: this.catalogRevision, + models, + default_models: { primary: selectedModelId || undefined }, + session_model_id: selectedModelId || undefined + }; + } + + async selectModel(modelId: string): Promise { + const catalog = await this.modelCatalog(); + if (!catalog.models.some((model: RemoteModelConfig): boolean => model.id === modelId)) { + return false; + } + this.preferredModelId = modelId; + const store = this.requireStore(); + await store.put(SELECTED_MODEL_ID_KEY, modelId); + await store.flush(); + this.catalogRevision += 1; + return true; + } + + async selectLocalModel(): Promise { + this.preferredModelId = GENERAL_CHAT_LOCAL_MODEL_ID; + const store = this.requireStore(); + await store.put(SELECTED_MODEL_ID_KEY, GENERAL_CHAT_LOCAL_MODEL_ID); + await store.flush(); + this.catalogRevision += 1; + } + + async activeSnapshot(): Promise { + const selectedModelId = this.effectiveSelectedModelId((await this.modelCatalog()).models); + const accountModel = this.accountModels.find((model: GeneralChatRuntimeModelConfig): boolean => { + return model.modelId === selectedModelId; + }); + if (accountModel) { + return { apiUrl: accountModel.apiUrl, modelName: accountModel.modelName, hasApiKey: true }; + } + return this.snapshot(); + } + + async activeAccessToken(): Promise { + const selectedModelId = this.effectiveSelectedModelId((await this.modelCatalog()).models); + const accountModel = this.accountModels.find((model: GeneralChatRuntimeModelConfig): boolean => { + return model.modelId === selectedModelId; + }); + return accountModel ? accountModel.apiKey : this.accessToken(); + } + private async encryptApiKey(apiKey: string): Promise { await this.ensureHuksKey(); const iv = Encoding.randomBytes(16); @@ -199,6 +308,18 @@ export class GeneralChatConfigStore implements GeneralChatTokenProvider { return this.store; } + private effectiveSelectedModelId(models: RemoteModelConfig[]): string { + if (models.some((model: RemoteModelConfig): boolean => model.id === this.preferredModelId)) { + return this.preferredModelId; + } + const local = models.find((model: RemoteModelConfig): boolean => model.id === GENERAL_CHAT_LOCAL_MODEL_ID); + return local ? local.id : (models.length > 0 ? models[0].id : ''); + } + + private static isComplete(snapshot: GeneralChatConfigSnapshot): boolean { + return snapshot.apiUrl.trim().length > 0 && snapshot.modelName.trim().length > 0 && snapshot.hasApiKey; + } + private static normalizeBaseUrl(value: string): string { const trimmed = value.trim(); return trimmed.endsWith('/') ? trimmed.slice(0, trimmed.length - 1) : trimmed; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatRepository.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatRepository.ets index 8b47247afb..8044588c46 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatRepository.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatRepository.ets @@ -47,8 +47,14 @@ export class GeneralChatRepository { return cached.slice(); } const stored = await this.localStore.loadMessages(sessionId); - this.messagesBySession.set(sessionId, stored); - return stored.slice(); + const normalized = stored.map((message: ChatMessage) => GeneralChatRepository.persistableMessage(message)); + this.messagesBySession.set(sessionId, normalized); + if (normalized.some((message: ChatMessage, index: number) => { + return message.detail !== stored[index].detail; + })) { + await this.localStore.replaceMessages(sessionId, normalized); + } + return normalized.slice(); } async sendMessage( @@ -118,9 +124,10 @@ export class GeneralChatRepository { } async recordAssistantMessage(sessionId: string, message: ChatMessage): Promise { + const persistedMessage = GeneralChatRepository.persistableMessage(message); const messages = (await this.messages(sessionId)) - .filter((item: ChatMessage) => item.id !== message.id) - .concat([message]); + .filter((item: ChatMessage) => item.id !== persistedMessage.id) + .concat([persistedMessage]); this.messagesBySession.set(sessionId, messages); await this.localStore.replaceMessages(sessionId, messages); await this.bumpSession(sessionId, messages.length); @@ -329,4 +336,25 @@ export class GeneralChatRepository { images: message.images }; } + + private static persistableMessage(message: ChatMessage): ChatMessage { + if (message.role !== 'assistant' || (message.status || '').toLowerCase() !== 'failed' || + (message.detail || '').length === 0) { + return message; + } + return { + id: message.id, + role: message.role, + text: message.text, + status: message.status, + renderVersion: message.renderVersion, + turnId: message.turnId, + detail: '', + timestamp: message.timestamp, + thinking: message.thinking, + tools: message.tools, + items: message.items, + images: message.images + }; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets index 3726b6b3b0..c5faf7efaa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets @@ -212,8 +212,8 @@ export class ModelProviderGeneralChatAdapter implements GeneralChatPort { } async generateTitle(firstMessage: string, assistantMessage: string): Promise { - const config = await this.configStore.snapshot(); - const apiKey = (await this.configStore.accessToken()).trim(); + const config = await this.configStore.activeSnapshot(); + const apiKey = (await this.configStore.activeAccessToken()).trim(); if (config.apiUrl.length === 0 || config.modelName.length === 0 || apiKey.length === 0) { return ''; } @@ -264,8 +264,8 @@ export class ModelProviderGeneralChatAdapter implements GeneralChatPort { if (images.length > 0) { throw new Error(RemoteI18n.t('generalChat.imageNotSupported')); } - const config = await this.configStore.snapshot(); - const apiKey = (await this.configStore.accessToken()).trim(); + const config = await this.configStore.activeSnapshot(); + const apiKey = (await this.configStore.activeAccessToken()).trim(); if (config.apiUrl.length === 0 || config.modelName.length === 0 || apiKey.length === 0) { throw new Error(RemoteI18n.t('generalChat.modelNotConfigured')); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/module.json5 b/src/apps/mobile/harmonyos/entry/src/main/module.json5 index 59a3b7b158..33f39ae8e9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/module.json5 +++ b/src/apps/mobile/harmonyos/entry/src/main/module.json5 @@ -5,7 +5,8 @@ "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": [ - "phone" + "phone", + "tablet" ], "deliveryWithInstall": true, "installationFree": false, 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 3c712962da..124f69ff32 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 @@ -3,6 +3,118 @@ { "name": "start_window_background", "value": "#FFFFFF" + }, + { + "name": "page_bg", + "value": "#FDFDFB" + }, + { + "name": "ink", + "value": "#171717" + }, + { + "name": "muted", + "value": "#706F6A" + }, + { + "name": "subtle", + "value": "#A5A39B" + }, + { + "name": "line", + "value": "#E9E7E2" + }, + { + "name": "card", + "value": "#FFFFFF" + }, + { + "name": "accent", + "value": "#111111" + }, + { + "name": "file_link", + "value": "#2563EB" + }, + { + "name": "primary_action", + "value": "#111111" + }, + { + "name": "primary_action_text", + "value": "#FFFFFF" + }, + { + "name": "connect_hero_bg", + "value": "#E6EDFF" + }, + { + "name": "connect_hero_accent", + "value": "#9DB4FF" + }, + { + "name": "connect_hero_secondary", + "value": "#C9C5FF" + }, + { + "name": "connect_hero_surface", + "value": "#F8FAFF" + }, + { + "name": "soft", + "value": "#F4F3F0" + }, + { + "name": "floating_panel_bg", + "value": "#F7F7F5" + }, + { + "name": "green", + "value": "#27C46A" + }, + { + "name": "red", + "value": "#E04F4F" + }, + { + "name": "code_line_number", + "value": "#AAA69D" + }, + { + "name": "code_keyword", + "value": "#8F3F71" + }, + { + "name": "code_string", + "value": "#477A4A" + }, + { + "name": "code_number", + "value": "#9A5B13" + }, + { + "name": "code_comment", + "value": "#7A8078" + }, + { + "name": "code_function", + "value": "#2C6693" + }, + { + "name": "code_type", + "value": "#865A20" + }, + { + "name": "code_constant", + "value": "#A04444" + }, + { + "name": "code_property", + "value": "#466D78" + }, + { + "name": "code_target_bg", + "value": "#FFF1BE" } ] -} \ No newline at end of file +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_ref_sidebar_connected.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_ref_sidebar_connected.png index 6d1e8d28da..309bc9eee6 100644 Binary files a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_ref_sidebar_connected.png and b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_ref_sidebar_connected.png differ 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 79b11c2747..39e3e9d2c5 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 @@ -3,6 +3,118 @@ { "name": "start_window_background", "value": "#000000" + }, + { + "name": "page_bg", + "value": "#151514" + }, + { + "name": "ink", + "value": "#F4F3EF" + }, + { + "name": "muted", + "value": "#AAA8A0" + }, + { + "name": "subtle", + "value": "#77756E" + }, + { + "name": "line", + "value": "#363531" + }, + { + "name": "card", + "value": "#252522" + }, + { + "name": "accent", + "value": "#5B5954" + }, + { + "name": "file_link", + "value": "#60A5FA" + }, + { + "name": "primary_action", + "value": "#454540" + }, + { + "name": "primary_action_text", + "value": "#FFFFFF" + }, + { + "name": "connect_hero_bg", + "value": "#2B2B29" + }, + { + "name": "connect_hero_accent", + "value": "#4A4944" + }, + { + "name": "connect_hero_secondary", + "value": "#3C3B38" + }, + { + "name": "connect_hero_surface", + "value": "#252522" + }, + { + "name": "soft", + "value": "#2D2C28" + }, + { + "name": "floating_panel_bg", + "value": "#1E1E1C" + }, + { + "name": "green", + "value": "#3BD47B" + }, + { + "name": "red", + "value": "#FF6B6B" + }, + { + "name": "code_line_number", + "value": "#77756E" + }, + { + "name": "code_keyword", + "value": "#D99AC4" + }, + { + "name": "code_string", + "value": "#9BCB9D" + }, + { + "name": "code_number", + "value": "#E3B36D" + }, + { + "name": "code_comment", + "value": "#96958D" + }, + { + "name": "code_function", + "value": "#8CBCE0" + }, + { + "name": "code_type", + "value": "#D5B27F" + }, + { + "name": "code_constant", + "value": "#E79A9A" + }, + { + "name": "code_property", + "value": "#9CC8D0" + }, + { + "name": "code_target_bg", + "value": "#5A4E24" } ] -} \ No newline at end of file +} 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 25b3dc5c9f..1af9cec0d6 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,13 @@ 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 { FilePreviewRequest, FilePreviewTarget } from '../main/ets/pages/state/FilePreviewTarget'; class FakeAppRootHost implements AppRootHostPort { + externalLinks: string[] = []; + externalLinkResult: boolean = true; + attach(_context: Context, _uiContext: UIContext): void { } @@ -17,13 +22,18 @@ class FakeAppRootHost implements AppRootHostPort { showToast(_message: string, _duration: number): boolean { return false; } + + async openExternalLink(link: string): Promise { + this.externalLinks.push(link); + return this.externalLinkResult; + } } class TestAppRootRuntime extends AppRootRuntime { stopGeneralChatStreamCalls: number = 0; - constructor() { - super(new FakeAppRootHost()); + constructor(host: AppRootHostPort = new FakeAppRootHost()) { + super(host); } stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { @@ -49,5 +59,71 @@ export default function appRootLifecycleUnitTest() { expect(runtime.stopGeneralChatStreamCalls).assertEqual(1); }); + + 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( + AppRoute.ChatHome, + new FilePreviewRequest('https://example.com/docs', 'docs') + ); + await new Promise((resolve: () => void) => setTimeout(resolve, 0)); + + expect(host.externalLinks.length).assertEqual(1); + expect(host.externalLinks[0]).assertEqual('https://example.com/docs'); + expect(runtime.filePreviewState.visible).assertFalse(); + }); + + it('closes preview before applying conversation navigation back', 0, () => { + const runtime = new TestAppRootRuntime(); + runtime.filePreviewState.begin(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 + )); + + expect(runtime.handleNavigationBack(AppRoute.RemoteChat)).assertTrue(); + expect(runtime.filePreviewState.visible).assertFalse(); + expect(runtime.handleRootBack()).assertFalse(); + }); + + it('invalidates and closes preview when the remote target changes', 0, () => { + const runtime = new TestAppRootRuntime(); + runtime.filePreviewState.begin(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 + )); + + runtime.invalidateFilePreviewTarget(); + + expect(runtime.filePreviewState.visible).assertFalse(); + }); + + it('closes preview only when the active remote session ownership changes', 0, () => { + const runtime = new TestAppRootRuntime(); + runtime.remotePageState.setActiveSession({ + sessionId: 'session-1', + title: 'Session 1', + workspacePath: '/workspace', + agentType: 'code' + }); + runtime.filePreviewState.begin(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 + )); + + runtime.applyRemoteActiveSession({ + sessionId: 'session-1', + title: 'Renamed session', + workspacePath: '/workspace', + agentType: 'code' + }); + expect(runtime.filePreviewState.visible).assertTrue(); + + runtime.applyRemoteActiveSession({ + sessionId: 'session-2', + title: 'Session 2', + workspacePath: '/workspace', + agentType: 'code' + }); + expect(runtime.filePreviewState.visible).assertFalse(); + }); }); } 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 b5cbe9ef20..1139637387 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets @@ -83,5 +83,13 @@ export default function architectureUnitTest() { expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); expect(shell.navigationStack.getAllPathName().length).assertEqual(1); }); + + it('keeps same-route state updates from rebuilding navigation', 0, () => { + const shell = new AppShellViewModel(); + shell.pushRoute(AppRoute.RemoteHome); + shell.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); + expect(shell.navigationStack.getAllPathName().length).assertEqual(1); + }); }); } 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 38cb8ef6bd..1c6a270efd 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -253,6 +253,24 @@ export default function conversationStateUnitTest() { expect(items[1].type).assertEqual('assistant_live_turn'); expect(items[1].isFinalizing).assertEqual(true); }); + + it('offers retry only for the latest unresolved failed message', 0, () => { + const interrupted = chatMessage('assistant-failed-1', 'assistant', 'Partial reply', 'failed'); + interrupted.detail = 'Retry prompt'; + const unresolved = ChatTimelineProjector.project([ + chatMessage('user-1', 'user', 'Retry prompt'), + interrupted + ], [], chatMessage('', 'assistant', ''), false); + const continued = ChatTimelineProjector.project([ + chatMessage('user-1', 'user', 'Retry prompt'), + interrupted, + chatMessage('user-2', 'user', 'Continue') + ], [], chatMessage('', 'assistant', ''), false); + + expect(unresolved[1].showRetryAction).assertTrue(); + expect(continued[1].showRetryAction).assertFalse(); + expect(continued[2].showRetryAction).assertFalse(); + }); }); describe('ChatTimelineStore', () => { 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 3be9a16c59..3fbd4c6b56 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets @@ -177,6 +177,13 @@ export default function lifecycleUnitTest() { expect(state.showSettings).assertTrue(); expect(state.showConnectSheet).assertTrue(); + state.openSettings('account'); + expect(state.showSettings).assertTrue(); + expect(state.settingsMode).assertEqual('account'); + state.leaveSettings(); + expect(state.showSettings).assertTrue(); + expect(state.settingsMode).assertEqual('general'); + state.closeGlobalSurfaces(); expect(state.showSidebar).assertFalse(); expect(state.showSettings).assertFalse(); diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets index 939ef397a1..68a3341a7f 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets @@ -109,6 +109,7 @@ import { RemoteImageContext, RemoteQuestionAnswerPayload, RemoteModelCatalog, + ReadFileChunkResult, ReadFileResult, RemoteSession, SelectedImageAttachment, @@ -655,14 +656,29 @@ export class FakeRemoteFileDownloadClient implements RemoteFileDownloadClient { infoRequests: string[] = []; readRequests: string[] = []; shouldFailRead: boolean = false; + fileInfo: FileInfo = { + name: 'README.md', + size: 4096, + mimeType: 'text/plain' + }; + chunkResult: ReadFileChunkResult = { + name: 'README.md', + contentBase64: 'cmVhZG1l', + offset: 0, + chunkSize: 6, + totalSize: 6, + mimeType: 'text/plain' + }; + fileReadResult: ReadFileResult = { + name: 'README.md', + contentBase64: 'cmVhZG1l', + mimeType: 'text/plain', + size: 4096 + }; async getFileInfo(path: string, sessionId?: string): Promise { this.infoRequests.push(`${path}|${sessionId || ''}`); - return { - name: 'README.md', - size: 4096, - mimeType: 'text/plain' - }; + return this.fileInfo; } async readFile( @@ -677,11 +693,26 @@ export class FakeRemoteFileDownloadClient implements RemoteFileDownloadClient { if (this.shouldFailRead) { throw new Error('Expected download failure.'); } + return this.fileReadResult; + } + + async readFileChunk( + path: string, + offset: number, + limit: number, + sessionId?: string + ): Promise { + this.readRequests.push(`${path}|${sessionId || ''}|${offset}|${limit}`); + if (this.shouldFailRead) { + throw new Error('Expected download failure.'); + } return { - name: 'README.md', - contentBase64: 'cmVhZG1l', - mimeType: 'text/plain', - size: 4096 + name: this.chunkResult.name, + contentBase64: this.chunkResult.contentBase64, + offset, + chunkSize: this.chunkResult.chunkSize, + totalSize: this.chunkResult.totalSize, + mimeType: this.chunkResult.mimeType }; } } 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 402228292b..eaf64f2b06 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -47,7 +47,17 @@ import { } from '../main/ets/services/general-chat/ModelProviderGeneralChatAdapter'; import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelProviderSseParser'; import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; -import { MarkdownParser } from '../main/ets/services/MarkdownParser'; +import { MarkdownParseCache, MarkdownParser } from '../main/ets/services/MarkdownParser'; +import { + ToolFileInputPayload, + ToolFileReferenceResolver +} from '../main/ets/services/ToolFileReferenceResolver'; +import { + CodeSyntaxHighlightCache, + CodeSyntaxHighlighter, + CodeSyntaxToken, + CodeSyntaxTokenKind +} from '../main/ets/services/CodeSyntaxHighlighter'; import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; @@ -59,6 +69,14 @@ import { RemoteFileDownloadController, RemoteFileDownloadScheduler } from '../main/ets/services/RemoteFileDownloadController'; +import { FileReferenceKind, FileTargetResolver } from '../main/ets/services/FileTargetResolver'; +import { FilePreviewPolicy } from '../main/ets/services/FilePreviewPolicy'; +import { FilePreviewErrorPolicy } from '../main/ets/services/FilePreviewErrorPolicy'; +import { + MessageFileReferenceProjectionCache, + MessageFileReferenceProjector +} from '../main/ets/services/MessageFileReferenceProjector'; +import { RemoteFilePreviewController } from '../main/ets/services/RemoteFilePreviewController'; import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; import { RemoteModelClient, @@ -82,17 +100,35 @@ import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/Voi import { AppShellState } from '../main/ets/pages/state/AppShellState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; +import { RemoteCreateSessionState } from '../main/ets/pages/state/RemoteCreateSessionState'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { + FilePreviewPhase, + FilePreviewRendererKind, + FilePreviewState +} from '../main/ets/pages/state/FilePreviewState'; +import { FilePreviewTarget, FilePreviewTargetContext } from '../main/ets/pages/state/FilePreviewTarget'; +import { + FilePreviewPlacement, + FilePreviewPlacementPolicy +} from '../main/ets/pages/state/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'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES } from '../main/ets/pages/components/ChatComposerCapabilities'; import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; +import { + ConversationUiModel, + ConversationUiModelCatalog +} from '../main/ets/pages/components/ConversationUiModels'; import { AppNavigationBackAction, AppNavigationPathSpec, @@ -170,7 +206,606 @@ import { FakeGeneralChatConfigStore } from './LocalTestFixtures'; +class BlockingFileInfoClient extends FakeRemoteFileDownloadClient { + private infoResolver?: (info: FileInfo) => void; + + async getFileInfo(path: string, sessionId?: string): Promise { + this.infoRequests.push(`${path}|${sessionId || ''}`); + return new Promise((resolve: (info: FileInfo) => void) => { + this.infoResolver = resolve; + }); + } + + resolveFileInfo(): void { + if (this.infoResolver) { + const resolve = this.infoResolver; + this.infoResolver = undefined; + resolve(this.fileInfo); + } + } +} + +class MultiBlockingFileInfoClient extends FakeRemoteFileDownloadClient { + private infoResolvers: Map void> = new Map void>(); + + async getFileInfo(path: string, sessionId?: string): Promise { + this.infoRequests.push(`${path}|${sessionId || ''}`); + return new Promise((resolve: (info: FileInfo) => void) => { + this.infoResolvers.set(path, resolve); + }); + } + + resolveFileInfo(path: string, info: FileInfo): void { + const resolve = this.infoResolvers.get(path); + if (resolve) { + this.infoResolvers.delete(path); + resolve(info); + } + } +} + export default function remoteControllersUnitTest() { + describe('ToolFileReferenceResolver', () => { + it('extracts structured single-file paths from supported tools', 0, () => { + const readInput: ToolFileInputPayload = { file_path: 'src/main.rs' }; + const read = ToolFileReferenceResolver.resolve('Read', readInput); + const edit = ToolFileReferenceResolver.resolve('edit_file', undefined, + '{"path":"computer://src/app.ts#L12-L20"}'); + + expect(read?.path || '').assertEqual('src/main.rs'); + expect(read?.label || '').assertEqual('main.rs'); + expect(edit?.path || '').assertEqual('computer://src/app.ts#L12-L20'); + expect(edit?.label || '').assertEqual('app.ts'); + }); + + it('rejects commands, directory tools and unstructured previews', 0, () => { + const fileInput: ToolFileInputPayload = { path: 'src/main.rs' }; + const directoryInput: ToolFileInputPayload = { path: 'src/' }; + expect(ToolFileReferenceResolver.resolve('Bash', fileInput) === undefined).assertTrue(); + expect(ToolFileReferenceResolver.resolve('LS', directoryInput) === undefined).assertTrue(); + expect(ToolFileReferenceResolver.resolve('Read', undefined, 'src/main.rs') === undefined).assertTrue(); + }); + }); + + describe('CodeSyntaxHighlighter', () => { + it('colors common code tokens while preserving numbered source text', 0, () => { + const source = 'const answer = 42;\n// note\nfunction run() { return "ok"; }'; + const tokens = CodeSyntaxHighlighter.tokenize(source, 'server.js'); + const rendered = tokens.map((token: CodeSyntaxToken): string => token.text).join(''); + const kinds = tokens.map((token: CodeSyntaxToken): CodeSyntaxTokenKind => token.kind); + + expect(rendered).assertEqual('1 const answer = 42;\n2 // note\n3 function run() { return "ok"; }'); + expect(kinds.indexOf(CodeSyntaxTokenKind.Keyword) >= 0).assertTrue(); + expect(kinds.indexOf(CodeSyntaxTokenKind.Number) >= 0).assertTrue(); + expect(kinds.indexOf(CodeSyntaxTokenKind.Comment) >= 0).assertTrue(); + expect(kinds.indexOf(CodeSyntaxTokenKind.Function) >= 0).assertTrue(); + expect(kinds.indexOf(CodeSyntaxTokenKind.String) >= 0).assertTrue(); + }); + + it('falls back to one plain span for bounded rendering of large files', 0, () => { + const tokens = CodeSyntaxHighlighter.tokenize('x'.repeat(257 * 1024), 'large.rs'); + + expect(tokens.length).assertEqual(1); + expect(tokens[0].kind).assertEqual(CodeSyntaxTokenKind.Plain); + }); + + it('reuses token arrays until content or language identity changes', 0, () => { + const cache = new CodeSyntaxHighlightCache(); + const first = cache.tokensFor('const answer = 42;', 'main.ts'); + const same = cache.tokensFor('const answer = 42;', 'main.ts'); + const renamed = cache.tokensFor('const answer = 42;', 'main.txt'); + const changed = cache.tokensFor('const answer = 43;', 'main.txt'); + + expect(first === same).assertTrue(); + expect(same === renamed).assertFalse(); + expect(renamed === changed).assertFalse(); + }); + }); + + describe('MarkdownParser', () => { + it('caches parsed blocks until the Markdown source changes', 0, () => { + const cache = new MarkdownParseCache(); + const first = cache.blocksFor('# Title\n\nBody'); + const same = cache.blocksFor('# Title\n\nBody'); + const changed = cache.blocksFor('# Title\n\nUpdated'); + + expect(first === same).assertTrue(); + expect(same === changed).assertFalse(); + expect(MarkdownParser.formatInlineText('[file](README.md)')).assertEqual('file (README.md)'); + }); + }); + + describe('MessageFileReferenceProjector', () => { + it('deduplicates explicit desktop file cards by normalized target path', 0, () => { + const references = MessageFileReferenceProjector.project( + '[first](computer://src/main.rs#L4) and computer://src/main.rs:9, ' + + '[second](computer://src/lib.rs). [relative](README.md) `computer://src/hidden.rs`' + ); + + expect(references.length).assertEqual(2); + expect(references[0].remotePath).assertEqual('src/main.rs'); + expect(references[0].path).assertEqual('computer://src/main.rs#L4'); + expect(references[1].remotePath).assertEqual('src/lib.rs'); + }); + + it('caps cards and reuses projections until message text changes', 0, () => { + const source = 'computer://a.rs computer://b.rs computer://c.rs computer://d.rs computer://e.rs'; + const cache = new MessageFileReferenceProjectionCache(); + const first = cache.referencesFor(source); + const same = cache.referencesFor(source); + const changed = cache.referencesFor('computer://next.rs'); + + expect(first.length).assertEqual(4); + expect(first === same).assertTrue(); + expect(same === changed).assertFalse(); + expect(changed.length).assertEqual(1); + }); + }); + + describe('FileTargetResolver', () => { + it('resolves remote file references and line ranges', 0, () => { + const context = new FilePreviewTargetContext('session-1', '/workspace/BitFun', 4); + const computer = FileTargetResolver.resolve('computer://src/main.rs#L42-L58', '', context); + const relative = FileTargetResolver.resolve('README.md:12-18', 'Readme', context); + const windows = FileTargetResolver.resolve('C:\\workspace\\main.cpp#L9', '', context); + + expect(computer.kind).assertEqual(FileReferenceKind.RemoteWorkspaceFile); + expect(computer.target?.remotePath || '').assertEqual('src/main.rs'); + expect(computer.target?.lineStart || 0).assertEqual(42); + expect(computer.target?.lineEnd || 0).assertEqual(58); + expect(relative.target?.displayName || '').assertEqual('Readme'); + expect(relative.target?.lineStart || 0).assertEqual(12); + expect(windows.target?.remotePath || '').assertEqual('C:\\workspace\\main.cpp'); + expect(windows.target?.lineStart || 0).assertEqual(9); + expect(FileTargetResolver.matchesRemotePath('computer://src/main.rs#L42-L58', 'src/main.rs')).assertTrue(); + expect(FileTargetResolver.matchesRemotePath('src/main.rs:42', 'src/main.rs')).assertTrue(); + expect(FileTargetResolver.matchesRemotePath('src/other.rs', 'src/main.rs')).assertFalse(); + }); + + it('normalizes schemes, encoded paths, extensionless files and trailing punctuation', 0, () => { + const context = new FilePreviewTargetContext('session-1', '/workspace/BitFun', 4); + const fileUrl = FileTargetResolver.resolve('file:///workspace/Makefile', '', context); + const encoded = FileTargetResolver.resolve('computer://docs/My%20File.md),', '', context); + const dockerfile = FileTargetResolver.resolve('/workspace/Dockerfile', '', context); + const dotfile = FileTargetResolver.resolve('.env', '', context); + const extensionless = FileTargetResolver.resolve('LICENSE', '', context); + const repeated = FileTargetResolver.resolve('file:///workspace/Makefile', '', context); + + expect(fileUrl.target?.remotePath || '').assertEqual('/workspace/Makefile'); + expect(encoded.target?.remotePath || '').assertEqual('docs/My File.md'); + expect(dockerfile.kind).assertEqual(FileReferenceKind.RemoteWorkspaceFile); + expect(dotfile.kind).assertEqual(FileReferenceKind.RemoteWorkspaceFile); + expect(extensionless.kind).assertEqual(FileReferenceKind.RemoteWorkspaceFile); + expect(repeated.target?.remotePath || '').assertEqual(fileUrl.target?.remotePath || ''); + }); + + it('classifies web, anchor and unsupported references without file targets', 0, () => { + const context = new FilePreviewTargetContext('session-1', '/workspace/BitFun', 1); + expect(FileTargetResolver.resolve('https://example.com', '', context).kind) + .assertEqual(FileReferenceKind.HttpUrl); + expect(FileTargetResolver.resolve('http://example.com', '', context).kind) + .assertEqual(FileReferenceKind.HttpUrl); + expect(FileTargetResolver.resolve('#section', '', context).kind) + .assertEqual(FileReferenceKind.Anchor); + expect(FileTargetResolver.resolve('mailto:test@example.com', '', context).kind) + .assertEqual(FileReferenceKind.UnsupportedScheme); + expect(FileTargetResolver.resolve('', '', context).kind) + .assertEqual(FileReferenceKind.Invalid); + }); + }); + + describe('FilePreviewState', () => { + it('targets linked lines and restores the last user scroll offset', 0, () => { + const state = new FilePreviewState(); + state.begin(new FilePreviewTarget( + 'src/main.rs#L42-L48', 'src/main.rs', 'main.rs', 'session-1', '/workspace', 1, 42, 48 + )); + + expect(state.initialScrollY()).assertEqual(741); + state.recordScroll(15, 380); + expect(state.initialScrollX()).assertEqual(15); + expect(state.initialScrollY()).assertEqual(380); + + state.begin(new FilePreviewTarget( + 'src/lib.rs', 'src/lib.rs', 'lib.rs', 'session-1', '/workspace', 1 + )); + expect(state.initialScrollY()).assertEqual(0); + }); + }); + + describe('RemoteFilePreviewController', () => { + it('centralizes bounded text and image size decisions in FilePreviewPolicy', 0, () => { + expect(FilePreviewPolicy.textReadLimit(128)).assertEqual(128); + expect(FilePreviewPolicy.textReadLimit(0)).assertEqual(2 * 1024 * 1024); + expect(FilePreviewPolicy.textReadLimit(3 * 1024 * 1024)).assertEqual(2 * 1024 * 1024); + expect(FilePreviewPolicy.canPreviewImage(12 * 1024 * 1024)).assertTrue(); + expect(FilePreviewPolicy.canPreviewImage(12 * 1024 * 1024 + 1)).assertFalse(); + }); + + it('maps file protocol failures to localized preview errors', 0, () => { + const missing = FilePreviewErrorPolicy.resolve(new Error('File not found: /workspace/missing.md')); + const unresolved = FilePreviewErrorPolicy.resolve(new Error('Remote file path could not be resolved: missing.md')); + const denied = FilePreviewErrorPolicy.resolve(new Error('Path outside workspace is restricted')); + const tooLarge = FilePreviewErrorPolicy.resolve(new Error('File too large (20 bytes, limit 10 bytes)')); + const unknown = FilePreviewErrorPolicy.resolve(new Error('Unexpected backend detail')); + + expect(missing.text).assertEqual(RemoteI18n.t('filePreview.notFound')); + expect(missing.retryable).assertTrue(); + expect(unresolved.text).assertEqual(RemoteI18n.t('filePreview.unavailable')); + expect(unresolved.retryable).assertTrue(); + expect(denied.text).assertEqual(RemoteI18n.t('filePreview.accessDenied')); + expect(denied.retryable).assertFalse(); + expect(tooLarge.text).assertEqual(RemoteI18n.t('filePreview.tooLarge')); + expect(tooLarge.retryable).assertFalse(); + expect(unknown.text).assertEqual(RemoteI18n.t('filePreview.loadFailed')); + expect(unknown.retryable).assertTrue(); + }); + + it('loads a bounded text preview without global busy state', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + client.fileInfo = { name: 'main.rs', size: 16, mimeType: 'text/plain' }; + client.chunkResult = { + name: 'main.rs', + contentBase64: Encoding.bytesToBase64(Encoding.utf8ToBytes('fn main() {}')), + offset: 0, + chunkSize: 12, + totalSize: 16, + mimeType: 'text/plain' + }; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => true, () => 3); + const target = new FilePreviewTarget( + 'computer://src/main.rs', 'src/main.rs', 'main.rs', 'session-1', '/workspace/BitFun', 3 + ); + + await controller.open(target); + + expect(state.phase).assertEqual(FilePreviewPhase.Ready); + expect(state.rendererKind).assertEqual(FilePreviewRendererKind.Text); + expect(state.textContent).assertEqual('fn main() {}'); + expect(state.truncated).assertTrue(); + expect(client.infoRequests[0]).assertEqual('src/main.rs|session-1'); + expect(client.readRequests[0].indexOf('src/main.rs|session-1|0|16') === 0).assertTrue(); + }); + + it('does not transfer unsupported binary files', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + client.fileInfo = { name: 'archive.zip', size: 2048, mimeType: 'application/zip' }; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => true, () => 2); + + await controller.open(new FilePreviewTarget( + 'archive.zip', 'archive.zip', 'archive.zip', 'session-1', '/workspace', 2 + )); + + expect(state.phase).assertEqual(FilePreviewPhase.Unsupported); + expect(state.rendererKind).assertEqual(FilePreviewRendererKind.Unsupported); + expect(client.readRequests.length).assertEqual(0); + }); + + it('uses a bounded default range for unknown text sizes and rejects invalid UTF-8', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + client.fileInfo = { name: 'unknown.txt', size: 0, mimeType: 'text/plain' }; + client.chunkResult = { + name: 'unknown.txt', + contentBase64: Encoding.bytesToBase64(new Uint8Array([0xC3, 0x28])), + offset: 0, + chunkSize: 2, + totalSize: 0, + mimeType: 'text/plain' + }; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => true, () => 2); + + await controller.open(new FilePreviewTarget( + 'unknown.txt', 'unknown.txt', 'unknown.txt', 'session-1', '/workspace', 2 + )); + + expect(state.phase).assertEqual(FilePreviewPhase.Unsupported); + expect(client.readRequests[0].indexOf('|0|2097152') >= 0).assertTrue(); + }); + + it('rejects an image when returned content exceeds the preview limit', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + client.fileInfo = { name: 'changed.png', size: 1024, mimeType: 'image/png' }; + client.fileReadResult = { + name: 'changed.png', + contentBase64: '', + mimeType: 'image/png', + size: 13 * 1024 * 1024 + }; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => true, () => 2); + + await controller.open(new FilePreviewTarget( + 'changed.png', 'changed.png', 'changed.png', 'session-1', '/workspace', 2 + )); + + expect(state.phase).assertEqual(FilePreviewPhase.Unsupported); + expect(state.contentBase64).assertEqual(''); + }); + + it('loads Markdown and image content into their dedicated renderer states', 0, async () => { + const markdownClient = new FakeRemoteFileDownloadClient(); + markdownClient.fileInfo = { name: 'README.md', size: 7, mimeType: 'text/markdown' }; + markdownClient.chunkResult = { + name: 'README.md', + contentBase64: Encoding.bytesToBase64(Encoding.utf8ToBytes('# Title')), + offset: 0, + chunkSize: 7, + totalSize: 7, + mimeType: 'text/markdown' + }; + const markdownState = new FilePreviewState(); + const markdownController = new RemoteFilePreviewController(markdownClient, markdownState, () => true, () => 2); + await markdownController.open(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 2 + )); + + expect(markdownState.phase).assertEqual(FilePreviewPhase.Ready); + expect(markdownState.rendererKind).assertEqual(FilePreviewRendererKind.Markdown); + expect(markdownState.textContent).assertEqual('# Title'); + + const imageClient = new FakeRemoteFileDownloadClient(); + imageClient.fileInfo = { name: 'photo.png', size: 4, mimeType: 'image/png' }; + imageClient.fileReadResult = { + name: 'photo.png', + contentBase64: 'AAECAw==', + mimeType: 'image/png', + size: 4 + }; + const imageState = new FilePreviewState(); + const imageController = new RemoteFilePreviewController(imageClient, imageState, () => true, () => 2); + await imageController.open(new FilePreviewTarget( + 'photo.png', 'photo.png', 'photo.png', 'session-1', '/workspace', 2 + )); + + expect(imageState.phase).assertEqual(FilePreviewPhase.Ready); + expect(imageState.rendererKind).assertEqual(FilePreviewRendererKind.Image); + expect(imageState.contentBase64).assertEqual('AAECAw=='); + }); + + it('does not access the client for unavailable or invalid targets', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + let available = false; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => available, () => 2); + + await controller.open(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 2 + )); + available = true; + await controller.open(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', '', '/workspace', 2 + )); + + expect(client.infoRequests.length).assertEqual(0); + expect(client.readRequests.length).assertEqual(0); + expect(state.visible).assertFalse(); + }); + + it('discards results after close or control-target changes', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + const state = new FilePreviewState(); + let epoch = 5; + const controller = new RemoteFilePreviewController(client, state, () => true, () => epoch); + const target = new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 5 + ); + + const pending = controller.open(target); + controller.close(); + epoch = 6; + await pending; + + expect(state.visible).assertFalse(); + expect(state.phase).assertEqual(FilePreviewPhase.Idle); + await controller.open(target); + expect(state.visible).assertFalse(); + }); + + it('turns an interrupted load into a retryable error after a transient disconnect', 0, async () => { + const client = new BlockingFileInfoClient(); + client.fileInfo = { name: 'README.md', size: 6, mimeType: 'text/plain' }; + client.chunkResult = { + name: 'README.md', + contentBase64: Encoding.bytesToBase64(Encoding.utf8ToBytes('readme')), + offset: 0, + chunkSize: 6, + totalSize: 6, + mimeType: 'text/plain' + }; + const state = new FilePreviewState(); + let available = true; + const controller = new RemoteFilePreviewController(client, state, () => available, () => 4); + const target = new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 4 + ); + + const interrupted = controller.open(target); + expect(state.phase).assertEqual(FilePreviewPhase.Loading); + available = false; + client.resolveFileInfo(); + await interrupted; + + expect(state.visible).assertTrue(); + expect(state.phase).assertEqual(FilePreviewPhase.Error); + expect(state.errorText).assertEqual(RemoteI18n.t('filePreview.offline')); + expect(state.target.remotePath).assertEqual('README.md'); + + available = true; + const retried = controller.refresh(); + client.resolveFileInfo(); + await retried; + + expect(state.phase).assertEqual(FilePreviewPhase.Ready); + expect(state.textContent).assertEqual('readme'); + }); + + it('keeps C when A and B metadata resolve after three rapid requests', 0, async () => { + const client = new MultiBlockingFileInfoClient(); + client.chunkResult = { + name: 'c.rs', + contentBase64: Encoding.bytesToBase64(Encoding.utf8ToBytes('fn c() {}')), + offset: 0, + chunkSize: 9, + totalSize: 9, + mimeType: 'text/plain' + }; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => true, () => 7); + const targetA = new FilePreviewTarget('a.rs', 'a.rs', 'a.rs', 'session-1', '/workspace', 7); + const targetB = new FilePreviewTarget('b.rs', 'b.rs', 'b.rs', 'session-1', '/workspace', 7); + const targetC = new FilePreviewTarget('c.rs', 'c.rs', 'c.rs', 'session-1', '/workspace', 7); + + const pendingA = controller.open(targetA); + const pendingB = controller.open(targetB); + const pendingC = controller.open(targetC); + client.resolveFileInfo('c.rs', { name: 'c.rs', size: 9, mimeType: 'text/plain' }); + await pendingC; + client.resolveFileInfo('b.rs', { name: 'b.rs', size: 9, mimeType: 'text/plain' }); + await pendingB; + client.resolveFileInfo('a.rs', { name: 'a.rs', size: 9, mimeType: 'text/plain' }); + await pendingA; + + expect(state.phase).assertEqual(FilePreviewPhase.Ready); + expect(state.target.remotePath).assertEqual('c.rs'); + expect(state.textContent).assertEqual('fn c() {}'); + expect(client.readRequests.length).assertEqual(1); + expect(client.readRequests[0].indexOf('c.rs|session-1') === 0).assertTrue(); + }); + + it('classifies markdown, images and executable SVG conservatively', 0, () => { + expect(RemoteFilePreviewController.rendererFor('README.md', 'text/markdown')) + .assertEqual(FilePreviewRendererKind.Markdown); + expect(RemoteFilePreviewController.rendererFor('Dockerfile', 'application/octet-stream')) + .assertEqual(FilePreviewRendererKind.Text); + expect(RemoteFilePreviewController.rendererFor('Makefile', 'application/octet-stream')) + .assertEqual(FilePreviewRendererKind.Text); + expect(RemoteFilePreviewController.rendererFor('LICENSE', 'application/octet-stream')) + .assertEqual(FilePreviewRendererKind.Text); + expect(RemoteFilePreviewController.rendererFor('photo.png', 'image/png')) + .assertEqual(FilePreviewRendererKind.Image); + expect(RemoteFilePreviewController.rendererFor('drawing.svg', 'image/svg+xml')) + .assertEqual(FilePreviewRendererKind.Unsupported); + }); + }); + + describe('FilePreviewPlacementPolicy', () => { + it('keeps compact devices full-page and regular tablets focused on chat plus preview', 0, () => { + expect(FilePreviewPlacementPolicy.resolve(true, false, 1080, [])) + .assertEqual(FilePreviewPlacement.CompactFullPage); + expect(FilePreviewPlacementPolicy.resolve(true, true, 900, [])) + .assertEqual(FilePreviewPlacement.WideFocusSplit); + expect(FilePreviewPlacementPolicy.resolve(false, true, 1400, [])) + .assertEqual(FilePreviewPlacement.Hidden); + }); + + it('uses triple pane only when all flat or crease-aligned panes meet minimum widths', 0, () => { + expect(FilePreviewPlacementPolicy.resolve(true, true, 1002, [])) + .assertEqual(FilePreviewPlacement.WideTriplePane); + expect(FilePreviewPlacementPolicy.resolve(true, true, 1200, [ + new ConversationLayoutCrease(300, 8), + new ConversationLayoutCrease(700, 8) + ])).assertEqual(FilePreviewPlacement.WideTriplePane); + expect(FilePreviewPlacementPolicy.resolve(true, true, 1050, [ + new ConversationLayoutCrease(280, 8), + new ConversationLayoutCrease(620, 8) + ])).assertEqual(FilePreviewPlacement.WideFocusSplit); + }); + + it('returns renderable pane geometry that matches flat and crease-aligned decisions', 0, () => { + const flat = FilePreviewPlacementPolicy.resolveLayout(true, true, 1100, [], 344); + expect(flat.placement).assertEqual(FilePreviewPlacement.WideTriplePane); + expect(flat.masterPaneWidth).assertEqual(344); + expect(flat.masterConversationGap).assertEqual(1); + expect(flat.conversationPaneWidth).assertEqual(377); + expect(flat.conversationPreviewGap).assertEqual(1); + expect(flat.previewPaneWidth).assertEqual(377); + + const creased = FilePreviewPlacementPolicy.resolveLayout(true, true, 1260, [ + new ConversationLayoutCrease(320, 12), + new ConversationLayoutCrease(760, 16) + ], 344); + expect(creased.placement).assertEqual(FilePreviewPlacement.WideTriplePane); + expect(creased.masterPaneWidth).assertEqual(320); + expect(creased.masterConversationGap).assertEqual(12); + expect(creased.conversationPaneWidth).assertEqual(428); + expect(creased.conversationPreviewGap).assertEqual(16); + expect(creased.previewPaneWidth).assertEqual(484); + }); + + it('shrinks the flat master pane at the triple-pane threshold without narrowing content panes', 0, () => { + const threshold = FilePreviewPlacementPolicy.resolveLayout(true, true, 1002, [], 344); + expect(threshold.placement).assertEqual(FilePreviewPlacement.WideTriplePane); + expect(threshold.masterPaneWidth).assertEqual(280); + expect(threshold.conversationPaneWidth).assertEqual(360); + expect(threshold.previewPaneWidth).assertEqual(360); + }); + + it('handles invalid, unsorted and rotation-sized geometry conservatively', 0, () => { + expect(FilePreviewPlacementPolicy.resolve(true, true, 900, [ + new ConversationLayoutCrease(-20, 8), + new ConversationLayoutCrease(880, 40) + ])).assertEqual(FilePreviewPlacement.WideFocusSplit); + expect(FilePreviewPlacementPolicy.resolve(true, true, 1200, [ + new ConversationLayoutCrease(700, 8), + new ConversationLayoutCrease(300, 8) + ])).assertEqual(FilePreviewPlacement.WideTriplePane); + expect(FilePreviewPlacementPolicy.resolve(true, true, 760, [])) + .assertEqual(FilePreviewPlacement.WideFocusSplit); + expect(FilePreviewPlacementPolicy.resolve(true, false, 760, [])) + .assertEqual(FilePreviewPlacement.CompactFullPage); + }); + + it('keeps the focus layout full-page until both panes meet their minimum widths', 0, () => { + expect(FilePreviewPlacementPolicy.resolve(true, true, 720, [])) + .assertEqual(FilePreviewPlacement.CompactFullPage); + const threshold = FilePreviewPlacementPolicy.resolveLayout(true, true, 721, []); + expect(threshold.placement).assertEqual(FilePreviewPlacement.WideFocusSplit); + expect(threshold.conversationPaneWidth).assertEqual(360); + expect(threshold.previewPaneWidth).assertEqual(360); + }); + }); + + describe('RemoteCreateSessionState', () => { + it('keeps selected device and workspace labels aligned with refreshed choices', 0, () => { + const state = new RemoteCreateSessionState(); + state.prepare('desktop-b', 'stale device label'); + state.selectWorkspace({ + name: 'stale workspace label', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' + }); + state.setDevices([ + { deviceId: 'desktop-a', deviceName: 'Desktop A', online: true }, + { deviceId: 'desktop-b', deviceName: 'Desktop B', online: true } + ]); + state.setWorkspaces([ + { name: 'BitFun', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' }, + { name: 'flashgrep', path: '/workspace/flashgrep', lastOpened: '', workspaceKind: 'normal' } + ]); + + expect(state.selectedDeviceName).assertEqual('Desktop B'); + expect(state.selectedWorkspaceName).assertEqual('BitFun'); + }); + + it('freezes the selected creation target and keeps workspace chats as Claw sessions', 0, () => { + const state = new RemoteCreateSessionState(); + state.prepare('desktop-b', 'Desktop B'); + state.setWorkspaces([{ + name: 'BitFun', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' + }]); + state.selectWorkspace({ + name: 'BitFun', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' + }); + + const context = state.submissionContext(); + + expect(context.deviceId).assertEqual('desktop-b'); + expect(context.workspacePath).assertEqual('/workspace/BitFun'); + expect(context.agentType).assertEqual('Claw'); + }); + }); + describe('RemoteFileDownloadController', () => { it('owns remote file download progress and delayed downloading marker cleanup', 0, async () => { const client = new FakeRemoteFileDownloadClient(); @@ -1044,19 +1679,221 @@ export default function remoteControllersUnitTest() { }); }); + describe('SessionActionPolicy', () => { + it('exposes only capabilities backed by each session scope', 0, () => { + const generalChat = SessionActionPolicy.resolve(SessionActionScope.General, 'chat', false); + const generalCode = SessionActionPolicy.resolve(SessionActionScope.General, 'code', false); + const remote = SessionActionPolicy.resolve(SessionActionScope.Remote, 'code', false); + const busyRemote = SessionActionPolicy.resolve(SessionActionScope.Remote, 'code', true); + + expect(generalChat.canArchive).assertTrue(); + expect(generalChat.canViewDetails).assertTrue(); + expect(generalChat.canExport).assertTrue(); + expect(generalChat.canDelete).assertTrue(); + expect(generalCode.canArchive).assertFalse(); + expect(generalCode.canExport).assertFalse(); + expect(generalCode.canDelete).assertTrue(); + expect(remote.canArchive).assertFalse(); + expect(remote.canViewDetails).assertTrue(); + expect(remote.canExport).assertFalse(); + expect(remote.canDelete).assertTrue(); + expect(busyRemote.canDelete).assertFalse(); + expect(busyRemote.canViewDetails).assertFalse(); + }); + }); + + describe('ConversationSessionFilterPolicy', () => { + const codeSession: RemoteSession = { + id: 'code-1', + title: 'Fix layout', + agentType: 'code', + status: 'idle', + updatedAt: '', + createdAt: '', + messageCount: 3, + workspacePath: '/workspace/bitfun' + }; + const chatSession: RemoteSession = { + id: 'chat-1', + title: 'Product notes', + agentType: 'claw', + status: 'active', + updatedAt: '', + createdAt: '', + messageCount: 2 + }; + + it('combines query, workspace, agent and status filters', 0, () => { + expect(ConversationSessionFilterPolicy.matches( + codeSession, 'layout', '', '/workspace/bitfun', 'code', 'idle', false + )).assertTrue(); + expect(ConversationSessionFilterPolicy.matches( + codeSession, 'notes', '', '/workspace/bitfun', 'code', 'idle', false + )).assertFalse(); + expect(ConversationSessionFilterPolicy.matches( + codeSession, '', '', '/workspace/other', 'code', 'idle', false + )).assertFalse(); + expect(ConversationSessionFilterPolicy.matches( + codeSession, '', '', '/workspace/bitfun/', 'code', 'idle', false + )).assertTrue(); + expect(ConversationSessionFilterPolicy.workspacePathsEqual( + '/workspace/bitfun/', '/workspace/bitfun' + )).assertTrue(); + expect(ConversationSessionFilterPolicy.matches( + chatSession, '', '/workspace/assistant', '', 'chat', 'active', true + )).assertTrue(); + expect(ConversationSessionFilterPolicy.matches( + chatSession, '', '/workspace/assistant', '/workspace/assistant', 'chat', 'active', true + )).assertFalse(); + }); + + it('excludes invalid and archived sessions before presentation grouping', 0, () => { + const archived: RemoteSession = { + id: 'archived-1', + title: 'Archived', + agentType: 'cowork', + status: 'archived', + updatedAt: '', + createdAt: '', + messageCount: 1 + }; + expect(ConversationSessionFilterPolicy.matches( + archived, '', '', '', '', '', false + )).assertFalse(); + expect(ConversationSessionFilterPolicy.agentGroup(chatSession, true)).assertEqual('chat'); + expect(ConversationSessionFilterPolicy.agentGroup(archived, false)).assertEqual('cowork'); + }); + }); + + describe('ConversationModelPresentationPolicy', () => { + const primaryModel: ConversationUiModel = { + id: 'anthropic/claude-sonnet-4', + name: 'Anthropic', + provider: 'anthropic', + base_url: '', + model_name: 'anthropic/claude-sonnet-4', + enabled: true, + capabilities: [] + }; + const fastModel: ConversationUiModel = { + id: 'openbitfun:gpt-5-mini', + name: 'Fast', + provider: 'openai', + base_url: '', + model_name: 'openbitfun:gpt-5-mini', + enabled: true, + capabilities: [] + }; + const disabledModel: ConversationUiModel = { + id: 'disabled-model', + name: 'Disabled', + provider: 'test', + base_url: '', + model_name: 'disabled-model', + enabled: false, + capabilities: [] + }; + const catalog: ConversationUiModelCatalog = { + version: 1, + models: [primaryModel, fastModel, disabledModel], + default_models: { primary: primaryModel.id }, + session_model_id: fastModel.id + }; + + it('filters disabled models and resolves selection precedence', 0, () => { + const enabled = ConversationModelPresentationPolicy.enabledModels(catalog); + expect(enabled.length).assertEqual(2); + expect(ConversationModelPresentationPolicy.selectedModel(catalog, primaryModel.id)?.id) + .assertEqual(primaryModel.id); + expect(ConversationModelPresentationPolicy.selectedModel(catalog, '')?.id) + .assertEqual(fastModel.id); + + const defaultCatalog: ConversationUiModelCatalog = { + version: 1, + models: catalog.models, + default_models: catalog.default_models + }; + expect(ConversationModelPresentationPolicy.selectedModel(defaultCatalog, '')?.id) + .assertEqual(primaryModel.id); + expect(ConversationModelPresentationPolicy.selectedModel(catalog, disabledModel.id)?.id) + .assertEqual(fastModel.id); + }); + + it('uses specific model names and preserves useful provider context', 0, () => { + expect(ConversationModelPresentationPolicy.primaryLabel(primaryModel, 'Model')) + .assertEqual('claude-sonnet-4'); + expect(ConversationModelPresentationPolicy.primaryLabel(fastModel, 'Model')) + .assertEqual('gpt-5-mini'); + expect(ConversationModelPresentationPolicy.secondaryLabel(fastModel, 'Model')) + .assertEqual('openai · Fast'); + }); + }); + describe('ConversationLayoutPolicy', () => { it('keeps folded and narrow surfaces compact', 0, () => { - expect(ConversationLayoutPolicy.useMasterDetail(480, false, false)).assertFalse(); - expect(ConversationLayoutPolicy.useMasterDetail(900, true, true)).assertFalse(); - expect(ConversationLayoutPolicy.useMasterDetail(900, false, true)).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail(480, false, false, 'tablet', [])).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail(900, true, true, 'tablet', [])).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail(900, false, true, 'phone', [ + new ConversationLayoutCrease(300, 8), + new ConversationLayoutCrease(600, 8) + ])).assertFalse(); }); - it('uses master-detail for media-query or width-qualified surfaces', 0, () => { - expect(ConversationLayoutPolicy.useMasterDetail(480, true, false)).assertTrue(); + it('uses master-detail for width-qualified tablets', 0, () => { expect(ConversationLayoutPolicy.useMasterDetail( ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH, false, - false + false, + 'tablet', + [] + )).assertTrue(); + expect(ConversationLayoutPolicy.useMasterDetail( + 480, + true, + false, + 'tablet', + [] + )).assertTrue(); + }); + + it('keeps phone, single-fold, and dual-screen foldables compact', 0, () => { + expect(ConversationLayoutPolicy.useMasterDetail( + ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + true, + false, + 'phone', + [] + )).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail( + ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + true, + false, + 'phone', + [new ConversationLayoutCrease(520, 8)] + )).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail( + ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + true, + false, + 'tablet', + [new ConversationLayoutCrease(520, 8)] + )).assertFalse(); + }); + + it('uses master-detail for unfolded tri-fold surfaces', 0, () => { + expect(ConversationLayoutPolicy.useMasterDetail( + ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + false, + false, + 'phone', + [new ConversationLayoutCrease(352, 8), new ConversationLayoutCrease(712, 8)] + )).assertTrue(); + expect(ConversationLayoutPolicy.useMasterDetail( + ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH, + false, + false, + 'phone', + [new ConversationLayoutCrease(230, 8), new ConversationLayoutCrease(480, 8)] )).assertTrue(); }); @@ -1071,6 +1908,8 @@ export default function remoteControllersUnitTest() { expect(geometry.isExtraWide).assertFalse(); expect(geometry.detailContentOffset).assertEqual(0); expect(geometry.detailContentWidth).assertEqual(516); + expect(geometry.collapsedDetailContentOffset).assertEqual(0); + expect(geometry.collapsedDetailContentWidth).assertEqual(860); expect(invalidCrease.masterPaneWidth).assertEqual(ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH); }); @@ -1079,6 +1918,8 @@ export default function remoteControllersUnitTest() { expect(geometry.masterPaneWidth).assertEqual(ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH); expect(geometry.detailContentOffset).assertEqual(0); expect(geometry.detailContentWidth).assertEqual(0); + expect(geometry.collapsedDetailContentOffset).assertEqual(0); + expect(geometry.collapsedDetailContentWidth).assertEqual(300); }); it('aligns the master boundary with the first usable fold crease', 0, () => { @@ -1114,6 +1955,8 @@ export default function remoteControllersUnitTest() { expect(geometry.isExtraWide).assertTrue(); expect(geometry.detailContentOffset).assertEqual(360); expect(geometry.detailContentWidth).assertEqual(360); + expect(geometry.collapsedDetailContentOffset).assertEqual(720); + expect(geometry.collapsedDetailContentWidth).assertEqual(360); }); it('selects the widest hinge-free detail band on asymmetric three-screen geometry', 0, () => { @@ -1123,6 +1966,8 @@ export default function remoteControllersUnitTest() { ); expect(geometry.detailContentOffset).assertEqual(0); expect(geometry.detailContentWidth).assertEqual(440); + expect(geometry.collapsedDetailContentOffset).assertEqual(360); + expect(geometry.collapsedDetailContentWidth).assertEqual(440); }); it('uses width as the extra-wide fallback when crease data is missing', 0, () => { 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 2423f95116..e6b32f7f91 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -20,10 +20,12 @@ import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ import { GeneralChatConfigSnapshot, GeneralChatConfigStore, - GeneralChatConfigValidator + GeneralChatConfigValidator, + GeneralChatModelSelectionPolicy } from '../main/ets/services/general-chat/GeneralChatConfigStore'; import { GeneralChatDraftController, GeneralChatDraftScheduler } from '../main/ets/services/general-chat/GeneralChatDraftController'; import { GeneralChatDraftLifecycleController } from '../main/ets/services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatCloudConfigPolicy } from '../main/ets/services/general-chat/GeneralChatCloudConfigPolicy'; import { GeneralChatEventMapper } from '../main/ets/services/general-chat/GeneralChatEventMapper'; import { GeneralChatExportFormatter } from '../main/ets/services/general-chat/GeneralChatExportFormatter'; import { @@ -532,6 +534,21 @@ export default function transportAndGeneralChatUnitTest() { expect(messages.length).assertEqual(1); expect(messages[0].status).assertEqual('sent'); }); + + it('does not persist assistant retry detail after an interrupted turn', 0, async () => { + const localStore = new FakeGeneralChatLocalStore(); + const repository = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); + const session = await repository.createSession('中断恢复'); + const interrupted = chatMessage('assistant-failed-1', 'assistant', 'Partial reply', 'failed'); + interrupted.detail = 'Retry prompt'; + + await repository.recordAssistantMessage(session.id, interrupted); + + const messages = await repository.messages(session.id); + expect(messages[0].status).assertEqual('failed'); + expect(messages[0].detail || '').assertEqual(''); + expect((await localStore.loadMessages(session.id))[0].detail || '').assertEqual(''); + }); }); describe('GeneralChatExportFormatter', () => { @@ -731,6 +748,73 @@ export default function transportAndGeneralChatUnitTest() { }); }); + describe('GeneralChatModelSelectionPolicy', () => { + it('keeps an existing cloud selection when a local model is saved', 0, () => { + const shouldActivateLocal = GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel({ + version: 4, + models: [{ + id: 'cloud:account-model', + name: 'Account model', + provider: 'openai', + base_url: 'https://chat.example.com/v1', + model_name: 'account-model', + enabled: true, + capabilities: [] + }], + default_models: { primary: 'cloud:account-model' }, + session_model_id: 'cloud:account-model' + }); + + expect(shouldActivateLocal).assertFalse(); + }); + + it('activates the first saved local model when no model was previously available', 0, () => { + const shouldActivateLocal = GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel({ + version: 1, + models: [], + default_models: {} + }); + + expect(shouldActivateLocal).assertTrue(); + }); + }); + + describe('GeneralChatCloudConfigPolicy', () => { + it('orders the primary model first while retaining other compatible account models', 0, () => { + const payload = '{"config":{"ai":{"default_models":{"primary":"primary-model"},"models":[' + + '{"id":"fallback-model","provider":"openai","model_name":"fallback","base_url":"https://fallback.example.com/v1","api_key":"fallback-key","enabled":true,"category":"general_chat"},' + + '{"id":"primary-model","provider":"openai","model_name":"primary","base_url":"https://primary.example.com/v1/","api_key":"primary-key","enabled":true,"category":"code_specialized"}' + + ']}}}'; + + const models = GeneralChatCloudConfigPolicy.models(payload); + + expect(models.length).assertEqual(2); + expect(models[0].modelId).assertEqual('cloud:primary-model'); + expect(models[0].apiUrl).assertEqual('https://primary.example.com/v1'); + expect(models[0].modelName).assertEqual('primary'); + expect(models[0].apiKey).assertEqual('primary-key'); + expect(models[1].modelId).assertEqual('cloud:fallback-model'); + }); + + it('skips subscription models and adapts an Anthropic general-chat endpoint', 0, () => { + const payload = '{"ai":{"default_models":{"primary":"subscription-model"},"models":[' + + '{"id":"subscription-model","provider":"openai","model_name":"codex","base_url":"https://subscription.example.com","api_key":"unused","enabled":true,"category":"general_chat","auth":{"type":"subscription"}},' + + '{"id":"anthropic-model","provider":"anthropic","model_name":"claude","base_url":"https://api.anthropic.com/","api_key":"anthropic-key","enabled":true,"category":"general_chat"}' + + ']}}'; + + const selected = GeneralChatCloudConfigPolicy.selectModel(payload); + + expect(selected?.modelId).assertEqual('cloud:anthropic-model'); + expect(selected?.apiUrl).assertEqual('https://api.anthropic.com/v1/messages'); + }); + + it('returns no model for malformed or unusable cloud settings', 0, () => { + const unusable = '{"ai":{"models":[{"id":"disabled","enabled":false,"base_url":"https://api.example.com","model_name":"model","api_key":"key"}]}}'; + expect(GeneralChatCloudConfigPolicy.models('{not-json').length).assertEqual(0); + expect(GeneralChatCloudConfigPolicy.models(unusable).length).assertEqual(0); + }); + }); + describe('ModelProviderGeneralChatAdapter', () => { it('infers provider protocol and request URL without a protocol setting', 0, () => { const openBitFunProtocol = ModelProviderGeneralChatAdapter.resolveProtocol('https://api.openbitfun.com');