diff --git a/src/apps/mobile/harmonyos/entry/build-profile.json5 b/src/apps/mobile/harmonyos/entry/build-profile.json5 index 701c3dad01..0fc4bd6dbb 100644 --- a/src/apps/mobile/harmonyos/entry/build-profile.json5 +++ b/src/apps/mobile/harmonyos/entry/build-profile.json5 @@ -31,6 +31,22 @@ }, { "name": "ohosTest", + }, + { + // Emulator-only target. The x86_64 emulator cannot install the shipped + // arm64-only `default` artifact, so a separate target carries the ABI + // override. `default` stays arm64-only: nothing else may add x86_64 to it. + // Build with `-p module=entry@emulator`. + "name": "emulator", + "config": { + "buildOption": { + "externalNativeOptions": { + "abiFilters": [ + "x86_64" + ] + } + } + } } ] } 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 34ac44d932..92abe3210e 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 @@ -13,7 +13,7 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const scenarioId = want.parameters?.['openbitfunDesignPreview']; - if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || + if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || scenarioId === 'fold-context' || scenarioId === 'long-reading' || @@ -25,9 +25,9 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' + const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK - : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' + : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT : ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; this.context.getApplicationContext().setColorMode(colorMode); @@ -63,7 +63,11 @@ export default class EntryAbility extends UIAbility { try { const mainWindow = windowStage.getMainWindowSync(); this.mainWindow = mainWindow; - mainWindow.setWindowLayoutFullScreen(false); + // The shell runs immersive: the chat page's transcript has to reach the + // bottom edge of the screen, and pages read the avoid areas themselves to + // keep their content clear of the system bars. + mainWindow.setWindowLayoutFullScreen(true); + this.logWindowInsets(mainWindow); this.updateSystemBars(this.context.config?.colorMode); } catch (err) { hilog.error(DOMAIN, TAG, 'Failed to configure window bars. Cause: %{public}s', JSON.stringify(err)); @@ -102,6 +106,24 @@ export default class EntryAbility extends UIAbility { hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onBackground'); } + /** + * The immersive shell is not verifiable from a screenshot alone: whether the + * window covers the status bar and how tall the bottom strip is are device + * facts, so state them once per launch. + */ + private logWindowInsets(mainWindow: window.Window): void { + try { + const system = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); + const navigation = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR); + hilog.info(DOMAIN, TAG, + 'Window insets statusBar=%{public}s navigation=%{public}s windowRect=%{public}s', + String(system.topRect.height), String(navigation.bottomRect.height), + JSON.stringify(mainWindow.getWindowProperties().windowRect)); + } catch (err) { + hilog.error(DOMAIN, TAG, 'Failed to read window insets. Cause: %{public}s', JSON.stringify(err)); + } + } + private updateSystemBars(colorMode?: ConfigurationConstant.ColorMode): void { if (!this.mainWindow) { return; @@ -111,8 +133,12 @@ export default class EntryAbility extends UIAbility { const content = dark ? MobileDesignColors.ink.dark : MobileDesignColors.ink.light; try { this.mainWindow.setWindowSystemBarProperties({ + // The status bar keeps the page colour it has always had — the window + // does not draw behind it on every device, and an opaque bar cannot + // regress the header band either way. The navigation bar is transparent + // instead: the chat page's bottom fade is what should be visible there. statusBarColor: background, - navigationBarColor: background, + navigationBarColor: MobileDesignColors.transparent.light, statusBarContentColor: content, navigationBarContentColor: content }); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index c12aab39ed..1e73b9aca8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -477,6 +477,11 @@ export const EN_US_MESSAGES: [string, string][] = [ ['chat.addImage', 'Add image'], ['chat.removeImage', 'Remove image'], ['chat.selectModel', 'Select model'], + ['chat.modelPrimary', 'Primary Model'], + ['chat.modelFast', 'Fast Model'], + ['chat.modelFastNotConfigured', 'Not set, falls back to primary'], + ['chat.modelRolePrimary', 'Primary'], + ['chat.modelRoleFast', 'Fast'], ['chat.loadOlder', 'Load older messages'], ['chat.loadOlderFailed', 'Could not load earlier messages. Tap to retry.'], ['chat.inputPlaceholder', 'Ask OpenBitFun'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index acb6df1c56..a08b5bcf57 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -477,6 +477,11 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['chat.addImage', '添加图片'], ['chat.removeImage', '移除图片'], ['chat.selectModel', '选择模型'], + ['chat.modelPrimary', '主力模型'], + ['chat.modelFast', '快速模型'], + ['chat.modelFastNotConfigured', '未配置时使用主力模型'], + ['chat.modelRolePrimary', '主力'], + ['chat.modelRoleFast', '快速'], ['chat.loadOlder', '加载更早消息'], ['chat.loadOlderFailed', '加载更早消息失败,点按重试。'], ['chat.inputPlaceholder', '向 OpenBitFun 提问'], 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 3a22388991..c0436728b0 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 @@ -160,6 +160,9 @@ export struct AppShell { .backgroundColor(PAGE_BG) .borderRadius(MobileDesignGeometry.sheetTopRadius) .clip(true) + // The cover is bound with `enableSafeArea: true`, so the framework already + // ends its page above the navigation bar and this stays the design's own + // float above that edge. .margin({ bottom: MobileDesignGeometry.loginSheetOuterMargin }) } .width('100%') 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 cd49a28bed..95d0c52091 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 @@ -19,6 +19,7 @@ import { import { SidebarToggleButton } from './SidebarToggleButton'; import { SidebarGlyph } from './SidebarGlyphs'; import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionActionPolicy'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; /** Device and workspace navigation shared by compact and wide controllers. */ @ComponentV2 @@ -47,6 +48,14 @@ export struct AppSidebar { @Event onOpenAccount: () => void = () => {}; @Local showSearch: boolean = false; @Local sessionSearchQuery: string = ''; + /** + * The window's own insets, in vp. The panel's fill runs to its bottom edge — + * the screen edge for the compact drawer — and the session rows scroll under + * the navigation indicator, so the strip is the list's own business now. The + * floating footer keeps its distance from that edge itself, which is what + * keeps the buttons above the bar while the rows run under it. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); /** * The authenticated device/workspace section. * @@ -55,6 +64,17 @@ export struct AppSidebar { */ @BuilderParam contentSlot: () => void = this.EmptySlot; + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + build() { this.SidebarContent() } @@ -95,7 +115,7 @@ export struct AppSidebar { } .width('100%') .alignItems(HorizontalAlign.Start) - .padding({ bottom: this.usesCompactFooter() ? 84 : 120 }) + .padding({ bottom: this.scrollTailPadding() }) } .width('100%') .height('100%') @@ -106,6 +126,8 @@ export struct AppSidebar { // Pre-connection actions form a conventional footer and retain a fade. // Once either account or QR access unlocks the workspace, navigation // uses two floating controls and the scroll stays visible behind them. + // The fade stops above the navigation bar with the controls it serves, + // so the rows it does not cover stay readable in the strip below it. if (!this.shouldShowPrimaryNavigation()) { Column() .width('100%') @@ -115,6 +137,7 @@ export struct AppSidebar { angle: 180, colors: [[SIDEBAR_BG_FADE, 0.0], [SIDEBAR_BG, 0.62]] }) + .margin({ bottom: this.footerBottomPadding() }) } if (this.shouldShowPrimaryNavigation()) { @@ -128,7 +151,12 @@ export struct AppSidebar { } .width('100%') .height('100%') - .padding({ left: 20, right: 20, top: 0, bottom: 16 }) + // The panel's fill runs to its own bottom edge — the screen edge for the + // compact drawer, the wide master pane's card edge in the wide host — and + // the session rows are content that scrolls under the navigation indicator. + // The footer, its fade and the list's tail are what keep the strip clear, + // each against the edge this panel actually has. + .padding({ left: 20, right: 20, top: 0 }) .backgroundColor(SIDEBAR_BG) } @@ -256,6 +284,7 @@ export struct AppSidebar { onOpenTools: () => this.requestDeviceTools(), onOpenSettings: () => this.onOpenSettings() }) + .margin({ bottom: this.footerBottomPadding() }) } @Builder @@ -279,6 +308,7 @@ export struct AppSidebar { }) } .width('100%') + .margin({ bottom: this.footerBottomPadding() }) .zIndex(2) } @@ -298,4 +328,25 @@ export struct AppSidebar { private usesCompactFooter(): boolean { return this.shouldShowPrimaryNavigation() || this.connectionState === 'connected'; } + + /** + * The distance the floating footer and its fade keep from the panel's own + * bottom edge: the design's spacing where the strip is already clear of it, + * the strip itself where it is not. In the compact drawer that edge is the + * screen edge, so the buttons sit directly above the navigation bar; in the + * wide master pane the edge is the floating card's, and the same number + * keeps the buttons clear of the bar there too. + */ + private footerBottomPadding(): number { + return this.insets.bottomPadding(16); + } + + /** + * The session list's tail spacer: the floating footer's clearance plus the + * strip. The rows roll under the footer and the navigation indicator while + * they are moving, and the last row still rests above the bar. + */ + private scrollTailPadding(): number { + return (this.usesCompactFooter() ? 84 : 120) + this.insets.bottom; + } } 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 a002702f8e..0ccf4a68f8 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 @@ -83,6 +83,30 @@ export struct ChatMessageBubble { this.AssistantBubble() } + /** + * The Task branch as the transcript currently holds it. + * + * The structured ForEach that draws the card is keyed by the branch view key, + * which deliberately does not change while the subagent publishes steps, so its + * item builder keeps the entry it captured — the empty one a Task has before + * its subagent starts. Param expressions are re-evaluated on every render, so + * the branch is re-read from the observed message here, and a step that arrives + * later reaches the card without recreating it or closing it. + * + * Known limits. A branch nested inside another branch's child items resolves + * only through its captured entry: the lookup scans the top-level transcript, + * and today's wire publishes subagent records flat, so that shape is not + * reachable yet. The lookup also runs once per card param — title, status and + * items — which is bounded by the few branches a turn can draw. + */ + private currentSubagentBranch(entry: ConversationUiMessageItem, path: string): ConversationUiMessageItem { + return ChatMessageStructurePolicy.subagentBranchAt( + this.structuredProcessItems(this.item.items || []), + ChatMessageStructurePolicy.subagentOrdinal(path), + entry.tool?.id || '' + ) || entry; + } + @Builder AssistantBubble() { Row() { @@ -340,10 +364,11 @@ export struct ChatMessageBubble { SubagentGroup(entry: ConversationUiMessageItem, path: string, activeScope: boolean = false) { SubagentTaskCard({ interactionMailbox: this.interactionMailbox, - title: this.subagentTitle(entry), + title: this.subagentTitle(this.currentSubagentBranch(entry, path)), path: path, - status: entry.tool?.status || (activeScope ? 'running' : 'completed'), - items: entry.subItems || [], + status: this.currentSubagentBranch(entry, path).tool?.status || + (activeScope ? 'running' : 'completed'), + items: this.currentSubagentBranch(entry, path).subItems || [], renderRevision: this.renderRevision, onApproveTool: this.onApproveTool, onRejectTool: this.onRejectTool, 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 5135aac2d0..0696cab2da 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 @@ -8,7 +8,10 @@ import { ConversationUiModelCatalog, ConversationUiSelectedImage } from '../state/ConversationUiModels'; -import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; +import { + ConversationModelPresentationPolicy, + ConversationUiModelRoleEntry +} from '../policy/ConversationModelPresentationPolicy'; import { TemplateIcon } from './TemplateIcon'; import { CARD, @@ -46,6 +49,10 @@ const COMPOSER_IMAGE_STRIP_TOP_GAP: number = 6; /** Strip height plus its top gap and the parent Column's 2vp row spacing. */ const COMPOSER_IMAGE_STRIP_BLOCK: number = COMPOSER_IMAGE_CARD_SIZE + COMPOSER_IMAGE_STRIP_TOP_GAP + 2; +/** Gap the model popover keeps between its defaults section and the list. */ +const COMPOSER_MODEL_SELECTOR_SECTION_SPACE: number = 10; +const COMPOSER_MODEL_DEFAULTS_DIVIDER_STROKE: number = 0.5; +const COMPOSER_MODEL_DEFAULTS_DIVIDER_MARGIN_TOP: number = 4; @ComponentV2 export struct ComposerBar { @@ -171,6 +178,8 @@ export struct ComposerBar { this.ModelControl() } Blank() + // The draft keeps the collapsed field's one line to itself, so the + // supplemental slot is mounted in this row alone. if (this.shouldShowSupplementalVoice()) { this.SupplementalVoiceButton() } @@ -265,7 +274,20 @@ export struct ComposerBar { @Builder ModelSelector() { - Column({ space: 10 }) { + Column({ space: COMPOSER_MODEL_SELECTOR_SECTION_SPACE }) { + if (this.roleEntries().length > 0) { + Column({ space: MobileDesignGeometry.composerModelSelectorRowGap }) { + ForEach(this.roleEntries(), (entry: ConversationUiModelRoleEntry) => { + this.ModelRoleRow(entry) + }, (entry: ConversationUiModelRoleEntry) => entry.role) + Divider() + .strokeWidth(COMPOSER_MODEL_DEFAULTS_DIVIDER_STROKE) + .color(LINE) + .width('100%') + .margin({ top: COMPOSER_MODEL_DEFAULTS_DIVIDER_MARGIN_TOP }) + } + .width('100%') + } List({ space: MobileDesignGeometry.composerModelSelectorRowGap }) { ForEach(this.selectorModels(), (model: ConversationUiModel) => { ListItem() { @@ -292,10 +314,10 @@ export struct ComposerBar { } @Builder - ModelRow(model: ConversationUiModel) { + ModelRoleRow(entry: ConversationUiModelRoleEntry) { Row({ space: 10 }) { Stack({ alignContent: Alignment.Center }) { - if (this.isSelectedModel(model)) { + if (this.isSelectedRole(entry.role)) { SymbolGlyph($r('sys.symbol.checkmark_circle')) .fontSize(16) .fontColor([INK]) @@ -305,10 +327,71 @@ export struct ComposerBar { .height(20) Column({ space: 2 }) { - Text(ConversationModelPresentationPolicy.primaryLabel(model, RemoteI18n.t('chat.model'))) + Text(RemoteI18n.t(entry.labelKey)) .fontSize(MobileDesignTypography.bodySmall.size) .fontWeight(FontWeight.Medium) .fontColor(INK) + if (this.roleMetaText(entry).length > 0) { + Text(this.roleMetaText(entry)) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .constraintSize({ minHeight: MobileDesignGeometry.composerModelSelectorRowHeight }) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .backgroundColor(this.isSelectedRole(entry.role) ? SOFT : TRANSPARENT) + .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) + .accessibilityText(RemoteI18n.t(entry.labelKey)) + .onClick(() => { + this.closeModelSelector(true); + this.onSelectModel(entry.role); + }) + } + + @Builder + ModelRoleBadge(role: string) { + Text(RemoteI18n.t(ConversationModelPresentationPolicy.roleBadgeLabelKey(role))) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED) + .padding({ left: 6, right: 6, top: 1, bottom: 1 }) + .backgroundColor(SOFT) + .borderRadius(4) + } + + @Builder + ModelRow(model: ConversationUiModel) { + Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + if (this.isSelectedConcreteModel(model)) { + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(16) + .fontColor([INK]) + } + } + .width(20) + .height(20) + + Column({ space: 2 }) { + Row({ space: 6 }) { + Text(ConversationModelPresentationPolicy.primaryLabel(model, RemoteI18n.t('chat.model'))) + .fontSize(MobileDesignTypography.bodySmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + ForEach(this.modelRoles(model), (role: string) => { + this.ModelRoleBadge(role) + }, (role: string) => role) + } + .width('100%') + Text(ConversationModelPresentationPolicy.secondaryLabel(model, RemoteI18n.t('chat.model'))) .fontSize(MobileDesignTypography.labelSmall.size) .fontColor(MUTED) @@ -319,7 +402,7 @@ export struct ComposerBar { .width('100%') .constraintSize({ minHeight: MobileDesignGeometry.composerModelSelectorRowHeight }) .padding({ left: 10, right: 10, top: 8, bottom: 8 }) - .backgroundColor(this.isSelectedModel(model) ? SOFT : TRANSPARENT) + .backgroundColor(this.isSelectedConcreteModel(model) ? SOFT : TRANSPARENT) .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) .onClick(() => { this.closeModelSelector(true); @@ -371,9 +454,14 @@ export struct ComposerBar { top: this.isComposerExpanded() ? 10 : 0, bottom: this.isComposerExpanded() ? 8 : 0 }) - // Limit the viewport, not the editable text; keep overflow scrollable. - .textOverflow(TextOverflow.Clip) - .maxLines(this.isComposerExpanded() ? 4 : 1, { overflowMode: MaxLinesMode.SCROLL }) + // Limit the viewport, not the editable text. The collapsed field is one + // line tall, so what it cannot show is marked with an ellipsis instead of + // scrolling sideways under the reader's finger; the expanded field keeps + // its scrollable multi-line viewport. + .textOverflow(this.isComposerExpanded() ? TextOverflow.Clip : TextOverflow.Ellipsis) + .maxLines(this.isComposerExpanded() ? 4 : 1, { + overflowMode: this.isComposerExpanded() ? MaxLinesMode.SCROLL : MaxLinesMode.CLIP + }) .defaultFocus(false) .onFocus(() => { this.inputFocused = true; @@ -391,9 +479,6 @@ export struct ComposerBar { this.inputText = value; this.onChatInputChange(value); }) - if (this.shouldShowSupplementalVoice() && !this.isComposerExpanded()) { - this.SupplementalVoiceButton() - } } .layoutWeight(1) .constraintSize({ @@ -685,12 +770,36 @@ export struct ComposerBar { return ConversationModelPresentationPolicy.selectedModel(this.modelCatalog, this.selectedModelId); } - private isSelectedModel(model: ConversationUiModel): boolean { - const selected = this.selectedModel(); - return !!selected && selected.id === model.id; + private roleEntries(): ConversationUiModelRoleEntry[] { + return ConversationModelPresentationPolicy.roleEntries(this.modelCatalog); + } + + private isSelectedRole(role: string): boolean { + return ConversationModelPresentationPolicy.resolvedSelectedRole( + this.modelCatalog, this.selectedModelId) === role; + } + + private isSelectedConcreteModel(model: ConversationUiModel): boolean { + return ConversationModelPresentationPolicy.isConcreteModelSelected(this.selectedModelId, model.id); + } + + private modelRoles(model: ConversationUiModel): string[] { + return ConversationModelPresentationPolicy.modelRoles(this.modelCatalog, model.id); + } + + private roleMetaText(entry: ConversationUiModelRoleEntry): string { + const noticeKey = ConversationModelPresentationPolicy.roleMetaNoticeKey(entry); + if (noticeKey.length > 0) { + return RemoteI18n.t(noticeKey); + } + return ConversationModelPresentationPolicy.roleMetaLabel(entry.model, RemoteI18n.t('chat.model')); } private displaySelectedModel(): string { + const roleKey = ConversationModelPresentationPolicy.selectedModelLabelKey(this.modelCatalog, this.selectedModelId); + if (roleKey.length > 0) { + return RemoteI18n.t(roleKey); + } const selected = this.selectedModel(); return selected ? ConversationModelPresentationPolicy.primaryLabel(selected, RemoteI18n.t('chat.model')) : RemoteI18n.t('chat.model'); @@ -698,11 +807,36 @@ export struct ComposerBar { private modelListHeight(): number { // Five rows fit comfortably above the compact composer while the keyboard - // remains visible. Larger catalogs scroll inside the anchored menu. + // remains visible. The defaults section takes its share of that budget + // first, so the concrete models only get what is left. Larger catalogs + // scroll inside the anchored menu. const maxVisibleRows = this.presentation === ComposerPresentation.Compact ? 5 : 7; - const visibleRows = Math.min(this.enabledModels().length, maxVisibleRows); - return visibleRows * MobileDesignGeometry.composerModelSelectorRowHeight + - Math.max(0, visibleRows - 1) * MobileDesignGeometry.composerModelSelectorRowGap; + const rowHeight = MobileDesignGeometry.composerModelSelectorRowHeight; + const rowGap = MobileDesignGeometry.composerModelSelectorRowGap; + const budgetHeight = maxVisibleRows * rowHeight + Math.max(0, maxVisibleRows - 1) * rowGap; + const availableHeight = Math.max(0, budgetHeight - this.modelDefaultsBlockHeight()); + const fittingRows = Math.floor((availableHeight + rowGap) / (rowHeight + rowGap)); + const visibleRows = Math.max(0, Math.min(this.enabledModels().length, fittingRows)); + const rowsHeight = visibleRows * rowHeight + Math.max(0, visibleRows - 1) * rowGap; + // A budget too small for one full row still leaves the list a scrollable + // viewport instead of collapsing it away. The remaining budget is the cap, + // so the popover can never grow past the footprint it had before. + return Math.max(0, Math.min(availableHeight, Math.max(rowHeight, rowsHeight))); + } + + /** Height the defaults section takes above the concrete list, chrome included. */ + private modelDefaultsBlockHeight(): number { + const roleRows = this.roleEntries().length; + if (roleRows === 0) { + return 0; + } + const rowsHeight = roleRows * MobileDesignGeometry.composerModelSelectorRowHeight + + Math.max(0, roleRows - 1) * MobileDesignGeometry.composerModelSelectorRowGap; + const chrome = MobileDesignGeometry.composerModelSelectorRowGap + + COMPOSER_MODEL_DEFAULTS_DIVIDER_STROKE + + COMPOSER_MODEL_DEFAULTS_DIVIDER_MARGIN_TOP + + COMPOSER_MODEL_SELECTOR_SECTION_SPACE; + return rowsHeight + chrome; } private selectorModels(): ConversationUiModel[] { 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 203bd60fa3..47d07139f0 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 @@ -11,6 +11,7 @@ import { } from '../policy/ConnectSheetLandingPolicy'; import { CARD, CONNECT_HERO_ACCENT, CONNECT_HERO_BG, CONNECT_HERO_SECONDARY, CONNECT_HERO_SURFACE, INK, MUTED, SOFT, STATUS_DANGER, STATUS_SUCCESS, SUBTLE } from './Theme'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; /** * Connect sheet. A signed-in phone picks a desktop from the account device @@ -37,6 +38,23 @@ export struct ConnectView { @Event cloudListDevices: () => Promise = async (): Promise => []; @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = async (_device: CloudAccountDevice): Promise => {}; + /** + * The sheet's own bottom edge, in vp. The connect sheet is full-height and + * its status strip is the fixed control at that edge, so the strip keeps the + * navigation bar clear itself. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } build() { Stack() { @@ -104,7 +122,9 @@ export struct ConnectView { } .width('100%') .height('100%') - .padding({ bottom: 28 }) + // The sheet's fill runs to its bottom edge; the status strip is the fixed + // control there, and it keeps the navigation bar clear itself. + .padding({ bottom: this.insets.bottomPadding(28) }) .backgroundColor(CARD) } 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 0ce830d6cc..7df81a5de3 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 @@ -12,6 +12,7 @@ import { ConversationViewState } from '../state/ConversationViewState'; import { ConversationIntent, ConversationIntents, ConversationIntentType } from '../actions/ConversationIntent'; import { ChatTimeline } from './ChatTimeline'; import { ConversationLoadingState } from './ConversationLoadingState'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { ConversationViewContract, ConversationViewOptions @@ -56,16 +57,38 @@ export struct ConversationView { */ @Local topInset: number = 0; @Local bottomInset: number = 0; + /** + * The window's own insets, in vp. The shell is immersive, so the page draws + * from the top of the window and the composer keeps its distance from the + * bottom edge itself, which is what lets the transcript and its fade run to the + * very bottom of the screen without carrying the composer down with them. The + * header band reserves the top strip for the same reason: the band's own box is + * what the transcript borrows as its content start offset. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; + private get topSafeInset(): number { + return this.insets.top; + } + + private get bottomSafeInset(): number { + return this.insets.bottom; + } + aboutToAppear(): void { RemoteLogger.info(`conversation view mounted surface=${this.surface}`); this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } } aboutToDisappear(): void { RemoteLogger.info('conversation view disposed'); + this.insets.unbind(); this.getUIContext().setKeyboardAvoidMode(this.previousKeyboardAvoidMode); } @@ -136,6 +159,9 @@ export struct ConversationView { this.Composer() } .width('100%') + // The composer pane of a half-folded screen is the bottom edge too, so it + // keeps the same distance from it as the floating layer does. + .padding({ bottom: this.bottomSafeInset }) } .alignContent(Alignment.Bottom) .layoutWeight(1) @@ -156,6 +182,10 @@ export struct ConversationView { this.Mailbox() } .width('100%') + // Carried by the band that owns the background, not by the wrapper: the + // band is what has to reach the status bar, and its measured height is + // what the transcript borrows as its content start offset. + .padding({ top: this.topSafeInset }) .backgroundColor(PAGE_BG_OVERLAY) .backgroundBlurStyle(BlurStyle.Thin) this.TopEdgeFade() @@ -173,6 +203,11 @@ export struct ConversationView { this.Composer() } .width('100%') + // The composer keeps the distance from the screen edge it had while the page + // area ended above the navigation bar, and the layer's own box grows by that + // strip: the gradient below is the layer's background, so it fills the strip + // and the transcript behind it reaches the bottom of the screen. + .padding({ bottom: this.bottomSafeInset }) // The fade covers the whole layer, not just a strip above it: the transcript // runs behind the composer, so anything short of that leaves a line of text // sitting crisp and legible beside the pill after it has already faded out @@ -182,6 +217,16 @@ export struct ConversationView { angle: 180, colors: [[PAGE_BG_FADE, 0.0], [PAGE_BG_OVERLAY, 0.45], [PAGE_BG_OVERLAY, 1.0]] }) + // This layer owns the bottom edge of the screen, so it is allowed to paint + // into it. Where the immersive layout already puts the page area there the + // two overlap and nothing extra is drawn; on a window that still ends above + // the navigation bar this is what keeps the strip from being bare page + // colour under the fade. + .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) + // Named so a layout dump can be read against the pixels: the fade's own box + // is what says where the chat page's bottom edge sits and how far the + // transcript can scroll, and neither is visible in the composer's bounds. + .id('conversation-bottom-fade') .onAreaChange((_oldArea: Area, newArea: Area) => { this.bottomInset = newArea.height as number; }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets index aa60b040cd..0cc7be016f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets @@ -2,6 +2,7 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; import { RemoteLogger } from '../../services/RemoteLogger'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; import { CARD, @@ -105,9 +106,21 @@ export struct ConversationViewSettings { @Event onUpdatedMetadataChange: (value: boolean) => void = (_value: boolean) => {}; @Event onStatusMetadataChange: (value: boolean) => void = (_value: boolean) => {}; @Event onClose: () => void = () => {}; + // In its compact placement this surface is a bottom sheet under the immersive + // shell: the scrolling column runs to the screen edge and ends in a tail that + // rests the last control above the navigation bar. + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); aboutToAppear(): void { this.syncSelectionState(); + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); } build() { @@ -176,7 +189,7 @@ export struct ConversationViewSettings { this.onStatusMetadataChange) } .width('100%') - .padding({ left: 20, right: 20, bottom: 24 }) + .padding({ left: 20, right: 20, bottom: this.insets.tailSpacing(24) }) } .width('100%') .layoutWeight(1) 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 index 587b671071..3c4c7cd554 100644 --- 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 @@ -33,6 +33,7 @@ import { SOFT, TRANSPARENT, } from './Theme'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; @ComponentV2 export struct FilePreviewSurface { @@ -40,6 +41,12 @@ export struct FilePreviewSurface { private readonly markdownScroller: Scroller = new Scroller(); private readonly syntaxHighlightCache: CodeSyntaxHighlightCache = new CodeSyntaxHighlightCache(); private restoreScrollTimerId: number = 0; + /** + * The surface's own bottom edge, in vp. The preview scrollers run to the + * screen edge — content rolls under the navigation indicator while it is + * moving — and their tail padding is what rests the last line above the bar. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); @Local imageOriginalSizeTarget: string = ''; @Param state: FilePreviewState = new FilePreviewState(); @Param remoteAvailable: boolean = true; @@ -54,7 +61,15 @@ export struct FilePreviewSurface { @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; @Event onToggleExpanded: () => void = () => {}; + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + aboutToDisappear(): void { + this.insets.unbind(); this.clearRestoreScrollTimer(); } @@ -340,7 +355,7 @@ export struct FilePreviewSurface { .fontColor(INK) .fontFamily('monospace') .textSelectable(TextSelectableMode.SELECTABLE_UNFOCUSABLE) - .padding({ left: 12, right: 12, top: 14, bottom: 24 }) + .padding({ left: 12, right: 12, top: 14, bottom: this.insets.tailSpacing(24) }) .constraintSize({ minWidth: '100%' }) } .scrollable(ScrollDirection.FREE) @@ -371,7 +386,7 @@ export struct FilePreviewSurface { } }) .width('100%') - .padding({ left: 16, right: 16, top: 16, bottom: 28 }) + .padding({ left: 16, right: 16, top: 16, bottom: this.insets.tailSpacing(28) }) } .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Auto) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets index 4603b3bf1e..d1638d5b7f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets @@ -5,12 +5,25 @@ import { MiniAppState } from '../state/MiniAppState'; import { MiniAppWebView } from './MiniAppWebView'; import { NavigationBackButton } from './NavigationBackButton'; import { PAGE_BG, INK, MUTED, SOFT, LINE, TRANSPARENT } from './Theme'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; @ComponentV2 export struct MiniAppSurface { @Param state: MiniAppState = new MiniAppState(); @Param actions: MiniAppActions = emptyMiniAppActions(); @Local galleryWidth: number = 0; + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } build() { Column() { @@ -53,11 +66,28 @@ export struct MiniAppSurface { ForEach(this.state.apps, (app: MiniAppSummary) => { GridItem() { this.PreviewTile(app) } }, (app: MiniAppSummary) => app.id) - }.columnsTemplate(this.galleryWidth >= 600 ? '1fr 1fr 1fr' : '1fr 1fr') + // The gallery's tail spacer: the strip plus the design's own + // breathing, as one full-width, untouchable row. The grid's + // viewport runs to the screen edge — tiles roll under the + // navigation indicator while they are moving — and the last row + // of tiles still rests above the bar. + GridItem() { + Column() + .width('100%') + .height(this.insets.tailSpacing(24)) + } + .columnStart(0) + .columnEnd(this.galleryColumns() - 1) + .hitTestBehavior(HitTestMode.None) + }.columnsTemplate(this.galleryColumnsTemplate()) .columnsGap(14).rowsGap(20).layoutWeight(1).width('100%').scrollBar(BarState.Off) } }.width('100%').constraintSize({ maxWidth: 1000 }).layoutWeight(1) - .alignItems(HorizontalAlign.Start).padding({ left: 16, right: 16, bottom: 24 }) + // The gallery is the surface's bottom edge: its scrolling viewport + // runs to the screen edge, and the grid's own tail spacer is what + // keeps the last row of tiles out of the navigation bar. + .alignItems(HorizontalAlign.Start) + .padding({ left: 16, right: 16 }) .onAreaChange((_oldArea: Area, area: Area) => { this.galleryWidth = Number(area.width); }) } }.width('100%').height('100%').backgroundColor(PAGE_BG) @@ -84,4 +114,13 @@ export struct MiniAppSurface { if (id === 'builtin-regex-playground') return $r('app.media.miniapp_regex_preview'); return $r('app.media.miniapp_divination_preview'); } + + /** Columns of the gallery grid: 3 once it is wide, 2 on a phone. */ + private galleryColumns(): number { + return this.galleryWidth >= 600 ? 3 : 2; + } + + private galleryColumnsTemplate(): string { + return this.galleryColumns() === 3 ? '1fr 1fr 1fr' : '1fr 1fr'; + } } 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 8dc6deba75..944d9a0c97 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 @@ -22,6 +22,7 @@ import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } fr import { SessionDetailsView } from './SessionDetailsView'; import { RemoteSessionRow } from './RemoteSessionRow'; import { HarnessProfileMenu } from './HarnessProfileMenu'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { SessionListInputs, SessionListProjection, @@ -81,6 +82,10 @@ export struct RemoteSessionList { @Local detailsSessionId: string = ''; @Local showSessionDetails: boolean = false; @Local optimisticSelectedSessionId: string = ''; + // The list owns two window-level sheets whose content ends at the screen + // edge, so it reads the strip itself: the action sheet's rows are fixed + // controls that keep the bar clear, and the details sheet scrolls under it. + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); // Selecting a row reruns this whole builder tree, and the tree asks for the // grouped lists far more often than it changes them. The cache answers from // the last projection unless the sessions, the workspaces or a filter moved — @@ -121,8 +126,25 @@ export struct RemoteSessionList { this.optimisticSelectedSessionId = ''; } + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + build() { Column() { + // A node carries at most one bindSheet: chaining the action sheet and + // the details sheet on one node left the first sheet unopenable, so the + // action sheet binds on its own zero-height carrier row — the same + // pattern the sidebar's section uses for its own pair of sheets. + Row() {}.height(0) + .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) Scroll() { Column() { if (this.filteredSessions().length === 0) { @@ -161,7 +183,6 @@ export struct RemoteSessionList { .width('100%') .layoutWeight(1) .alignItems(HorizontalAlign.Start) - .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) .bindSheet($$this.showSessionDetails, this.SessionDetailsSheet(), this.sessionDetailsSheetOptions()) } @@ -632,6 +653,10 @@ export struct RemoteSessionList { SessionActionSurface({ presentation, sessionTitle: this.actionSessionTitle(), + // Only the bottom-sheet presentation sits at the screen edge; a popover + // floats at its anchor and keeps the plain design spacing. + bottomPadding: presentation === SessionActionPresentation.BottomSheet ? + this.insets.bottomPadding(18) : 18, canViewDetails: this.actionCapabilities().canViewDetails, canDelete: this.actionCapabilities().canDelete, onViewDetails: () => this.openActionSessionDetails(), 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 index 0fb7a5b3a9..4ba5f56c58 100644 --- 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 @@ -12,6 +12,12 @@ export enum SessionActionPresentation { export struct SessionActionSurface { @Param presentation: SessionActionPresentation = SessionActionPresentation.BottomSheet; @Param sessionTitle: string = ''; + /** + * Bottom padding in vp. The bottom-sheet presentation sits at the screen + * edge under the immersive shell, so its host passes the strip-aware value + * (`insets.bottomPadding(18)`); a popover keeps the plain design spacing. + */ + @Param bottomPadding: number = 18; @Param archived: boolean = false; @Param canViewDetails: boolean = false; @Param canArchive: boolean = false; @@ -102,7 +108,9 @@ export struct SessionActionSurface { } } .width(this.presentation === SessionActionPresentation.Popover ? 300 : '100%') - .padding({ left: 16, right: 16, top: 10, bottom: 18 }) + // The card's fill still reaches the sheet's bottom edge; the padding is + // what keeps the last action row out of the navigation bar's strip. + .padding({ left: 16, right: 16, top: 10, bottom: this.bottomPadding }) .backgroundColor(CARD) .border({ width: this.presentation === SessionActionPresentation.Popover ? 0.5 : 1, color: LINE }) .borderRadius(16) 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 index daba9eb99b..8710f1b173 100644 --- 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 @@ -2,6 +2,7 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteSession } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { TimeFormat } from '../../services/TimeFormat'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; @ComponentV2 @@ -16,6 +17,21 @@ export struct SessionDetailsView { messageCount: 0 }; @Event onClose: () => void = () => {}; + // The sheet is a bottom sheet under the immersive shell, so the scrolling + // column reads the strip itself and ends in a tail that rests the last row + // above the navigation bar. + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } build() { Column() { @@ -74,7 +90,7 @@ export struct SessionDetailsView { } } .width('100%') - .padding({ left: 20, right: 20, top: 8, bottom: 24 }) + .padding({ left: 20, right: 20, top: 8, bottom: this.insets.tailSpacing(24) }) } .width('100%') .layoutWeight(1) 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 1bfa58c68c..d90d6044b8 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 @@ -8,6 +8,7 @@ import { LanguageSettingsPanel } from './LanguageSettingsPanel'; import { SheetCloseHeader } from './SheetCloseHeader'; import { SHEET_HORIZONTAL_PADDING, SHEET_TOP_RADIUS } from './SheetLayout'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { RemotePermissionMode } from '../../model/RemoteModels'; import { RemotePermissionSection } from './RemotePermissionSection'; @@ -39,6 +40,20 @@ export struct SettingsSheet { @Event setPermissionMode: (mode: RemotePermissionMode) => Promise = async (mode: RemotePermissionMode): Promise => mode; @Event onSetLanguage: (language: string) => Promise = async (_language: string) => {}; + /** The sheet's own bottom edge, so its scrolling rows can run under the bar and still rest above it. */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + build() { Stack({ alignContent: Alignment.TopEnd }) { if (this.sheetState.page === 'language') { @@ -49,6 +64,14 @@ export struct SettingsSheet { } .width('100%') .height('100%') + // The sheet's fill is the shell's bottom edge and runs to the screen edge, + // and so does its scrolling viewport: the rows are content, and they roll + // under the navigation indicator while they are still moving instead of + // stopping short of it above a band of bare page colour. What stops above + // the bar is the column's own tail padding, which brings the last row to + // rest there once the sheet is scrolled to its end. The framework does not + // inset a bottom sheet for the app while the window is immersive, so the + // sheet does it itself. .backgroundColor(PAGE_BG) .borderRadius({ topLeft: SHEET_TOP_RADIUS, topRight: SHEET_TOP_RADIUS }) } @@ -96,7 +119,9 @@ export struct SettingsSheet { left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING, top: 22, - bottom: 34 + // The sheet's tail spacer: the strip plus its own breathing, so the + // last row rests above the navigation bar once the scroll ends. + bottom: this.insets.tailSpacing(34) }) } .width('100%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets index bf58de71c5..06f3aff632 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets @@ -4,6 +4,7 @@ import { SavedRuntimeConnection, RecentWorkspaceEntry, RemoteWorkspaceIdentity } import { remoteWorkspaceIdentityMatches, remoteWorkspaceKey } from '../../services/RemoteSessionIdentity'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, CONTENT_ON_ACTION, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, SOFT, TRANSPARENT } from './Theme'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; @ComponentV2 export struct SidebarWorkspacePicker { @@ -23,6 +24,23 @@ export struct SidebarWorkspacePicker { @Param selectedWorkspaceSshHost: string = ''; @Param loading: boolean = false; @Event onClose: () => void = () => {}; + /** + * The sheet's own bottom edge, in vp. The picker is a full-height sheet and + * its confirm button is the fixed control at that edge, so the button keeps + * the navigation bar clear itself. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } /** * Catalog rows report their stable `workspaceId`; the manual path entry has * none, which is the only case where the host may resolve by path. @@ -195,7 +213,9 @@ export struct SidebarWorkspacePicker { .backgroundColor(PRIMARY_ACTION).height(48).width('100%').borderRadius(14) .enabled(this.canOpenTargetPath()) .onClick(() => this.onSelectWorkspace(this.targetPath.trim(), this.connectionId.length > 0 ? this.connectionId : undefined)) - }.width('100%').padding({ top: 12, bottom: 16 }).backgroundColor(PAGE_BG) + // The sheet's fixed confirm button: the strip, or the design's own + // spacing where the strip is already clear, so it never sits in it. + }.width('100%').padding({ top: 12, bottom: this.insets.bottomPadding(16) }).backgroundColor(PAGE_BG) } .width('100%') .height('100%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets index 22b699bb51..3b46f8e57a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets @@ -22,6 +22,13 @@ export struct SubagentTaskCard { @Param title: string = ''; @Param path: string = ''; @Param status: string = 'completed'; + /** + * Process items of this branch, as re-read by the bubble on every render. + * + * A snapshot array would freeze the first empty branch: the ForEach that draws + * this card is keyed by the branch view key, which deliberately does not change + * while the subagent publishes steps, so its item builder never re-runs. + */ @Param items: ConversationUiMessageItem[] = []; @Param renderRevision: number = 0; @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets index 7e638a136b..61fa36977f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets @@ -1,11 +1,19 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { MobileDesignTypography, MobileDesignGeometry as G } from '../../generated/MobileDesignTokens'; import { MotionPreference } from '../../services/MotionPreference'; -import { WindowSystemBarService } from '../../services/WindowSystemBarService'; +import { WindowInsetsBinding, WindowSystemBarService } from '../../services/WindowSystemBarService'; import { WelcomeBrandFlow } from './WelcomeBrandFlow'; import { INK, PAGE_BG, WELCOME_PAGE_BG, MUTED, TRANSPARENT } from './Theme'; -/** A welcome composition with the C staggered-slide phrase timeline. */ +/** + * A welcome composition with the C staggered-slide phrase timeline. + * + * The window layout mode is not this page's business any more: it used to switch + * the whole shell in and out of full-screen as the compact welcome page came and + * went, which left every other page — the chat page above all — laid out without + * it. The shell runs immersive for every page now, and this page only reserves + * the status bar for its own header. + */ @ComponentV2 export struct WelcomeHome { @Param signedIn: boolean = false; @@ -15,21 +23,37 @@ export struct WelcomeHome { @Local pageHeight: number = 700; @Local pageWidth: number = 390; @Local topInset: number = 0; + // The dock paints into the strip (expandSafeArea carries its background to + // the screen edge), so its buttons read the strip: the design's own clearance + // where it is already taller than the indicator, the indicator's height + // where it is not. + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); private timer: number = -1; private started: number = 0; - private immersiveCompact: boolean = false; + + aboutToAppear(): void { + this.readTopInset(); + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } aboutToDisappear(): void { this.stop(); - if (this.immersiveCompact) { - const context = this.getUIContext().getHostContext(); - if (context) { - void WindowSystemBarService.setLayoutFullScreen(context, false); - } - this.immersiveCompact = false; - this.topInset = 0; + this.insets.unbind(); + } + + private readTopInset(): void { + const context = this.getUIContext().getHostContext(); + if (!context) { + return; } + void WindowSystemBarService.topSystemInsetPx(context).then((topInsetPx: number): void => { + this.topInset = this.getUIContext().px2vp(topInsetPx); + }); } + private stop(): void { if (this.timer >= 0) clearInterval(this.timer); this.timer = -1; } private play(): void { this.stop(); @@ -108,36 +132,10 @@ export struct WelcomeHome { .onSizeChange((_old: SizeOptions, size: SizeOptions) => { this.pageHeight = Number(size.height); this.pageWidth = Number(size.width); - this.updateWindowLayout(); }) .onVisibleAreaChange([0], (visible: boolean) => { if (visible) this.play(); else this.stop(); }) } - private updateWindowLayout(): void { - const compact = this.pageWidth < 600; - if (compact === this.immersiveCompact) { - return; - } - const context = this.getUIContext().getHostContext(); - if (!context) { - return; - } - this.immersiveCompact = compact; - if (!compact) { - this.topInset = 0; - void WindowSystemBarService.setLayoutFullScreen(context, false); - return; - } - void WindowSystemBarService.setLayoutFullScreen(context, true).then(async (): Promise => { - if (this.immersiveCompact) { - const topInsetPx: number = await WindowSystemBarService.topSystemInsetPx(context); - if (this.immersiveCompact) { - this.topInset = this.getUIContext().px2vp(topInsetPx); - } - } - }); - } - @Builder private Hero() { Column({ space: this.wide() ? 16 : 20 }) { @@ -167,7 +165,7 @@ export struct WelcomeHome { }.height(44).width('100%').backgroundColor(TRANSPARENT).onClick(this.onMiniApps) }.width('100%').constraintSize({ maxWidth: this.wide() ? 400 : G.welcomeMaxWidth }) .padding({ left: G.welcomeGutter, right: G.welcomeGutter, top: this.wide() ? 0 : G.welcomeGutter, - bottom: this.wide() ? 0 : G.welcomeDockBottom }) + bottom: this.wide() ? 0 : this.insets.bottomPadding(G.welcomeDockBottom) }) .backgroundColor(this.wide() ? TRANSPARENT : $r('app.color.welcome_dock')) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) .borderRadius({ topLeft: G.welcomeDockRadius, topRight: G.welcomeDockRadius }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets index f3eeb64470..f3b76098ec 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets @@ -6,16 +6,36 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, CONTENT_ON_ACTION, INK, MEDIA_BACKGROUND, MUTED, PAGE_BG, LINE, SOFT, TRANSPARENT } from './Theme'; import { SheetCloseButton } from './SheetCloseButton'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; @ComponentV2 export struct WorkspaceToolsPanel { @Param state: WorkspaceToolsState = new WorkspaceToolsState(); @Event onIntent: (intent: WorkspaceToolsIntent) => void = (_intent: WorkspaceToolsIntent) => {}; private filesScroller: Scroller = new Scroller(); + /** + * The sheet's own bottom edge, in vp. The panel is a full-height sheet, so + * its scrolling viewport runs to the screen edge and its rows roll under the + * navigation indicator; the tail spacer and the fixed bottom controls are + * what keep the bar clear. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); @Monitor('state.directory', 'state.connectionId', 'state.sort') private onFileLocationChanged(): void { this.filesScroller.scrollEdge(Edge.Top); } + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + build() { Column({ space: 0 }) { Row({ space: 12 }) { @@ -44,7 +64,7 @@ export struct WorkspaceToolsPanel { TabContent() { this.Files() }.tabBar(new SubTabBarStyle(RemoteI18n.t('workspaceTools.files')).labelStyle({ selectedColor: INK, unselectedColor: MUTED }).indicator({ color: INK })) TabContent() { this.Terminal() }.tabBar(new SubTabBarStyle(RemoteI18n.t('workspaceTools.terminal')).labelStyle({ selectedColor: INK, unselectedColor: MUTED }).indicator({ color: INK })) }.barHeight(50).onChange((index: number) => this.onIntent({ type: 'panel', tab: index })).layoutWeight(1) - }.padding({ left: 16, right: 16, bottom: 16 }).width('100%').height('100%').backgroundColor(PAGE_BG) + }.padding({ left: 16, right: 16 }).width('100%').height('100%').backgroundColor(PAGE_BG) } @Builder private Files() { @@ -87,7 +107,7 @@ export struct WorkspaceToolsPanel { } if (this.state.error) { Text(this.state.error).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size) } if (this.state.busy) { LoadingProgress().width(24).height(24).color(MUTED) } - }.padding({ left: 20, right: 20, top: 20, bottom: 24 }).width('100%').backgroundColor(CARD) + }.padding({ left: 20, right: 20, top: 20, bottom: this.insets.bottomPadding(24) }).width('100%').backgroundColor(CARD) } @Builder private FileDirectory() { @@ -156,14 +176,25 @@ export struct WorkspaceToolsPanel { } }, (entry: WorkspaceFileEntry) => entry.path) if (this.state.hasMore) { ListItem() { Button(RemoteI18n.t('workspaceTools.more')).fontColor(INK).backgroundColor(TRANSPARENT).height(44).onClick(() => this.onIntent({ type: 'more' })) } } + // The list's tail spacer: the strip plus the panel's own breathing, so + // the viewport can run to the screen edge — rows roll under the + // navigation indicator while they are moving — while the last row + // still rests above the bar. + ListItem() { + Column().width('100%').height(this.insets.tailSpacing(16)) + } }.divider({ strokeWidth: 1, color: LINE }).border({ width: 1, color: LINE }).borderRadius(8).layoutWeight(1).enabled(!this.state.busy) - if (this.state.downloadStatus) { Text(this.state.downloadStatus).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size) } - if (this.state.uploadPending) { - Progress({ value: this.state.uploadProgress, total: 100, type: ProgressType.Linear }) - Row({ space: 8 }) { - Button(RemoteI18n.t('workspaceTools.resumeUpload')).fontColor(INK).backgroundColor(PAGE_BG).onClick(() => this.onIntent({ type: 'upload-resume' })) - Button(RemoteI18n.t('workspaceTools.cancelUpload')).fontColor(INK).backgroundColor(PAGE_BG).onClick(() => this.onIntent({ type: 'upload-cancel' })) - }.enabled(!this.state.busy) + if (this.state.downloadStatus || this.state.uploadPending) { + Column({ space: 8 }) { + if (this.state.downloadStatus) { Text(this.state.downloadStatus).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size) } + if (this.state.uploadPending) { + Progress({ value: this.state.uploadProgress, total: 100, type: ProgressType.Linear }) + Row({ space: 8 }) { + Button(RemoteI18n.t('workspaceTools.resumeUpload')).fontColor(INK).backgroundColor(PAGE_BG).onClick(() => this.onIntent({ type: 'upload-resume' })) + Button(RemoteI18n.t('workspaceTools.cancelUpload')).fontColor(INK).backgroundColor(PAGE_BG).onClick(() => this.onIntent({ type: 'upload-cancel' })) + }.enabled(!this.state.busy) + } + }.width('100%').margin({ bottom: this.insets.bottomPadding(16) }) } }.padding({ top: 12 }).width('100%').height('100%') .bindSheet(this.state.fileAction.length > 0, this.FileActionForm(), { height: SheetSize.FIT_CONTENT, showClose: false, backgroundColor: PAGE_BG, keyboardAvoidMode: SheetKeyboardAvoidMode.TRANSLATE_AND_RESIZE, @@ -209,9 +240,15 @@ export struct WorkspaceToolsPanel { .onClick(() => this.onIntent({ type: 'editor-edit' })) } }.width('100%').justifyContent(FlexAlign.End) + // The editor is a WebView: its scrolling and its padding live inside the + // web renderer, so the panel cannot end it in an ArkUI tail spacer the + // way the file list does. The card is therefore fixed content, like the + // terminal key row: its bottom edge stops at the navigation bar's top, + // while the sheet's own fill still reaches the screen edge below it. WorkspaceFileEditorView({ content: this.state.content, editing: this.state.editing, lineNumbers: this.state.lineNumbers, onIntent: this.onIntent }).layoutWeight(1) .border({ width: 1, color: LINE }).borderRadius(8).backgroundColor(CARD) + .margin({ bottom: this.insets.bottomPadding(16) }) }.padding({ top: 12 }).width('100%').height('100%') } private terminalOptions(): SelectOption[] { @@ -263,6 +300,9 @@ export struct WorkspaceToolsPanel { .accessibilityText(RemoteI18n.t('workspaceTools.closeTerminal')) .onClick(() => this.onIntent({ type: 'terminal-close' })) }.width('100%').enabled(this.state.terminalId.length > 0) + // The key row is the tab's fixed bottom control; it keeps the strip + // clear so the terminal above it ends above the navigation bar. + .margin({ bottom: this.insets.bottomPadding(16) }) } }.padding({ top: 8 }).width('100%').height('100%') } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets index 4e8d52bbe2..cdc4e02c74 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets @@ -129,22 +129,13 @@ export class ChatMessageStructurePolicy { // Remote snapshots also mark tools executed inside a subagent. Those // are Task details, not sibling subagent cards in the parent timeline. if (ChatMessageStructurePolicy.isSubagentInternalTool(entry)) return; - if (ChatMessageStructurePolicy.shouldFoldIntoActivityGroup(entry)) { - activityItems.push(entry); - activityStatuses.push(ChatMessageStructurePolicy.itemStatus(entry, index, activeItemIndex, - activeScope, messageStatus)); - activityStreaming.push(ChatMessageStructurePolicy.itemShouldStream(entry, index, activeItemIndex, activeScope)); - activityChildScopes.push(ChatMessageStructurePolicy.itemChildActiveScope(entry, index, - activeItemIndex, activeScope)); - activityHasThinking = activityHasThinking || ChatMessageStructurePolicy.isThinkingEntry(entry); - return; - } - - flushActivity(); - // Task tool entries are subagent branches, not ordinary tool rows. Route - // them as structured items before the generic tool buffer so they keep - // their branch title, children, and lifecycle presentation. + // Task tool entries are subagent branches, not ordinary tool rows, and a + // subagent branch is never parent activity. Both checks run before the + // activity fold so a child's reasoning is never summarised as the + // parent's own, and so the branch keeps its title, children, and + // lifecycle presentation. if (ChatMessageStructurePolicy.isSubagentEntry(entry)) { + flushActivity(); flushTools(); const subagentPath = nextPath('subagent'); groups.push({ @@ -159,6 +150,18 @@ export class ChatMessageStructurePolicy { }); return; } + if (ChatMessageStructurePolicy.shouldFoldIntoActivityGroup(entry)) { + activityItems.push(entry); + activityStatuses.push(ChatMessageStructurePolicy.itemStatus(entry, index, activeItemIndex, + activeScope, messageStatus)); + activityStreaming.push(ChatMessageStructurePolicy.itemShouldStream(entry, index, activeItemIndex, activeScope)); + activityChildScopes.push(ChatMessageStructurePolicy.itemChildActiveScope(entry, index, + activeItemIndex, activeScope)); + activityHasThinking = activityHasThinking || ChatMessageStructurePolicy.isThinkingEntry(entry); + return; + } + + flushActivity(); if (entry.tool) { toolBuffer.push(entry.tool); return; @@ -193,6 +196,11 @@ export class ChatMessageStructurePolicy { * A Task's children can arrive nested in its own items and again flat behind * it, so folding the restatement would draw every child twice. A counted * restatement is skipped, and a child that legitimately repeats survives. + * + * A marked record with no owning Task in this list — a partial snapshot, or a + * child that arrived before its Task — keeps its own collapsed branch rather + * than being dropped, because the process it carries is exactly what the + * reader opened the branch to see. */ static scopeSubagentItems(items: ConversationUiMessageItem[]): ConversationUiMessageItem[] { const result: ConversationUiMessageItem[] = []; @@ -211,6 +219,8 @@ export class ChatMessageStructurePolicy { if (entry.is_subagent === true) { if (currentTask) { ChatMessageStructurePolicy.appendScopedChild(currentTask, carried, entry); + } else { + result.push(ChatMessageStructurePolicy.orphanSubagentBranch(entry)); } return; } @@ -225,6 +235,34 @@ export class ChatMessageStructurePolicy { return result; } + /** + * A marked record whose Task is not in this list still owns process the reader + * must be able to open, so it becomes its own collapsed branch. The outer copy + * keeps the marker and the tool identity that title and status come from; the + * inner copy drops the marker so the same record renders as branch content + * instead of nesting a second branch inside the first. + * + * A non-tool record's text is that content: leaving it on the outer copy would + * print it twice, once as the branch label and once as the step it labels, so + * the label falls back to the generic Task name and the body comes only from + * the child. A tool record keeps its text, because its label already resolves + * through the tool identity and its child draws a tool row rather than text. + */ + private static orphanSubagentBranch(entry: ConversationUiMessageItem): ConversationUiMessageItem { + const branch = ChatMessageStructurePolicy.copyItem(entry); + if (branch.subItems && branch.subItems.length > 0) { + return branch; + } + const child = ChatMessageStructurePolicy.copyItem(entry); + child.is_subagent = false; + child.subItems = []; + if (!entry.tool) { + branch.content = ''; + } + branch.subItems = [child]; + return branch; + } + /** Only original nested occurrences are matched; new flat entries consume no quota. */ private static appendScopedChild( task: ConversationUiMessageItem, carried: number[], entry: ConversationUiMessageItem @@ -425,9 +463,17 @@ export class ChatMessageStructurePolicy { (!!entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task'); } + /** + * The Task tool entry that owns a subagent branch. + * + * A Task entry may carry the subagent marker as well: the host records the + * child Session id on the Task tool item, and the remote wire maps that field + * onto `is_subagent`. The marker therefore means "this Task owns a subagent", + * not "this entry is subagent output", so it must not disqualify the owner — + * otherwise no Task ever opens a branch and every flat child record is lost. + */ private static isParentTaskEntry(entry: ConversationUiMessageItem): boolean { - return entry.is_subagent !== true && !!entry.tool && - ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task'; + return !!entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task'; } private static isRunningTaskEntry(entry: ConversationUiMessageItem): boolean { @@ -437,6 +483,43 @@ export class ChatMessageStructurePolicy { status === 'pending' || status === 'queued'; } + /** + * The branch entry a Task card is drawing, read from the transcript as it is + * now. + * + * The structured ForEach is keyed by the branch view key, which deliberately + * does not change while a subagent publishes steps, so its item builder keeps + * the entry it first captured — including the empty child list of a Task whose + * subagent had not started yet. Param expressions are re-evaluated on every + * render, so the card re-reads its branch through here instead of trusting that + * snapshot, and a step published later reaches a card that is already open. + * + * Branches are identified by the Task's tool call when the host sent one, and + * by the ordinal the render path numbered them with otherwise. + */ + static subagentBranchAt( + items: ConversationUiMessageItem[], + ordinal: number, + ownerToolId: string + ): ConversationUiMessageItem | undefined { + const branches = ChatMessageStructurePolicy.scopeSubagentItems(items) + .filter((entry: ConversationUiMessageItem) => ChatMessageStructurePolicy.isSubagentEntry(entry)); + if (ownerToolId.length > 0) { + const owned = branches.find((entry: ConversationUiMessageItem) => + !!entry.tool && entry.tool.id === ownerToolId); + if (owned) return owned; + } + return ordinal >= 0 && ordinal < branches.length ? branches[ordinal] : undefined; + } + + /** The ordinal a branch path was numbered with, e.g. `item-subagent-2` is 2. */ + static subagentOrdinal(path: string): number { + const marker = path.lastIndexOf('-subagent-'); + if (marker < 0) return 0; + const ordinal = Number(path.slice(marker + '-subagent-'.length)); + return Number.isFinite(ordinal) && ordinal >= 0 ? ordinal : 0; + } + private static isLegacySubagentProcessEntry(entry: ConversationUiMessageItem): boolean { return ChatMessageStructurePolicy.isThinkingEntry(entry) || ChatMessageStructurePolicy.isTextEntry(entry) || !!entry.tool; @@ -456,6 +539,9 @@ export class ChatMessageStructurePolicy { } private static isSubagentInternalTool(entry: ConversationUiMessageItem): boolean { + // A marked record that already carries children is a branch of its own, not + // an internal tool of some other branch. + if (!!entry.subItems && entry.subItems.length > 0) return false; return entry.is_subagent === true && !!entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) !== 'task'; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets index aac9661b16..34d62ebf67 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets @@ -3,11 +3,170 @@ import { ConversationUiModelCatalog } from '../state/ConversationUiModels'; +/** One role row of the composer selector's defaults section. */ +export interface ConversationUiModelRoleEntry { + /** Semantic model id sent to the host: `primary` or `fast`. */ + role: string; + labelKey: string; + /** Concrete model the role runs, or undefined when it has no target. */ + model: ConversationUiModel | undefined; + /** True when the role runs the primary model because it has none of its own. */ + fallsBackToPrimary: boolean; +} + export class ConversationModelPresentationPolicy { + /** Semantic selectors the host accepts in place of a concrete model id. */ + static readonly ROLE_PRIMARY: string = 'primary'; + static readonly ROLE_FAST: string = 'fast'; + static enabledModels(catalog: ConversationUiModelCatalog): ConversationUiModel[] { return catalog.models.filter((model: ConversationUiModel) => model.enabled); } + /** + * Resolves a selector to an enabled concrete model. + * + * `primary` resolves the configured primary model; `fast` uses its own model + * and falls back to the primary model when none is configured, exactly like + * the host's own model selection. Empty, `auto`, `default` and unknown + * selectors resolve to nothing so callers keep their existing fallback chain. + */ + static resolveModel( + catalog: ConversationUiModelCatalog, + modelId: string + ): ConversationUiModel | undefined { + const value = (modelId || '').trim(); + if (value.length === 0) { + return undefined; + } + if (value === ConversationModelPresentationPolicy.ROLE_PRIMARY) { + return ConversationModelPresentationPolicy.enabledModel(catalog, catalog.default_models.primary || ''); + } + if (value === ConversationModelPresentationPolicy.ROLE_FAST) { + return ConversationModelPresentationPolicy.enabledModel(catalog, catalog.default_models.fast || '') || + ConversationModelPresentationPolicy.enabledModel(catalog, catalog.default_models.primary || ''); + } + return ConversationModelPresentationPolicy.enabledModel(catalog, value); + } + + static isSemanticModelId(modelId: string): boolean { + const value = (modelId || '').trim(); + return value === ConversationModelPresentationPolicy.ROLE_PRIMARY || + value === ConversationModelPresentationPolicy.ROLE_FAST; + } + + /** The selected role, or an empty string when the selection is not a role. */ + static selectedRole(selectedModelId: string): string { + const value = (selectedModelId || '').trim(); + return ConversationModelPresentationPolicy.isSemanticModelId(value) ? value : ''; + } + + /** + * The selected role only while it still resolves to an enabled concrete + * model. A role the catalog can no longer run is presented as no selection at + * all, so the trigger and the role rows agree. + */ + static resolvedSelectedRole(catalog: ConversationUiModelCatalog, selectedModelId: string): string { + const role = ConversationModelPresentationPolicy.selectedRole(selectedModelId); + if (role.length === 0) { + return ''; + } + return ConversationModelPresentationPolicy.resolveModel(catalog, role) ? role : ''; + } + + static roleLabelKey(role: string): string { + if (role === ConversationModelPresentationPolicy.ROLE_PRIMARY) { + return 'chat.modelPrimary'; + } + if (role === ConversationModelPresentationPolicy.ROLE_FAST) { + return 'chat.modelFast'; + } + return ''; + } + + static roleBadgeLabelKey(role: string): string { + if (role === ConversationModelPresentationPolicy.ROLE_PRIMARY) { + return 'chat.modelRolePrimary'; + } + if (role === ConversationModelPresentationPolicy.ROLE_FAST) { + return 'chat.modelRoleFast'; + } + return ''; + } + + /** Roles a concrete model id serves, in primary-then-fast order. */ + static modelRoles(catalog: ConversationUiModelCatalog, modelId: string): string[] { + const value = (modelId || '').trim(); + const roles: string[] = []; + if (value.length === 0) { + return roles; + } + if (value === (catalog.default_models.primary || '').trim()) { + roles.push(ConversationModelPresentationPolicy.ROLE_PRIMARY); + } + if (value === (catalog.default_models.fast || '').trim()) { + roles.push(ConversationModelPresentationPolicy.ROLE_FAST); + } + return roles; + } + + /** + * Defaults section rows. Empty when neither role resolves to an enabled + * model, so the selector stays a plain model list. + */ + static roleEntries(catalog: ConversationUiModelCatalog): ConversationUiModelRoleEntry[] { + const primary = ConversationModelPresentationPolicy.resolveModel( + catalog, ConversationModelPresentationPolicy.ROLE_PRIMARY); + const fast = ConversationModelPresentationPolicy.resolveModel( + catalog, ConversationModelPresentationPolicy.ROLE_FAST); + if (!primary && !fast) { + return []; + } + const ownFast = ConversationModelPresentationPolicy.enabledModel(catalog, catalog.default_models.fast || ''); + return [ + { + role: ConversationModelPresentationPolicy.ROLE_PRIMARY, + labelKey: ConversationModelPresentationPolicy.roleLabelKey(ConversationModelPresentationPolicy.ROLE_PRIMARY), + model: primary, + fallsBackToPrimary: false + }, + { + role: ConversationModelPresentationPolicy.ROLE_FAST, + labelKey: ConversationModelPresentationPolicy.roleLabelKey(ConversationModelPresentationPolicy.ROLE_FAST), + model: fast, + fallsBackToPrimary: !ownFast + } + ]; + } + + /** + * Trigger label key for a role selection, or an empty string when the + * selection names a concrete model or the role no longer resolves. + */ + static selectedModelLabelKey(catalog: ConversationUiModelCatalog, selectedModelId: string): string { + return ConversationModelPresentationPolicy.roleLabelKey( + ConversationModelPresentationPolicy.resolvedSelectedRole(catalog, selectedModelId)); + } + + /** + * Notice key that replaces a role row's resolved-model metadata, or an empty + * string when the row shows the resolved model instead. + */ + static roleMetaNoticeKey(entry: ConversationUiModelRoleEntry): string { + return entry.fallsBackToPrimary ? 'chat.modelFastNotConfigured' : ''; + } + + /** + * True when a concrete row owns the current selection. A role selection + * highlights its role row instead, so no concrete row is marked. + */ + static isConcreteModelSelected(selectedModelId: string, modelId: string): boolean { + const value = (selectedModelId || '').trim(); + return value.length > 0 && + !ConversationModelPresentationPolicy.isSemanticModelId(value) && + value === (modelId || '').trim(); + } + static selectedModel( catalog: ConversationUiModelCatalog, selectedModelId: string @@ -18,11 +177,7 @@ export class ConversationModelPresentationPolicy { 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); + const model = ConversationModelPresentationPolicy.resolveModel(catalog, candidates[index]); if (model) { return model; } @@ -59,6 +214,48 @@ export class ConversationModelPresentationPolicy { return model.id || primary; } + /** Provider and context-window metadata, e.g. `openai · 128k`. */ + static modelContextLabel(model: ConversationUiModel): string { + const parts: string[] = []; + const provider = ConversationModelPresentationPolicy.cleanLabel(model.provider || ''); + if (provider.length > 0) { + parts.push(provider); + } + const context = ConversationModelPresentationPolicy.contextWindowLabel(model.context_window); + if (context.length > 0) { + parts.push(context); + } + return parts.join(' · '); + } + + /** Resolved model name plus its provider/context metadata for a role row. */ + static roleMetaLabel(model: ConversationUiModel | undefined, fallback: string): string { + if (!model) { + return ''; + } + const name = ConversationModelPresentationPolicy.primaryLabel(model, fallback); + const context = ConversationModelPresentationPolicy.modelContextLabel(model); + return context.length > 0 ? `${name} · ${context}` : name; + } + + private static enabledModel( + catalog: ConversationUiModelCatalog, + modelId: string + ): ConversationUiModel | undefined { + const value = (modelId || '').trim(); + if (value.length === 0) { + return undefined; + } + return catalog.models.find((item: ConversationUiModel) => item.id === value && item.enabled); + } + + private static contextWindowLabel(contextWindow: number | undefined): string { + if (!contextWindow || contextWindow <= 0) { + return ''; + } + return `${Math.round(contextWindow / 1000)}k`; + } + private static cleanLabel(value: string): string { const trimmed = (value || '').trim(); if (trimmed.length === 0) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets index 1a53d8111a..a027149ba9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets @@ -26,7 +26,8 @@ interface TaskPreviewPayload { /** Pure tool classification and payload projection used by the ArkUI renderer. */ export class ToolStatusPresentationPolicy { static normalizedName(tool: ConversationUiToolStatus): string { - return (tool.name || 'Tool').replace(/[\s-]/g, '_').toLowerCase(); + const raw = (tool.name || '').trim(); + return (raw.length > 0 ? raw : 'Tool').replace(/[\s-]/g, '_').toLowerCase(); } static isQuestionLike(tool: ConversationUiToolStatus): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ComposerOverflowPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ComposerOverflowPreview.ets new file mode 100644 index 0000000000..d0dcf45c7b --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ComposerOverflowPreview.ets @@ -0,0 +1,122 @@ +import { ChatComposerPolicy } from '../../services/ChatComposerPolicy'; +import { ComposerBar, ComposerPresentation } from '../components/ComposerBar'; +import { INK, PAGE_BG } from '../components/Theme'; + +/** + * One line of draft that is far wider than the collapsed field, so the field has + * to mark what it cannot show. + */ +const OVERFLOW_DRAFT: string = + 'Keep the collapsed composer honest about what it cannot show: this draft is ' + + 'much wider than one line of the field, so the reader must see where it stops.'; + +/** + * A newline puts the same production composer into its tall form, which is the + * other half of the contract: the expanded field keeps a scrollable viewport and + * the expanded action row carries the draft's supplemental microphone beside the + * primary action. + */ +const MULTILINE_DRAFT: string = + 'Expanded draft, first line\nsecond line\nthird line'; + +/** + * The composer's draft chrome as the phone draws it: an empty field reaches + * dictation from the primary slot, a collapsed draft keeps its one line to + * itself and marks the overflow with an ellipsis instead of scrolling sideways, + * and an expanded draft gets the supplemental microphone beside Send. + * + * This mounts the production `ComposerBar` with the same capabilities the remote + * chat surface uses and no host, so the pixels are the shipping pixels. The + * `composer-overflow-state` line asks the production `ChatComposerPolicy` the + * two questions that decide the chrome — which primary action the draft earns, + * and whether the supplemental voice slot is wanted — so a screenshot can be read + * without inferring the decision from the glyphs alone. `supplementalVoice` + * reports the slot as mounted, not the bare policy answer: the collapsed field + * gives its one line to the draft, so the slot only reaches the screen from the + * expanded action row. + * + * **Next draft** cycles the three states on one install: the wide single line, + * the empty field, and the multiline draft. **Resize** swaps the compact and + * floating presentations without restarting the app. + */ +@ComponentV2 +export struct ComposerOverflowPreview { + @Local draftIndex: number = 0; + @Local draft: string = OVERFLOW_DRAFT; + @Local floating: boolean = false; + + aboutToAppear(): void { + // The launcher picks the starting state and the posture through the scenario + // id, so each draft can be captured without an extra tap. + const scenarioId = AppStorage.get('scenarioId') ?? ''; + this.draftIndex = this.startingDraftIndex(scenarioId); + this.draft = this.draftFor(this.draftIndex); + this.floating = scenarioId.endsWith('-wide'); + } + + private startingDraftIndex(scenarioId: string): number { + if (scenarioId.startsWith('composer-overflow-empty')) { + return 1; + } + return scenarioId.startsWith('composer-overflow-expanded') ? 2 : 0; + } + + private draftFor(index: number): string { + if (index === 1) { + return ''; + } + return index === 2 ? MULTILINE_DRAFT : OVERFLOW_DRAFT; + } + + private nextDraft(): void { + this.draftIndex = (this.draftIndex + 1) % 3; + this.draft = this.draftFor(this.draftIndex); + } + + /** The same expansion decision the composer makes, read from the policy. */ + private collapsed(): boolean { + return !ChatComposerPolicy.isExpanded(this.draft, false, false); + } + + private stateLine(): string { + // The remote chat capabilities, an idle connection and no running turn: the + // two draft states this fixture is about are decided by the text alone. + const action = ChatComposerPolicy.primaryAction( + this.draft, 0, false, false, false, true, true, 'connected', true); + const collapsed = this.collapsed(); + // The policy owns whether a draft earns the slot; the collapsed field gives + // its one line to the draft, so the slot only reaches the screen from the + // expanded action row. Report what is mounted, which is what a reader can + // check against the pixels. + const supplementalVoice = !collapsed && + ChatComposerPolicy.shouldShowSupplementalVoice(this.draft, 0, false, true); + const draft = this.draft.length === 0 ? 'empty' : `${this.draft.length} chars`; + return `draft=${draft} collapsed=${collapsed} action=${action} ` + + `supplementalVoice=${supplementalVoice}`; + } + + build() { + Column({ space: 8 }) { + Row({ space: 8 }) { + Button('Next draft').id('composer-overflow-draft').onClick(() => { + this.nextDraft(); + }) + Button('Resize').id('composer-overflow-resize').onClick(() => { + this.floating = !this.floating; + }) + } + Text(this.stateLine()).id('composer-overflow-state').fontColor(INK).fontSize(12) + Blank() + ComposerBar({ + presentation: this.floating ? ComposerPresentation.Floating : ComposerPresentation.Compact, + chatInput: this.draft, + onChatInputChange: (value: string) => { + this.draft = value; + } + }) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/FileVisibilityPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/FileVisibilityPreview.ets new file mode 100644 index 0000000000..a30f68eea4 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/FileVisibilityPreview.ets @@ -0,0 +1,453 @@ +import { + ChatMessage, + FileInfo, + ReadFileChunkResult, + ReadFileResult, + RuntimeFileLocation, + SessionSummary +} from '../../model/RemoteModels'; +import { ChatTimelineRowStore, ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; +import { DurableSessionReducer, SessionRecord } from '../../services/DurableSessionReducer'; +import { ChatTimelineStore } from '../../services/ChatTimelineStore'; +import { HostSessionEvent } from '../../services/HostSessionStream'; +import { + RemoteFileDownloadClient, + RemoteFileDownloadController, + RemoteFileDownloadScheduler, + RemoteFileSaver +} from '../../services/RemoteFileDownloadController'; +import { Encoding } from '../../services/Encoding'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; +import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; +import { FilePreviewController } from '../viewmodel/FilePreviewController'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { ChatTimeline } from '../components/ChatTimeline'; +import { FilePreviewSurface } from '../components/FilePreviewSurface'; +import { INK, PAGE_BG } from '../components/Theme'; + +/** One file the fixture host can serve, in the shape the wire would carry it. */ +class PreviewFile { + readonly path: string; + readonly name: string; + readonly mimeType: string; + readonly contentBase64: string; + /** Derived from the bytes, so the reported size can never drift from them. */ + readonly size: number; + + constructor(path: string, name: string, mimeType: string, contentBase64: string) { + this.path = path; + this.name = name; + this.mimeType = mimeType; + this.contentBase64 = contentBase64; + this.size = Encoding.base64ToBytes(contentBase64).length; + } + + static text(path: string, mimeType: string, text: string): PreviewFile { + return new PreviewFile( + path, + PreviewFileHost.basename(path), + mimeType, + Encoding.bytesToBase64(Encoding.utf8ToBytes(text)) + ); + } +} + +/** + * A tiny real PNG so the image branch decodes on device instead of failing on a + * placeholder, plus the text files the fixture message links to. + */ +const PREVIEW_PNG_BASE64: string = + 'iVBORw0KGgoAAAANSUhEUgAAAHgAAABICAIAAACyfKYoAAABG0lEQVR42u3XIQ5CQRRD0WocG2BfbInV4pAfhQFDArlvMr3N09PmuMnp+ni7w7xyv13+dfmE1pqD1pqD1pqD1pqDLrf+J/RxHFpD0Fpz0Fpz0Fpz0Fpz0Fpz0Fpz0Fpz0Fpz0Fpz0OXWKHSzNQ1daz0A3Wk9A11oPQbdZj0JXWU9DN1jPQ9dYr0EdIP1KtD+0b9Pfn9Cawhaaw5aaw5aaw5aaw5aaw5aaw5aaw5aaw5aaw5aaw5aaw5aaw5a65BlzdaB+2qtw1d2WmektdA6U8Vt1hnsrrLObH2PdcYXlFhnhREN1llkx/bWWWfK3tZZas3G1llt0K7WWXDTltY5GyRCCy20EVpooSUQWmgjtNBCG6GFNkILLbQh8gRhd1Ue5saG0wAAAABJRU5ErkJggg=='; + +class PreviewFileHost implements RemoteFileDownloadClient { + private readonly files: Map = new Map(); + + constructor() { + this.add(new PreviewFile('png/openbitfun-wordmark.png', 'openbitfun-wordmark.png', 'image/png', + PREVIEW_PNG_BASE64)); + this.add(PreviewFile.text('artifacts/REPORT.md', 'text/markdown', + '# Brand report\n\nThe wordmark and the bundle are ready.\n')); + this.add(new PreviewFile('artifacts/bundle.zip', 'bundle.zip', 'application/zip', 'UEsDBAoAAAAAAA==')); + this.add(PreviewFile.text('artifacts/run.log', 'text/plain', + 'brand export ok\nbundled 2 files\n')); + } + + private add(file: PreviewFile): void { + this.files.set(file.path, file); + } + + static basename(path: string): string { + const parts = path.split('/'); + return parts[parts.length - 1] || path; + } + + async getFileInfo(path: string, _sessionId?: string): Promise { + const file = this.files.get(path); + if (!file) { + throw new Error(`File not found: ${path}`); + } + return { name: file.name, size: file.size, mimeType: file.mimeType }; + } + + async readFileChunk(path: string, offset: number, limit: number, + _sessionId?: string): Promise { + const file = this.files.get(path); + if (!file) { + throw new Error(`File not found: ${path}`); + } + const bytes = Encoding.base64ToBytes(file.contentBase64); + const end = Math.min(bytes.length, offset + limit); + const slice = bytes.slice(offset, end); + return { + revision: '1:1', + name: file.name, + contentBase64: Encoding.bytesToBase64(slice), + offset, + chunkSize: slice.length, + totalSize: bytes.length, + mimeType: file.mimeType + }; + } + + async streamFile(path: string, sessionId: string | undefined, + onChunk: (bytes: Uint8Array, info: FileInfo) => Promise, + onProgress?: (downloaded: number, total: number) => void, + _location?: RuntimeFileLocation): Promise { + const file = this.files.get(path); + if (!file) { + throw new Error(`File not found: ${path}`); + } + const bytes = Encoding.base64ToBytes(file.contentBase64); + const info = await this.getFileInfo(path, sessionId); + await onChunk(bytes, info); + if (onProgress) { + onProgress(bytes.length, bytes.length); + } + return info; + } + + async readFile(path: string, sessionId?: string, onProgress?: (downloaded: number, total: number) => void, + maxBytes?: number): Promise { + const file = this.files.get(path); + if (!file) { + throw new Error(`File not found: ${path}`); + } + if (maxBytes !== undefined && file.size > maxBytes) { + throw new Error('File too large for preview.'); + } + const info = await this.getFileInfo(path, sessionId); + if (onProgress) { + onProgress(file.size, file.size); + } + return { + name: info.name, + mimeType: info.mimeType, + size: info.size, + contentBase64: file.contentBase64 + }; + } +} + +/** + * Stands in for the system document picker and the file it would write, so a + * download can be proved on a device without a destination dialog in the way. + * The production `RemoteFileDownloadController` owns everything else. + */ +class PreviewFileSaver implements RemoteFileSaver { + destinationName: string = ''; + bytesWritten: number = 0; + committed: boolean = false; + aborted: boolean = false; + + reset(): void { + this.destinationName = ''; + this.bytesWritten = 0; + this.committed = false; + this.aborted = false; + } + + async begin(name: string): Promise { + this.destinationName = name; + } + + async write(bytes: Uint8Array): Promise { + this.bytesWritten += bytes.length; + } + + async finish(): Promise { + this.committed = true; + return 'file://preview/download'; + } + + async abort(): Promise { + this.aborted = true; + } +} + +class PreviewDownloadScheduler implements RemoteFileDownloadScheduler { + setTimeout(_callback: () => void, _delayMs: number): number { + return 1; + } + + clearTimeout(_timerId: number): void {} +} + +/** + * The file-visibility chain as the phone walks it, driven by the production + * components: a durable assistant turn whose prose links workspace files is + * projected through the real reducer, timeline store and rows, and a tap on a + * card routes through the real `FilePreviewController` into the real + * `FilePreviewSurface`. + * + * Only the transport is faked. The fixture host answers `get_file_info`, + * `read_file_chunk` and the streamed read exactly like `remote_connect.rs` + * does, so the image branch decodes a real PNG and the text branches show real + * content. **Next shape** swaps the same links between the final-answer body + * and a composite turn's trailing text, which is the two paths that draw cards. + */ +@ComponentV2 +export struct FileVisibilityPreview { + private reducer: DurableSessionReducer = new DurableSessionReducer(); + private readonly timeline: ChatTimelineStore = new ChatTimelineStore(); + private readonly rowStore: ChatTimelineRowStore = new ChatTimelineRowStore(); + private readonly fileHost: PreviewFileHost = new PreviewFileHost(); + private readonly previewState: FilePreviewState = new FilePreviewState(); + private readonly fileSaver: PreviewFileSaver = new PreviewFileSaver(); + private readonly downloads: RemoteFileDownloadController = new RemoteFileDownloadController( + this.fileHost, + (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string): void => { + this.downloadingFilePath = downloadingFilePath; + this.downloadedFilePath = downloadedFilePath; + this.downloadStatus = fileDownloadStatus; + }, + (): void => { + this.downloadingFilePath = ''; + }, + (statusText: string): void => { + this.downloadStatus = statusText; + this.savedSummary = this.fileSaver.committed ? + `${this.fileSaver.destinationName}:${this.fileSaver.bytesWritten}` : ''; + }, + (_isBusy: boolean): void => {}, + 2500, + new PreviewDownloadScheduler(), + this.fileSaver + ); + @Local rows: ObservableChatTimelineItem[] = []; + @Local revision: number = 0; + @Local shape: number = 0; + @Local routed: string = ''; + @Local previewVisible: boolean = false; + @Local downloadingFilePath: string = ''; + @Local downloadedFilePath: string = ''; + @Local downloadStatus: string = ''; + @Local savedSummary: string = ''; + private previewController?: FilePreviewController = undefined; + + aboutToAppear(): void { + this.previewController = new FilePreviewController( + this.fileHost, + this.previewState, + { + remoteAvailable: (): boolean => true, + activeSession: (): SessionSummary => { + const summary: SessionSummary = { + sessionId: 'preview-file-session', + title: 'File visibility', + workspacePath: '/workspace/project', + agentType: 'code' + }; + return summary; + }, + workspacePath: (): string => '/workspace/project', + openExternalLink: async (_reference: string): Promise => true, + onGeneralStatus: (_statusText: string): void => {}, + onRemoteStatus: (statusText: string): void => { + this.routed = statusText; + } + } + ); + this.timeline.reset('preview-file-session'); + this.publishShape(); + } + + aboutToDisappear(): void { + if (this.previewController) { + this.previewController.close(); + } + } + + private finalAnswer(): string { + return 'Done. The brand export wrote these files:\n\n' + + '- [openbitfun-wordmark.png](computer://png/openbitfun-wordmark.png)\n' + + '- [REPORT.md](computer://artifacts/REPORT.md)\n' + + '- [bundle.zip](computer://artifacts/bundle.zip)\n\n' + + 'The run log is at computer://artifacts/run.log, and the source image is ' + + 'png/openbitfun-wordmark.png.'; + } + + private trailingAnswer(): string { + return 'Checked the workspace files.\n\n' + + '- [openbitfun-wordmark.png](computer://png/openbitfun-wordmark.png)\n' + + '- [REPORT.md](computer://artifacts/REPORT.md)'; + } + + private record(id: string, revision: number, type: string, order: number, content: string, + toolName: string): HostSessionEvent { + const payload: SessionRecord = { + sessionId: 'preview-file-session', + id: `item/${id}`, + revision, + turn: { + turnId: 'turn-file-preview', + turnIndex: 0, + sessionId: 'preview-file-session', + timestamp: 1, + userMessage: { id: 'user-file-preview', content: 'Export the brand assets', timestamp: 1 }, + status: 'completed' + }, + round: { id: 'round-file-preview', turnId: 'turn-file-preview', roundIndex: 0, timestamp: 2, + status: 'completed' }, + item: { + type, + data: { id, orderIndex: order, timestamp: 3, content, toolName, status: 'completed' } + } + }; + return { session_id: 'preview-file-session', event: 'session-record', payload }; + } + + /** Rebuilds the transcript for the selected shape and republishes the rows. */ + private publishShape(): void { + this.reducer = new DurableSessionReducer(); + if (this.shape === 0) { + this.reducer.apply(this.record('final-text', 1, 'text', 0, this.finalAnswer(), '')); + } else { + this.reducer.apply(this.record('think', 1, 'thinking', 0, 'Reading the export output', '')); + this.reducer.apply(this.record('read', 2, 'tool', 1, '', 'Read')); + this.reducer.apply(this.record('trailing-text', 3, 'text', 2, this.trailingAnswer(), '')); + } + this.publish(); + } + + private publish(): void { + const messages = this.reducer.messages(); + this.timeline.applySnapshot({ + sessionId: 'preview-file-session', + cursor: { pollVersion: 0, knownMessageCount: messages.length, knownModelCatalogVersion: 0 }, + changed: true, + title: '', + sessionState: 'idle', + newMessages: [], + messageSnapshot: messages, + activeTurn: undefined, + shouldSyncAfterTurnEnded: false, + historyRewritten: false, + completedTurnId: '' + }); + this.rows = this.rowStore.reconcile(this.timeline.project(false)); + this.revision++; + } + + private openFilePreview(reference: string, label: string): void { + if (!this.previewController) { + return; + } + this.routed = reference; + this.previewController.open(AppRoute.RemoteChat, new FilePreviewRequest(reference, label)); + this.previewVisible = this.previewState.visible; + } + + private routeSummary(): string { + const phase = this.previewState.visible ? this.previewState.phase : FilePreviewPhase.Idle; + const saved = this.savedSummary.length > 0 ? ` saved=${this.savedSummary}` : ''; + return `shape=${this.shape} phase=${phase} kind=${this.previewState.rendererKind} ` + + `bytes=${this.previewState.loadedBytes} last=${this.routed}${saved}`; + } + + private downloadFile(reference: string): void { + this.downloadedFilePath = ''; + this.savedSummary = ''; + this.fileSaver.reset(); + void this.downloads.download( + reference, + 'preview-file-session', + false, + true + ); + } + + private closePreview(): void { + if (this.previewController) { + this.previewController.close(); + } + this.previewVisible = false; + } + + build() { + Stack() { + Column({ space: 8 }) { + Row({ space: 8 }) { + Button('Next shape').id('file-visibility-shape').onClick(() => { + this.shape = this.shape === 0 ? 1 : 0; + this.closePreview(); + this.publishShape(); + }) + } + Text(this.routeSummary()).id('file-visibility-state').fontColor(INK).fontSize(12) + ChatTimeline({ + timelineItems: this.rows, + timelineRevision: this.revision, + connectionState: 'connected', + isBusy: false, + downloadingFilePath: this.downloadingFilePath, + downloadedFilePath: this.downloadedFilePath, + fileDownloadStatus: this.downloadStatus, + activeFilePreviewPath: this.previewState.visible ? this.previewState.target.remotePath : '', + activeFilePreviewLoading: this.previewState.visible && + this.previewState.phase === FilePreviewPhase.Loading, + onOpenFilePreview: (path: string, label: string) => { + this.openFilePreview(path, label); + }, + onDownloadFile: (path: string) => { + this.downloadFile(path); + } + }) + .layoutWeight(1) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + + if (this.previewVisible) { + Column() { + FilePreviewSurface({ + state: this.previewState, + remoteAvailable: true, + downloadPath: this.downloadingFilePath, + downloadedPath: this.downloadedFilePath, + downloadStatus: this.downloadStatus, + onClose: () => { + this.closePreview(); + }, + onRefresh: () => { + if (this.previewController) { + this.previewController.refresh(); + } + }, + onDownload: (path: string) => { + this.downloadFile(path); + }, + onOpenLink: (reference: string, label: string) => { + this.openFilePreview(reference, label); + } + }) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + } + .width('100%') + .height('100%') + } +} \ No newline at end of file diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets new file mode 100644 index 0000000000..ce67b41656 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets @@ -0,0 +1,492 @@ +import { ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; +import { ChatMessage, RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { MiniAppSummary } from '../../model/MiniAppModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ChatSurface } from '../state/ChatSurface'; +import { REMOTE_CHAT_COMPOSER_CAPABILITIES } from '../state/ChatComposerCapabilities'; +import { AppShellState } from '../state/AppShellState'; +import { MiniAppState } from '../state/MiniAppState'; +import { RemotePageState } from '../state/RemotePageState'; +import { + DeviceDirectoryEntry, + DeviceDirectoryState, + emptyDeviceDirectoryEntry +} from '../state/DeviceDirectoryState'; +import { WorkspaceToolsState, WorkspaceFileEntry } from '../state/WorkspaceToolsState'; +import { ConversationView } from '../components/ConversationView'; +import { AppShell } from '../components/AppShell'; +import { MiniAppSurface } from '../components/MiniAppSurface'; +import { AppConnectSurface, AppSettingsSurface, AppSidebarSurface } from '../components/AppRootOverlaySurfaces'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from '../components/remote/RemoteSurfaceHost'; +import { AdaptiveSheetOptions } from '../components/AdaptiveSheetOptions'; +import { + SettingsPlacementPolicy, + SettingsSheetKind +} from '../policy/SettingsPlacementPolicy'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { ComposerPresentation } from '../components/ComposerBar'; +import { ConversationViewOptions } from '../components/ConversationViewContract'; +import { ConversationViewState } from '../state/ConversationViewState'; +import { ConversationIntent } from '../actions/ConversationIntent'; + +/** Turns in the transcript. Long enough that the list has to scroll to reach the end. */ +const TRANSCRIPT_TURNS: number = 16; + +/** Tiles in the mini-app gallery: enough rows that the gallery itself scrolls. */ +const GALLERY_APPS: number = 9; + +/** Devices the account panel lists: enough rows that the settings sheet scrolls. */ +const SETTINGS_DEVICES: number = 9; + +/** Workspaces on the fixture desktop: enough rows that the sidebar's directory scrolls. */ +const SIDEBAR_WORKSPACES: number = 14; + +/** Sessions in the first workspace: the sidebar's own scrollable rows. */ +const SIDEBAR_SESSIONS: number = 36; + +/** Files in the workspace tools sheet: enough rows that the file list scrolls. */ +const TOOLS_FILES: number = 18; + +/** Lines in the seeded editor file: enough that the editor's web view scrolls. */ +const EDITOR_LINES: number = 120; + +/** Prose tall enough to be read while it is already half faded out. */ +const ANSWER_PARAGRAPH: string = + 'The transcript runs the full height of the pane, so the last answer keeps ' + + 'scrolling until it reaches the bottom edge of the screen. The composer ' + + 'floats over it and the fade carries the text into the page colour instead ' + + 'of cutting it off at a hard line.'; + +const FINAL_ANSWER_PARAGRAPH: string = + 'This is the final answer of the transcript, and it decides whether the bottom ' + + 'edge reads as a continuation of the conversation or as a strip of empty page ' + + 'colour: it must be able to scroll above the floating composer, and everything ' + + 'below the composer must still look like the surface the answer is drawn on. A ' + + 'reader who flings the list to the end should see the answer fade into the page ' + + 'rather than stop short of it.'; + +function message(id: string, role: string, text: string): ChatMessage { + return { id, role, text, status: 'completed', timestamp: '0' }; +} + +function row(id: string, role: string, text: string): ObservableChatTimelineItem { + return new ObservableChatTimelineItem({ + id, + type: role === 'user' ? 'user_message' : 'assistant_message', + message: message(id, role, text), + isStreaming: false, + isFinalizing: false + }); +} + +function transcript(): ObservableChatTimelineItem[] { + const rows: ObservableChatTimelineItem[] = []; + for (let index = 0; index < TRANSCRIPT_TURNS; index++) { + const turn = index + 1; + rows.push(row(`turn-${turn}-user`, 'user', `Round ${turn}: how does the bottom of the chat page read?`)); + rows.push(row(`turn-${turn}-assistant`, 'assistant', + turn === TRANSCRIPT_TURNS ? FINAL_ANSWER_PARAGRAPH : ANSWER_PARAGRAPH)); + } + return rows; +} + +function gallery(): MiniAppSummary[] { + const apps: MiniAppSummary[] = []; + for (let index = 0; index < GALLERY_APPS; index++) { + const tile = index + 1; + apps.push({ + id: `gallery-${tile}`, + name: `Mini app ${tile}`, + description: 'Offline sample tile, so the gallery has rows below the fold.' + }); + } + return apps; +} + +function settingsDevices(): CloudAccountDevice[] { + const devices: CloudAccountDevice[] = []; + for (let index = 0; index < SETTINGS_DEVICES; index++) { + const device = index + 1; + devices.push({ + deviceId: `account-desktop-${device}`, + deviceName: `Account desktop ${device}`, + online: device % 3 !== 0, + lastSeenAt: device + }); + } + return devices; +} + +function sidebarWorkspaces(): RecentWorkspaceEntry[] { + const workspaces: RecentWorkspaceEntry[] = []; + for (let index = 0; index < SIDEBAR_WORKSPACES; index++) { + const workspace = index + 1; + workspaces.push({ + path: `/workspace/project-${workspace}`, + name: `Workspace ${workspace}`, + lastOpened: `${workspace}`, + workspaceKind: 'normal', + workspaceId: `ws-${workspace}` + }); + } + return workspaces; +} + +function sidebarSessions(): RemoteSession[] { + const sessions: RemoteSession[] = []; + for (let index = 0; index < SIDEBAR_SESSIONS; index++) { + const session = index + 1; + sessions.push({ + id: `sidebar-session-${session}`, + title: `Sidebar session ${String(session).padStart(2, '0')}`, + agentType: 'code', + status: 'completed', + updatedAt: `${session}`, + createdAt: `${session}`, + messageCount: session * 3, + workspacePath: '/workspace/project-1', + workspaceName: 'Workspace 1', + deviceId: 'fixture-desktop' + }); + } + return sessions; +} + +function toolsFiles(): WorkspaceFileEntry[] { + const entries: WorkspaceFileEntry[] = []; + for (let index = 0; index < TOOLS_FILES; index++) { + const file = index + 1; + const name = `report-${String(file).padStart(2, '0')}.md`; + entries.push({ + path: `/workspace/${name}`, + name, + isDirectory: false, + size: file * 64, + modifiedTime: file + }); + } + return entries; +} + +/** The file the editor tab opens: long enough that its content has to scroll. */ +function toolsEditorContent(): string { + const lines: string[] = []; + for (let index = 0; index < EDITOR_LINES; index++) { + const line = index + 1; + lines.push(`# Section ${line}`); + lines.push(''); + lines.push(`This line is body copy for section ${line}, long enough that the`); + lines.push('editor has to scroll before the last line can come to rest.'); + lines.push(''); + } + return lines.join('\n'); +} + +/** + * The empty actions with one override. The interface's members are readonly, + * so the settings sheet's device catalog is supplied by building the whole + * literal once rather than assigning into it. + */ +function previewActionsWithDevices(devices: CloudAccountDevice[]): AppRootPresentationActions { + const base = emptyAppRootPresentationActions(); + return { + onWorkspaceTools: base.onWorkspaceTools, + onNavigationBack: base.onNavigationBack, + onConversationIntent: base.onConversationIntent, + onCloseSidebar: base.onCloseSidebar, + onCompactLayoutEntered: base.onCompactLayoutEntered, + onLayoutModeChanged: base.onLayoutModeChanged, + onRemoteHome: base.onRemoteHome, + onSidebar: base.onSidebar, + onSettings: { + close: base.onSettings.close, + accountSettled: base.onSettings.accountSettled, + disconnect: base.onSettings.disconnect, + clearPairing: base.onSettings.clearPairing, + reconnect: base.onSettings.reconnect, + openAccount: base.onSettings.openAccount, + cloudCancelLogin: base.onSettings.cloudCancelLogin, + cloudLogin: base.onSettings.cloudLogin, + cloudLogout: base.onSettings.cloudLogout, + cloudListDevices: async (): Promise => devices, + cloudSelectDevice: base.onSettings.cloudSelectDevice, + getPermissionMode: base.onSettings.getPermissionMode, + setPermissionMode: base.onSettings.setPermissionMode, + setLanguage: base.onSettings.setLanguage + }, + onConnect: base.onConnect, + onFilePreview: base.onFilePreview, + generalStatus: base.generalStatus + }; +} + +/** + * The chat page's bottom edge, drawn by the production `ConversationView` over a + * transcript long enough to scroll: the composer and the status line float over + * the list, and the list carries the page colour down to the bottom of the + * screen. + * + * This fixture exists because the bottom edge cannot be judged from the composer + * alone: where the input ends up, how far the last message can scroll, and which + * colour the fade finishes on are three separate readings of one capture, and the + * layout dump has to show the same pane the pixels do. + * + * The scenario id picks the state, because the shell has to be in it before the + * first frame: `-wide` swaps the composer to the wide detail pane's floating + * presentation, `-drawer` mounts the production `AppShell` with its drawer open + * over the same conversation, `-settings` and `-cover` mount the shell with its + * settings sheet and its compact account cover open, `-miniapps` mounts the + * mini-app gallery, `-tools` opens the workspace tools sheet over the drawer + * (with `-tools-editor` opening it on the editor tab and + * `-tools-file-action` opening its file action form), `-view-settings` opens + * the remote view settings sheet over the same conversation, and `-connect` + * opens the connect sheet. The drawer, settings and tools states seed remote + * content — a device with workspaces and sessions, an account with desktops, a + * directory of files — because a surface whose content fits its viewport has no + * bottom edge to read. None of them changes the window posture: every one of + * these surfaces shares the shell's immersive layout, so every one of them is + * where a control can end up under the navigation bar. + */ +@ComponentV2 +export struct ImmersiveBottomPreview { + @Local state: ConversationViewState = this.initialState(); + @Local shellState: AppShellState = this.initialShellState(); + @Local miniAppState: MiniAppState = this.initialMiniAppState(); + @Local remotePageState: RemotePageState = new RemotePageState(); + @Local directoryState: DeviceDirectoryState = new DeviceDirectoryState(); + @Local previewActions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Local floating: boolean = false; + @Local drawer: boolean = false; + @Local settings: boolean = false; + @Local cover: boolean = false; + @Local galleryVisible: boolean = false; + @Local wideShell: boolean = false; + @Local viewSettings: boolean = false; + @Local showViewSettingsSheet: boolean = false; + @Local remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + + aboutToAppear(): void { + const scenarioId = AppStorage.get('scenarioId') ?? ''; + this.floating = scenarioId.endsWith('-wide') && !scenarioId.endsWith('-wide-shell'); + this.drawer = scenarioId.endsWith('-drawer') || scenarioId.startsWith('immersive-bottom-tools'); + this.settings = scenarioId.endsWith('-settings') || scenarioId.endsWith('-settings-dark'); + this.cover = scenarioId.endsWith('-cover'); + this.galleryVisible = scenarioId.endsWith('-miniapps'); + // The wide shell keeps the same session list as a master pane instead of a + // drawer, so its bottom edge is the same bottom edge. + this.wideShell = scenarioId.endsWith('-wide-shell'); + this.viewSettings = scenarioId.endsWith('-view-settings'); + this.showViewSettingsSheet = this.viewSettings; + // The shell's own surfaces have to be open before the first frame, and the + // scenario id is only known once the page is appearing, so the flags are + // applied here rather than in the field initialisers. + this.shellState.showSidebar = this.drawer || this.wideShell; + this.shellState.showSettings = this.settings || this.cover; + this.shellState.showConnectSheet = scenarioId.endsWith('-connect'); + if (this.cover) { + this.shellState.settingsMode = 'account'; + } + this.seedRemoteState(scenarioId); + } private initialState(): ConversationViewState { + const state = new ConversationViewState(); + state.surface = ChatSurface.Remote; + state.connectionState = 'connected'; + state.activeSession = { + sessionId: 'immersive-bottom-preview', + title: 'Bottom edge', + workspacePath: '/workspace', + agentType: 'code' + }; + state.desktopName = 'Studio'; + state.composerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; + state.timelineItems = transcript(); + state.timelineRevision = 1; + return state; + } + + private initialShellState(): AppShellState { + return new AppShellState(); + } + + private initialMiniAppState(): MiniAppState { + const state = new MiniAppState(); + state.visible = true; + state.apps = gallery(); + return state; + } + + /** + * Remote content for the shell's own surfaces. The surfaces under test scroll, + * so their fixture content has to overflow their viewport: the settings sheet + * lists an account's desktops, the drawer carries a device directory with + * workspaces and sessions, and the tools sheet carries a directory of files. + * The connect sheet carries a persisted legacy pairing link, because its + * status strip — the fixed control at its bottom edge — is what that state + * mounts there. The view settings sheet shares the drawer's directory because + * its filters are what scroll in it. + */ + private seedRemoteState(scenarioId: string): void { + if (this.settings) { + this.remotePageState.setAccountUserId('octocat'); + this.remotePageState.setAccountUsername('octocat'); + this.remotePageState.setControlTarget('account_device', 'account-desktop-1', 'Account desktop 1'); + this.remotePageState.setDesktopIdentity('Account desktop 1', 'account-desktop-1'); + this.remotePageState.connectionState = 'connected'; + this.previewActions = previewActionsWithDevices(settingsDevices()); + return; + } + if (scenarioId.endsWith('-connect')) { + this.remotePageState.setPairingInput('https://pair.example/openbitfun/legacy', 'user-1', false); + return; + } + if (this.drawer || this.viewSettings) { + const desktop: DeviceDirectoryEntry = emptyDeviceDirectoryEntry('fixture-desktop', 'Studio', true); + desktop.status = 'ready'; + desktop.workspaces = sidebarWorkspaces(); + this.directoryState.replace([desktop]); + this.directoryState.select('fixture-desktop'); + this.directoryState.setWorkspaceExpanded('fixture-desktop', '/workspace/project-1', true, + undefined, undefined, 'ws-1'); + this.remotePageState.setControlTarget('account_device', 'fixture-desktop', 'Studio'); + this.remotePageState.setDesktopIdentity('Studio', 'fixture-desktop'); + this.remotePageState.connectionState = 'connected'; + this.remotePageState.workspacePath = '/workspace/project-1'; + this.remotePageState.workspaceName = 'Workspace 1'; + this.remotePageState.workspaceId = 'ws-1'; + this.remotePageState.workspaceKind = 'normal'; + this.remotePageState.setSessions(sidebarSessions(), false); + if (scenarioId.startsWith('immersive-bottom-tools')) { + this.remotePageState.workspaceTools.visible = true; + this.remotePageState.workspaceTools.directory = '/workspace'; + this.remotePageState.workspaceTools.entries = toolsFiles(); + if (scenarioId.endsWith('-tools-editor')) { + // The editor tab: the WebView is the tab's bottom edge, so its content + // has to be long enough to scroll inside it. + this.remotePageState.workspaceTools.editorVisible = true; + this.remotePageState.workspaceTools.selectedFile = '/workspace/report-01.md'; + this.remotePageState.workspaceTools.content = toolsEditorContent(); + } + if (scenarioId.endsWith('-tools-file-action')) { + // The file action form: a FIT_CONTENT sheet whose buttons are the + // fixed controls at the screen edge. + this.remotePageState.workspaceTools.fileAction = 'file'; + } + } + } + } + + private options(): ConversationViewOptions { + const options = new ConversationViewOptions(); + options.showSidebarButton = true; + options.showBackButton = false; + options.composerPresentation = + this.floating ? ComposerPresentation.Floating : ComposerPresentation.Compact; + return options; + } + + build() { + if (this.galleryVisible) { + MiniAppSurface({ state: this.miniAppState }) + } else if (this.viewSettings) { + this.ViewSettingsLayout() + } else if (this.drawer || this.settings || this.cover || this.wideShell || + this.shellState.showConnectSheet) { + this.ShellLayout() + } else { + this.Conversation() + } + } + + /** + * The remote view settings sheet, mounted the way the app root mounts it: a + * bindSheet with the compact placement's options over the conversation. The + * sheet is the production `RemoteSurfaceHost` in its settings mode, so the + * surface under test is the production component. + */ + @Builder + private ViewSettingsLayout() { + Column() { + this.Conversation() + } + .width('100%') + .height('100%') + .bindSheet($$this.showViewSettingsSheet, this.ViewSettingsSheet(), AdaptiveSheetOptions.fromPlacement( + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.RemoteViewSettings), true)) + } + + @Builder + private ViewSettingsSheet() { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Settings, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.previewActions, + onCloseSettings: () => { this.showViewSettingsSheet = false; } + }) + } + + /** + * The drawer, the settings sheet, the connect sheet and the compact account + * cover are all shell surfaces rather than pages, so they are mounted through + * the production shell with the production content for their slot. Each of + * them has its own bottom edge, and the shell is what moves them with the + * window. + */ + @Builder + private ShellLayout() { + AppShell({ + shellState: this.shellState, + useWideLayout: this.wideShell, + allowSidebar: true, + compactAccountLogin: this.cover, + content: () => { this.Conversation(); }, + sidebar: () => { this.Sidebar(); }, + settings: () => { this.Settings(); }, + connect: () => { this.Connect(); }, + onCloseSidebar: () => { this.shellState.showSidebar = false; } + }) + } + + @Builder + private Settings() { + AppSettingsSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + actions: this.previewActions + }) + } + + @Builder + private Connect() { + AppConnectSurface({ + remotePageState: this.remotePageState, + actions: this.previewActions + }) + } + + @Builder + private Sidebar() { + AppSidebarSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + deviceDirectoryState: this.directoryState, + actions: emptyAppRootPresentationActions() + }) + } + + @Builder + private Conversation() { + ConversationView({ + state: this.state, + options: this.options(), + onIntent: (_intent: ConversationIntent): void => {} + }) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets index 2047ff7c21..c603ee2343 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -4,6 +4,11 @@ import { ComposerSubmissionPreview } from './ComposerSubmissionPreview'; import { DurableTimelinePreview } from './DurableTimelinePreview'; import { DeviceSelectorPreview } from './DeviceSelectorPreview'; import { CatalogRefreshPreview } from './CatalogRefreshPreview'; +import { ModelRoleSelectorPreview } from './ModelRoleSelectorPreview'; +import { SubagentProcessPreview } from './SubagentProcessPreview'; +import { FileVisibilityPreview } from './FileVisibilityPreview'; +import { ComposerOverflowPreview } from './ComposerOverflowPreview'; +import { ImmersiveBottomPreview } from './ImmersiveBottomPreview'; import { WelcomeHome } from '../components/WelcomeHome'; import { InteractionMailboxPreview } from './InteractionMailboxPreview'; import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; @@ -48,6 +53,16 @@ struct MobileDesignGallery { ComposerSubmissionPreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('catalog-refresh')) { CatalogRefreshPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('model-role-selector')) { + ModelRoleSelectorPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('subagent-process')) { + SubagentProcessPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('file-visibility')) { + FileVisibilityPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('composer-overflow')) { + ComposerOverflowPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('immersive-bottom')) { + ImmersiveBottomPreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('device-selector')) { DeviceSelectorPreview() } else if (AppStorage.get('scenarioId') === 'welcome-home') { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ModelRoleSelectorPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ModelRoleSelectorPreview.ets new file mode 100644 index 0000000000..9f630470a1 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ModelRoleSelectorPreview.ets @@ -0,0 +1,98 @@ +import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { ConversationUiModelCatalog, toConversationUiModelCatalog } from '../state/ConversationUiModels'; +import { ComposerBar, ComposerPresentation } from '../components/ComposerBar'; +import { INK, PAGE_BG } from '../components/Theme'; + +/** + * Drives the production composer with a model catalog fixture so the + * primary/fast role rows, their resolved metadata, and the concrete-model role + * badges can be inspected on a device. + * + * No account, host, or remote request is involved: the catalog is projected + * through the same `toConversationUiModelCatalog` path the app uses, the + * selection starts from the session model id the same way hydration does, and + * the select handler records the payload it received instead of talking to a + * host. The recorded payload is surfaced on screen so a screenshot can prove it. + */ +@ComponentV2 +export struct ModelRoleSelectorPreview { + @Local catalog: ConversationUiModelCatalog = toConversationUiModelCatalog(this.fixtureCatalog()); + @Local selectedModelId: string = RemoteUiState.selectedModelIdForCatalog(this.fixtureCatalog(), ''); + @Local received: string = ''; + @Local wide: boolean = false; + // The model control lives in the expanded action row, and the composer only + // expands for focus, an open selector, or a multiline draft. A two-line draft + // reaches that state without depending on the emulator's IME. + @Local draft: string = 'Model role selector probe\nor the fallback row'; + + aboutToAppear(): void { + // The launcher decides the posture through the scenario id, so both + // presentations can be captured without an extra tap. Resize still toggles + // the presentation live. + this.wide = (AppStorage.get('scenarioId') ?? '').endsWith('-wide'); + } + + private fixtureModel(id: string, name: string, provider: string, modelName: string, + contextWindow: number, enabled: boolean): RemoteModelConfig { + return { + id: id, + name: name, + provider: provider, + base_url: '', + model_name: modelName, + context_window: contextWindow, + enabled: enabled, + capabilities: [] + }; + } + + /** Four enabled models plus one disabled one, with distinct primary and fast targets. */ + private fixtureCatalog(): RemoteModelCatalog { + return { + version: 1, + models: [ + this.fixtureModel('anthropic/claude-sonnet-4.5', 'Claude Sonnet 4.5', 'anthropic', + 'claude-sonnet-4-5', 200000, true), + this.fixtureModel('openbitfun:gpt-5-mini', 'GPT-5 mini', 'openai', + 'openbitfun:gpt-5-mini', 128000, true), + this.fixtureModel('google/gemini-2.5-flash', 'Gemini 2.5 Flash', 'google', + 'gemini-2.5-flash', 1000000, true), + this.fixtureModel('local:qwen3-coder', 'Qwen3 Coder', 'local', 'qwen3-coder', 32768, true), + this.fixtureModel('legacy/disabled-model', 'Legacy model', 'legacy', 'legacy-model', 8192, false) + ], + default_models: { + primary: 'anthropic/claude-sonnet-4.5', + fast: 'openbitfun:gpt-5-mini', + search: '', + image_understanding: '' + }, + session_model_id: 'fast' + }; + } + + build() { + Column({ space: 8 }) { + Row({ space: 8 }) { + Button('Resize').id('model-role-resize').onClick(() => { this.wide = !this.wide; }) + } + Text(`selected=${this.selectedModelId} received=${this.received}`) + .id('model-role-state').fontColor(INK) + Blank() + ComposerBar({ + presentation: this.wide ? ComposerPresentation.Floating : ComposerPresentation.Compact, + chatInput: this.draft, + modelCatalog: this.catalog, + selectedModelId: this.selectedModelId, + onChatInputChange: (value: string) => { this.draft = value; }, + onSelectModel: (modelId: string) => { + this.received = modelId; + this.selectedModelId = modelId; + } + }) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets new file mode 100644 index 0000000000..129cb1e4fc --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets @@ -0,0 +1,168 @@ +import { ChatMessage } from '../../model/RemoteModels'; +import { ChatTimelineRowStore, ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; +import { DurableSessionReducer, SessionRecord } from '../../services/DurableSessionReducer'; +import { ChatTimelineStore } from '../../services/ChatTimelineStore'; +import { HostSessionEvent } from '../../services/HostSessionStream'; +import { ChatMessageStructurePolicy, StructuredRenderGroup } from '../policy/ChatMessageStructurePolicy'; +import { + ConversationUiMessage, + ConversationUiMessageItem, + toConversationUiMessage +} from '../state/ConversationUiModels'; +import { ChatTimeline } from '../components/ChatTimeline'; +import { ComposerBar } from '../components/ComposerBar'; +import { INK, PAGE_BG } from '../components/Theme'; + +/** Typed fixtures for the tool payloads a Task branch is titled and read from. */ +interface PreviewTaskInput { + description: string; + subagent_type: string; +} + +interface PreviewReadInput { + path: string; +} + +/** + * A running Task whose subagent process is published flat behind it. + * + * The fixture is the host's own durable record shape: the owner Task tool item + * carries the child Session id, and every process record the child produces + * repeats it, so the branch owner and its children both arrive marked. The + * preview mounts the production reducer, timeline store and rows, so what it + * draws is what a live session draws. + */ +@ComponentV2 +export struct SubagentProcessPreview { + private reducer: DurableSessionReducer = new DurableSessionReducer(); + private timeline: ChatTimelineStore = new ChatTimelineStore(); + private rowStore: ChatTimelineRowStore = new ChatTimelineRowStore(); + private taskCallId: string = 'task_call_preview'; + private childSessionId: string = 'child-session-preview'; + @Local rows: ObservableChatTimelineItem[] = []; + @Local revision: number = 0; + @Local step: number = 0; + @Local summary: string = ''; + @Local connectionState: string = 'connected'; + @Local active: boolean = true; + @Local stopRequests: number = 0; + + aboutToAppear(): void { + this.timeline.reset('preview'); + this.reducer.apply(this.taskRecord(1, 'running', 'inprogress')); + this.publish(); + } + + private taskRecord(revision: number, status: string, turnStatus: string): HostSessionEvent { + const input: PreviewTaskInput = { description: 'Audit the payment module', subagent_type: 'Explore' }; + const payload: SessionRecord = { + sessionId: 'preview', id: `item/${this.taskCallId}`, revision, + turn: { turnId: 'turn', turnIndex: 0, sessionId: 'preview', timestamp: 1, + userMessage: { id: 'user', content: 'Audit the payment module', timestamp: 1 }, status: turnStatus }, + round: { id: 'round', turnId: 'turn', roundIndex: 0, timestamp: 2, status: turnStatus }, + item: { type: 'tool', data: { id: this.taskCallId, orderIndex: 0, timestamp: 3, + toolName: 'Task', startTime: 3, status, + subagentSessionId: this.childSessionId, + toolCall: { id: this.taskCallId, input } } } + }; + return { session_id: 'preview', event: 'session-record', payload }; + } + + private childRecord(id: string, type: string, order: number, revision: number, + content: string, toolName: string, status: string): HostSessionEvent { + const input: PreviewReadInput = { path: 'src/payments.ts' }; + const payload: SessionRecord = { + sessionId: 'preview', id: `item/${id}`, revision, + turn: { turnId: 'turn', turnIndex: 0, sessionId: 'preview', timestamp: 1, + userMessage: { id: 'user', content: 'Audit the payment module', timestamp: 1 }, status: 'inprogress' }, + round: { id: 'round', turnId: 'turn', roundIndex: 0, timestamp: 2, status: 'inprogress' }, + item: { type, data: { id, orderIndex: order, timestamp: 3, content, + subagentSessionId: this.childSessionId, + toolName, toolCall: { id, input }, status } } + }; + return { session_id: 'preview', event: 'session-record', payload }; + } + + private publish(): void { + const messages = this.reducer.messages(); + const active = messages.find((message: ChatMessage) => message.role === 'assistant' && message.status === 'active'); + this.timeline.applySnapshot({ sessionId: 'preview', cursor: { + pollVersion: 0, knownMessageCount: messages.length, knownModelCatalogVersion: 0 + }, changed: true, title: '', sessionState: active ? 'active' : 'idle', newMessages: [], + messageSnapshot: messages.filter((message: ChatMessage) => message !== active), activeTurn: active, + shouldSyncAfterTurnEnded: false, historyRewritten: false, completedTurnId: '' }); + this.rows = this.rowStore.reconcile(this.timeline.project(false)); + this.revision++; + const state = this.timeline.snapshot(); + this.active = state.activeTurn !== undefined; + this.summary = this.summarize(state.activeTurn || state.persistedMessages + .filter((message: ChatMessage) => message.role === 'assistant') + .pop()); + } + + /** + * The branch the bubble would draw, read through the production grouping + * policy so the line states what is on screen rather than what was published. + */ + private summarize(answer?: ChatMessage): string { + if (!answer) { + return 'step=0 children=0 phase=none'; + } + const message: ConversationUiMessage = toConversationUiMessage(answer); + const groups: StructuredRenderGroup[] = ChatMessageStructurePolicy.structuredGroups( + message.items || [], 'item', (message.status || '') === 'active', message.status || 'done'); + const card = groups + .filter((group: StructuredRenderGroup) => group.type === 'item' && group.items.length === 1) + .map((group: StructuredRenderGroup) => group.items[0]) + .find((entry: ConversationUiMessageItem) => !!entry.tool && entry.tool.name === 'Task'); + const children = card && card.subItems ? card.subItems.length : 0; + return `step=${this.step} children=${children} phase=${message.status || 'done'}`; + } + + private next(): void { + this.step++; + if (this.step === 1) { + this.reducer.apply(this.childRecord('child-think', 'thinking', 1, 2, + 'Reading the payment module', 'Read', 'running')); + } + if (this.step === 2) { + this.reducer.apply(this.childRecord('child-read', 'tool', 2, 3, + '', 'Read', 'completed')); + } + if (this.step === 3) { + this.reducer.apply(this.childRecord('child-text', 'text', 3, 4, + 'Found no hard-coded keys', 'Read', 'completed')); + } + if (this.step === 4) { + this.reducer.apply(this.childRecord('child-grep', 'tool', 4, 5, + '', 'Grep', 'running')); + } + if (this.step === 5) { + this.reducer.apply(this.childRecord('child-grep', 'tool', 4, 6, + '', 'Grep', 'completed')); + } + if (this.step === 6) { + this.reducer.apply(this.taskRecord(7, 'completed', 'completed')); + } + this.publish(); + } + + build() { + Column({ space: 8 }) { + Button('Next step').id('subagent-next').onClick(() => { this.next(); }) + Text(this.summary).id('subagent-summary').fontColor(INK) + Row({ space: 8 }) { + Button('Host offline').id('subagent-offline').onClick(() => { this.connectionState = 'reconnecting'; }) + Button('Host returned').id('subagent-returned').onClick(() => { + this.connectionState = 'connected'; + this.publish(); + }) + } + Text(`${this.connectionState} stops=${this.stopRequests}`).id('subagent-host-state').fontColor(INK) + ChatTimeline({ timelineItems: this.rows, timelineRevision: this.revision, + connectionState: this.connectionState, isBusy: this.active }).layoutWeight(1) + ComposerBar({ canStop: this.active, connectionState: this.connectionState, + onStop: () => { this.stopRequests++; } }) + }.width('100%').height('100%').backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets index 5d4a6c4926..6269eb250f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets @@ -36,12 +36,16 @@ export class ChatComposerPolicy { } /** - * Whether voice needs its secondary slot inside the input field. + * Whether the draft earns the supplemental voice slot beside the primary one. * - * The primary slot belongs to Send as soon as the draft carries anything, - * but dictation is still an input method at that point. Keeping the inline - * affordance mounted while busy avoids layout movement; the component dims - * it until recording can start again. + * The primary slot belongs to Send as soon as the draft carries anything, but + * dictation is still an input method at that point, so a draft keeps a second + * way into it. That slot is asked for by the expanded action row alone: the + * collapsed field is one line tall and the draft being composed has the + * stronger claim on that room, so `ComposerBar` does not mount it inside the + * field in any state, and a collapsed draft shows the primary action only. + * Keeping the expanded affordance mounted while busy avoids layout movement; + * the component dims it until recording can start again. */ static shouldShowSupplementalVoice( text: string, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteUiState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteUiState.ets index e7649b91e8..08965cabd0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteUiState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteUiState.ets @@ -189,6 +189,13 @@ export class RemoteUiState { return sessionModelId; } if (currentSelectedModelId.length > 0) { + // A semantic selector stays semantic while the host can still resolve it, + // so the composer keeps showing the role that will actually run. The + // canonical value is returned, not the padded one that came in. + const semanticModelId = RemoteUiState.resolveSemanticModelId(catalog, currentSelectedModelId); + if (semanticModelId.length > 0) { + return semanticModelId; + } const exists = catalog.models.some((model: RemoteModelConfig) => { return model.id === currentSelectedModelId && model.enabled; }); @@ -199,6 +206,39 @@ export class RemoteUiState { return catalog.default_models.primary || catalog.default_models.fast || currentSelectedModelId; } + /** + * Keeps a semantic selector (`primary` / `fast`) only while it resolves to an + * enabled model, mirroring the host's own selection semantics including the + * fast-to-primary fallback. Returns an empty string for anything else. + * + * This repeats `ConversationModelPresentationPolicy.resolveModel` because the + * service layer may not import the page policy layer. Both sides trim, so a + * padded selector or default resolves the same way in either one. + */ + private static resolveSemanticModelId(catalog: RemoteModelCatalog, modelId: string): string { + const value = (modelId || '').trim(); + const primary = (catalog.default_models.primary || '').trim(); + if (value === 'primary') { + return RemoteUiState.hasEnabledModel(catalog, primary) ? value : ''; + } + if (value === 'fast') { + const fast = (catalog.default_models.fast || '').trim(); + if (RemoteUiState.hasEnabledModel(catalog, fast)) { + return value; + } + return RemoteUiState.hasEnabledModel(catalog, primary) ? value : ''; + } + return ''; + } + + private static hasEnabledModel(catalog: RemoteModelCatalog, modelId: string): boolean { + const value = (modelId || '').trim(); + if (value.length === 0) { + return false; + } + return catalog.models.some((model: RemoteModelConfig) => model.id === value && model.enabled); + } + static seedMessage(title: string): ChatMessage { return { id: `system-${Date.now()}`, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets index 53dc37dca1..ac9c0c684c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets @@ -1,8 +1,35 @@ import { Context } from '@kit.AbilityKit'; -import { window } from '@kit.ArkUI'; +import { UIContext, window } from '@kit.ArkUI'; +import { RemoteLogger } from './RemoteLogger'; + +/** + * How much of the window the app has to keep clear of its own content, in px. + * + * Both numbers are read rather than assumed. The shell runs in immersive layout, + * so the page draws from the top of the window and the status bar is the app's + * own inset to reserve; the bottom strip is the navigation indicator, until the + * soft keyboard takes its place. + */ +export interface WindowInsets { + top: number; + bottom: number; +} + +/** One read of the insets, plus the listener that keeps them current. */ +export type WindowInsetListener = (insets: WindowInsets) => void; +export type WindowInsetSubscription = () => void; /** Platform window adapter for surfaces that need to draw behind system bars. */ export class WindowSystemBarService { + /** + * Immersive layout for the whole shell, not one page: the transcript has to be + * able to reach the bottom edge of the screen, which only exists in the page + * area once the window stops reserving the navigation-bar strip. + * + * Immersive layout also moves the page's own origin to the top of the window, + * so every page that draws chrome against an edge reads the insets below + * instead of assuming the framework avoided the bars for it. + */ static async setLayoutFullScreen(context: Context, enabled: boolean): Promise { try { const appWindow = await window.getLastWindow(context); @@ -20,4 +47,117 @@ export class WindowSystemBarService { return 0; } } + + /** + * Reads the insets once and re-reads them on every avoid-area change — folding, + * rotation and the soft keyboard all move the avoid areas without recreating + * the page, and a stale number either clips the composer or leaves the + * transcript reserving space nothing is drawn in. + * + * The returned function unregisters the listener. + */ + static async observeInsets(context: Context, onChange: WindowInsetListener): Promise { + try { + const appWindow = await window.getLastWindow(context); + const read: () => void = (): void => { onChange(WindowSystemBarService.insetsOf(appWindow)); }; + const onAvoidAreaChange: (data: window.AvoidAreaOptions) => void = (_data: window.AvoidAreaOptions): void => { + read(); + }; + const subscription: WindowInsetSubscription = (): void => { + appWindow.off('avoidAreaChange', onAvoidAreaChange); + }; + appWindow.on('avoidAreaChange', onAvoidAreaChange); + read(); + return subscription; + } catch (_err) { + onChange({ top: 0, bottom: 0 }); + return (): void => {}; + } + } + + private static insetsOf(appWindow: window.Window): WindowInsets { + const top = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height; + // The keyboard covers the navigation indicator while it is up, and + // `KeyboardAvoidMode.RESIZE` has already lifted the page above it, so the + // bottom strip belongs to the keyboard for as long as it is visible. + const keyboard = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_KEYBOARD).bottomRect.height; + if (keyboard > 0) { + return { top, bottom: 0 }; + } + const navigation = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR).bottomRect.height; + if (navigation > 0) { + return { top, bottom: navigation }; + } + // Some builds fold the navigation bar into the system avoid area instead of + // reporting an indicator of its own. + return { top, bottom: appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).bottomRect.height }; + } +} + +/** + * One component's live view of the window insets, in vp. + * + * The shell is immersive, so every surface that puts chrome against a screen + * edge reads these numbers instead of assuming the framework reserved the strip + * for it: the chat page's fade fills the bottom edge, and the interactive rows + * of every other page — drawers, sheets, covers, galleries — keep clear of it. + * They mount and unmount independently, so the binding owns the subscription + * rather than leaving each page to juggle one, and it holds vp because padding, + * margins and radii in this app are vp. + */ +@ObservedV2 +export class WindowInsetsBinding { + @Trace top: number = 0; + @Trace bottom: number = 0; + private subscription?: WindowInsetSubscription; + private disposed: boolean = false; + + bind(uiContext: UIContext, context: Context): void { + this.disposed = false; + void WindowSystemBarService.observeInsets(context, (insets: WindowInsets): void => { + if (this.disposed) { + return; + } + this.top = uiContext.px2vp(insets.top); + this.bottom = uiContext.px2vp(insets.bottom); + RemoteLogger.info(`window insets top=${this.top} bottom=${this.bottom}`); + }).then((subscription: WindowInsetSubscription): void => { + // The page can disappear before the window call resolves; releasing here + // keeps a listener from outliving the component that asked for it. + if (this.disposed) { + subscription(); + return; + } + this.subscription = subscription; + }); + } + + unbind(): void { + this.disposed = true; + if (this.subscription) { + this.subscription(); + this.subscription = undefined; + } + } + + /** + * The padding a bottom-anchored content layer needs: the design's own spacing + * where it already clears the strip, and the strip itself where it does not. + * The strip is a device fact, so a page never hard-codes it. + */ + bottomPadding(designSpacing: number): number { + return Math.max(designSpacing, this.bottom); + } + + /** + * The tail spacer a scrolling surface appends after its last row: the strip + * itself plus the design's own breathing. The viewport then runs to the + * screen edge — rows roll under the navigation indicator while they are + * still moving — while the last row still comes to rest above the bar. + * Padding on the scrolling container cannot do that: it shrinks the viewport + * and leaves a band of bare page colour under the bar instead. + */ + tailSpacing(designSpacing: number): number { + return this.bottom + designSpacing; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets index 1acb90f914..7dda25bc74 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -698,12 +698,16 @@ export default function conversationPresentationUnitTest() { expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertTrue(); }); - it('keeps voice reachable beside Send once a draft has content', 0, () => { + it('keeps voice reachable beside Send while a draft holds the collapsed field', 0, () => { expect(ChatComposerPolicy.shouldShowSupplementalVoice('继续补充', 0, false, true)).assertTrue(); expect(ChatComposerPolicy.shouldShowSupplementalVoice('', 1, false, true)).assertTrue(); expect(ChatComposerPolicy.shouldShowSupplementalVoice('', 0, false, true)).assertFalse(); expect(ChatComposerPolicy.shouldShowSupplementalVoice('继续补充', 0, true, true)).assertFalse(); expect(ChatComposerPolicy.shouldShowSupplementalVoice('继续补充', 0, false, false)).assertFalse(); + // The slot is the draft's second way into dictation, never its only one: + // an empty field still reaches the microphone from the primary slot. + expect(ChatComposerPolicy.primaryAction('', 0, false, false, false, true, true, 'connected', true)) + .assertEqual(ComposerPrimaryAction.Voice); expect(ChatComposerPolicy.canUseSupplementalVoice(false)).assertTrue(); expect(ChatComposerPolicy.canUseSupplementalVoice(true)).assertFalse(); }); diff --git a/src/apps/mobile/harmonyos/tools/tests/composer-content-affordance.test.cjs b/src/apps/mobile/harmonyos/tools/tests/composer-content-affordance.test.cjs new file mode 100644 index 0000000000..11e7cd12f2 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/composer-content-affordance.test.cjs @@ -0,0 +1,118 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +function load(relativePath) { + const source = fs.readFileSync(path.join(__dirname, '../..', relativePath), 'utf8'); + const js = ts.transpileModule(source, { + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS} + }).outputText; + const exported = {}; + new Function('require', 'exports', js)(() => ({}), exported); + return exported; +} + +const {ChatComposerPolicy: Policy, ComposerPrimaryAction} = load('entry/src/main/ets/services/ChatComposerPolicy.ets'); +const composerSource = fs.readFileSync( + path.join(__dirname, '../../entry/src/main/ets/pages/components/ComposerBar.ets'), 'utf8'); + +// The composer input is the TextArea in InputField(). Slicing from its +// constructor to its first handler keeps these assertions off the model rows and +// role rows, which legitimately ellipsize their own labels. +function composerInputField(source) { + const start = source.indexOf('TextArea({ placeholder: this.inputPlaceholder()'); + assert.notEqual(start, -1, 'the composer input must stay a TextArea'); + const end = source.indexOf('.onChange(', start); + assert.notEqual(end, -1, 'the composer input must keep its onChange handler'); + return source.slice(start, end); +} + +// Reads one chained call out of the field so the assertions survive rewrapping. +function call(source, name) { + const start = source.indexOf(`.${name}(`); + assert.notEqual(start, -1, `the composer input must keep its .${name}() call`); + let depth = 0; + for (let index = start + name.length + 1; index < source.length; index++) { + if (source[index] === '(') { + depth += 1; + } else if (source[index] === ')') { + depth -= 1; + if (depth === 0) { + return source.slice(start, index + 1).replace(/\s+/g, ' '); + } + } + } + throw new Error(`unbalanced .${name}() call`); +} + +// Reads one @Builder out of the component, so an assertion can name the row it +// is about instead of counting occurrences in the whole file. +function builderBody(source, name) { + const start = source.indexOf(`\n ${name}() {`); + assert.notEqual(start, -1, `the composer must keep its ${name}() builder`); + const end = source.indexOf('\n @Builder', start); + assert.notEqual(end, -1, `${name}() must be followed by another builder`); + return source.slice(start, end); +} + +function primaryAction(text, attachments, busy, listening, turnRunning) { + return Policy.primaryAction(text, attachments, busy, listening, turnRunning, true, true, 'connected', true); +} + +test('a draft still earns the supplemental microphone beside Send', () => { + // The expanded action row is where a draft keeps its second way into + // dictation: any text or any image asks for the slot, while an empty field + // does not need it and a listening or voice-less surface must not show it. + assert.equal(Policy.shouldShowSupplementalVoice('继续补充', 0, false, true), true); + assert.equal(Policy.shouldShowSupplementalVoice(' ', 0, false, true), false); + assert.equal(Policy.shouldShowSupplementalVoice('', 1, false, true), true); + assert.equal(Policy.shouldShowSupplementalVoice('继续补充', 0, true, true), false); + assert.equal(Policy.shouldShowSupplementalVoice('继续补充', 0, false, false), false); +}); + +test('an empty field keeps reaching dictation from the primary slot', () => { + // The pending slot is never the only way in; that is what the empty field's + // primary action provides. + assert.equal(primaryAction('', 0, false, false, false), ComposerPrimaryAction.Voice); + assert.equal(primaryAction('', 0, true, false, false), ComposerPrimaryAction.VoiceBlocked); + assert.equal(primaryAction('继续补充', 0, false, false, false), ComposerPrimaryAction.Send); + assert.equal(primaryAction('', 1, false, false, false), ComposerPrimaryAction.Send); + assert.equal(primaryAction('', 0, false, false, true), ComposerPrimaryAction.Stop); +}); + +test('the collapsed input truncates with an ellipsis instead of scrolling', () => { + const field = composerInputField(composerSource); + assert.equal(call(field, 'textOverflow'), + '.textOverflow(this.isComposerExpanded() ? TextOverflow.Clip : TextOverflow.Ellipsis)'); + assert.equal(call(field, 'maxLines'), + '.maxLines(this.isComposerExpanded() ? 4 : 1, ' + + '{ overflowMode: this.isComposerExpanded() ? MaxLinesMode.SCROLL : MaxLinesMode.CLIP })'); +}); + +test('a collapsed draft keeps the field to itself', () => { + // The collapsed field is one line tall and the draft has the stronger claim + // on it, so nothing inside the field may mount a second microphone. Without + // this the restored policy would put one back beside the truncated text. + const field = builderBody(composerSource, 'InputField'); + assert.equal(field.indexOf('this.SupplementalVoiceButton()'), -1, + 'the input field must not render the supplemental voice slot'); + + const sites = [...composerSource.matchAll(/this\.SupplementalVoiceButton\(\)/g)]; + assert.equal(sites.length, 1, + 'the expanded action row is the only supplemental voice render site'); +}); + +test('the expanded action row keeps the supplemental microphone behind the policy', () => { + const composer = builderBody(composerSource, 'AdaptiveComposer'); + const expanded = composer.indexOf('if (this.isComposerExpanded()) {'); + assert.notEqual(expanded, -1, 'the composer must keep its expanded action row'); + const row = composer.slice(expanded); + const site = row.indexOf('this.SupplementalVoiceButton()'); + assert.notEqual(site, -1, + 'the expanded action row must render the supplemental voice slot once the draft has content'); + assert.match(row.slice(Math.max(0, site - 200), site), + /if \(this\.shouldShowSupplementalVoice\(\)/, + 'the expanded supplemental voice render site must stay gated by the composer policy'); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/file-visibility.test.cjs b/src/apps/mobile/harmonyos/tools/tests/file-visibility.test.cjs new file mode 100644 index 0000000000..3441073bd4 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/file-visibility.test.cjs @@ -0,0 +1,533 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const ROOT = path.join(__dirname, '../../entry/src/main/ets'); +const cache = new Map(); + +/** + * The two platform primitives the file chain really calls. + * + * `Encoding.ets` detaches bytes with `new Uint8Array(value.buffer)` and expects + * that buffer to hold only the value's own bytes, so the shim hands back an + * exactly sized ArrayBuffer instead of Node's pooled Buffer. A default import of + * a kit is compiled to `kit.default.member`, so each stub answers both shapes. + */ +function platformStub(module) { + return Object.assign({ default: module }, module); +} + +const PLATFORM = { + '@ohos.buffer': platformStub({ + from(value, encoding) { + const bytes = typeof value === 'string' + ? Uint8Array.from(Buffer.from(value, encoding || 'utf8')) + : Uint8Array.from(value); + return { + buffer: bytes.buffer, + toString: (enc) => Buffer.from(bytes).toString(enc || 'utf8') + }; + } + }), + '@kit.PerformanceAnalysisKit': platformStub({ + hilog: { info() {}, warn() {}, error() {}, debug() {} } + }) +}; + +/** Loads one .ets module and resolves its relative imports from the source tree. */ +function load(relative) { + if (cache.has(relative)) return cache.get(relative); + const source = fs.readFileSync(path.join(ROOT, relative + '.ets'), 'utf8') + .replace(/@ObservedV2\s*/g, '').replace(/@Trace\s*/g, ''); + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText; + const exported = {}; + cache.set(relative, exported); + new Function('require', 'exports', js)(name => { + if (!name.startsWith('.')) return PLATFORM[name] || {}; + const target = path.relative(ROOT, path.resolve(path.join(ROOT, relative), '..', name)) + .split(path.sep).join('/'); + return fs.existsSync(path.join(ROOT, `${target}.ets`)) ? load(target) : {}; + }, exported); + return exported; +} + +const { MarkdownParser } = load('services/MarkdownParser'); +const { RemoteUiState } = load('services/RemoteUiState'); +const { FileTargetResolver, FileReferenceKind } = load('pages/policy/FileTargetResolver'); +const { MessageFileReferenceProjector } = load('pages/policy/MessageFileReferenceProjector'); +const { RemoteFilePreviewController } = load('pages/viewmodel/RemoteFilePreviewController'); +const { FilePreviewPolicy } = load('services/FilePreviewPolicy'); +const { FilePreviewPhase, FilePreviewRendererKind, FilePreviewState } = load('pages/state/FilePreviewState'); +const { FilePreviewTarget, FilePreviewTargetContext } = load('model/FilePreviewTarget'); +const { RemoteCommandFactory } = load('services/RemoteCommandFactory'); +const { FilePreviewController } = load('pages/viewmodel/FilePreviewController'); + +const SESSION = 'session-1'; +const WORKSPACE = '/home/user/project'; + +/** + * Writes the link shapes the host actually produces into an assistant answer. + * + * `remote_file_delivery.rs:16` tells the model to write + * `[report.md](computer://artifacts/report.md)`; the bare form is what the model + * writes when it names the file again in prose. Four references is the whole + * card budget of one message, so this answer is exactly at the cap. + */ +const ANSWER = [ + 'Generated the brand asset and the report.', + '', + '- [openbitfun-wordmark.png](computer://png/openbitfun-wordmark.png)', + '- [REPORT.md](computer://artifacts/REPORT.md)', + '- [bundle.zip](computer://artifacts/bundle.zip)', + '', + 'The plain-text log is at computer://artifacts/run.log and the raw export is png/openbitfun-wordmark.png.', + '', + '' +].join('\n'); + +function projections(source, limit) { + return limit === undefined + ? MessageFileReferenceProjector.project(source) + : MessageFileReferenceProjector.project(source, limit); +} + +function resolve(reference, label = '') { + return FileTargetResolver.resolve( + reference, + label, + new FilePreviewTargetContext(SESSION, WORKSPACE, 1) + ); +} + +/* ------------------------------------------------------------------ stage 1 */ + +test('stage 1: the parser reads explicit links and bare file references as links', () => { + const blocks = MarkdownParser.parse(ANSWER); + const links = blocks.flatMap(block => block.inlines.concat( + block.items.flatMap(item => item.inlines) + )).filter(inline => inline.type === 'link'); + + const urls = links.map(link => link.url); + assert.ok(urls.includes('computer://png/openbitfun-wordmark.png'), + 'an explicit markdown link keeps its computer:// url'); + assert.ok(urls.includes('computer://artifacts/run.log'), + 'a bare computer:// reference in prose is an autolink'); + assert.ok(urls.includes('computer://artifacts/diagram.svg'), + 'an angle-bracket autolink keeps its computer:// url'); + assert.ok(!urls.includes('png/photo.jpg'), + 'a bare relative file name carries no scheme, so it is plain text'); +}); + +/* ------------------------------------------------------------------ stage 2 */ + +test('stage 2: a computer:// link becomes a RemoteWorkspaceFile card', () => { + const reference = projections('[openbitfun-wordmark.png](computer://png/openbitfun-wordmark.png)')[0]; + assert.ok(reference, 'the linked png is projected'); + assert.equal(reference.path, 'computer://png/openbitfun-wordmark.png'); + assert.equal(reference.remotePath, 'png/openbitfun-wordmark.png'); + assert.equal(reference.label, 'openbitfun-wordmark.png'); +}); + +test('stage 2: every file type the assistant links earns a card', () => { + const byPath = new Map(projections(ANSWER).map(item => [item.remotePath, item])); + for (const remotePath of [ + 'png/openbitfun-wordmark.png', + 'artifacts/REPORT.md', + 'artifacts/bundle.zip', + 'artifacts/run.log' + ]) { + assert.ok(byPath.has(remotePath), `a card is projected for ${remotePath}`); + } + assert.equal(byPath.get('png/openbitfun-wordmark.png').label, 'openbitfun-wordmark.png', + 'the card is named by the link label'); +}); + +test('stage 2: the card budget is bounded, and the surplus keeps its link', () => { + const source = '- [a.png](computer://artifacts/a.png)\n- [b.md](computer://artifacts/b.md)\n' + + '- [c.zip](computer://artifacts/c.zip)\n- [d.txt](computer://artifacts/d.txt)\n' + + '- [e.png](computer://artifacts/e.png)'; + const references = projections(source); + assert.equal(references.length, 4, 'one message draws at most four cards'); + assert.deepEqual(references.map(item => item.remotePath), [ + 'artifacts/a.png', 'artifacts/b.md', 'artifacts/c.zip', 'artifacts/d.txt' + ]); + assert.equal(projections(source, 1).length, 1, 'the limit is a parameter, not a constant of the answer'); +}); + +test('stage 2: a relative link is a card only for an output-shaped extension', () => { + // A bare path in prose carries no scheme, so it is never resolved (see below). + // A *relative link target* is different: the projector accepts it when its + // extension is one of the turn's output shapes, which is the same rule the + // shared Android/iOS projector applies (MessageFileReferences.kt:38). + assert.equal(projections('[Preview](artifacts/preview.png)')[0].remotePath, 'artifacts/preview.png', + 'a relative image link is a workspace file'); + assert.equal(projections('[Bundle](artifacts/bundle.zip)')[0].remotePath, 'artifacts/bundle.zip'); + assert.equal(projections('[Report](artifacts/REPORT.md)').length, 0, + 'a relative markdown link is not an output shape, so it stays prose'); + assert.equal(projections('[Log](artifacts/run.log)').length, 0, + 'a relative log link is not an output shape either'); +}); + +test('stage 2: the card path and card label stay in step with the resolver', () => { + for (const reference of [ + 'computer://png/openbitfun-wordmark.png', + 'computer://artifacts/REPORT.md', + 'file:///home/user/project/artifacts/notes.txt', + 'computer://D:/work/project/artifacts/notes.txt' + ]) { + const resolution = resolve(reference, 'label.md'); + assert.equal(resolution.kind, FileReferenceKind.RemoteWorkspaceFile, `${reference} resolves`); + assert.equal(resolution.target.rawReference, reference, 'the raw reference is preserved'); + assert.equal(resolution.target.remotePath, RemoteUiState.normalizeRemoteFilePath(reference), + 'the resolved path is the normalized reference'); + } +}); + +test('stage 2: a scheme-less relative path is not a card', () => { + assert.equal(projections('the photo is png/photo.jpg').length, 0, + 'a bare file name cannot be resolved against the host, so it stays plain text'); +}); + +/* ------------------------------------------------------------------ stage 3 */ + +test('stage 3: the read command carries the normalized workspace-relative path', () => { + const target = resolve('computer://png/openbitfun-wordmark.png', 'openbitfun-wordmark.png').target; + const info = RemoteCommandFactory.getFileInfo(target.remotePath, target.sessionId); + assert.equal(info.cmd, 'get_file_info'); + assert.equal(info.path, 'png/openbitfun-wordmark.png'); + assert.equal(info.session_id, SESSION); + + const chunk = RemoteCommandFactory.readFileChunk(target.remotePath, 0, 1024, target.sessionId); + assert.equal(chunk.cmd, 'read_file_chunk'); + assert.equal(chunk.path, 'png/openbitfun-wordmark.png'); + assert.equal(chunk.session_id, SESSION); +}); + +test('stage 3: the preview target keeps the session the file must be read from', () => { + const target = resolve('computer://png/openbitfun-wordmark.png').target; + assert.ok(target.isValid(), 'a target with a session and a path is openable'); + const anonymous = resolve('computer://png/x.png').target; + assert.equal(anonymous.sessionId, SESSION, 'the resolver takes the session from its context'); +}); + +test('stage 3: normalizeRemoteFilePath only strips the schemes the host sends', () => { + assert.equal(RemoteUiState.normalizeRemoteFilePath('computer://png/a.png'), 'png/a.png'); + assert.equal(RemoteUiState.normalizeRemoteFilePath('file:///home/u/a.png'), '/home/u/a.png'); + assert.equal(RemoteUiState.normalizeRemoteFilePath('computer://D:/work/a.png'), 'D:/work/a.png'); + assert.equal(RemoteUiState.normalizeRemoteFilePath('png/a.png'), 'png/a.png'); +}); + +/* ------------------------------------------------------------------ stage 4 */ + +const MIME = { + png: 'image/png', + jpg: 'image/jpeg', + md: 'text/markdown', + txt: 'text/plain', + zip: 'application/zip', + pdf: 'application/pdf' +}; + +function nameOf(remotePath) { + return remotePath.split('/').pop(); +} + +/** A host that answers exactly like `remote_connect.rs` does for a real file. */ +class FakeFileHost { + constructor(files) { + this.files = files; + this.commands = []; + } + async getFileInfo(path, sessionId) { + this.commands.push({ cmd: 'get_file_info', path, sessionId }); + const file = this.files[path]; + if (!file) throw new Error(`File not found: ${path}`); + return { name: nameOf(path), size: file.size, mimeType: MIME[file.kind] }; + } + async readFileChunk(path, offset, limit, sessionId) { + this.commands.push({ cmd: 'read_file_chunk', path, offset, limit, sessionId }); + const file = this.files[path]; + if (!file) throw new Error(`File not found: ${path}`); + const end = Math.min(file.size, offset + limit); + return { + name: nameOf(path), + contentBase64: Buffer.from(file.bytes.slice(offset, end)).toString('base64'), + revision: '1:1', + offset, + chunkSize: end - offset, + totalSize: file.size, + mimeType: MIME[file.kind] + }; + } + async readFile(path, sessionId, onProgress, maxBytes) { + const file = this.files[path]; + if (!file) throw new Error(`File not found: ${path}`); + if (maxBytes !== undefined && file.size > maxBytes) throw new Error('File too large for preview.'); + const info = await this.getFileInfo(path, sessionId); + return { + name: info.name, + mimeType: info.mimeType, + size: info.size, + contentBase64: Buffer.from(file.bytes).toString('base64') + }; + } + async streamFile(path, sessionId, onChunk) { + const file = this.files[path]; + if (!file) throw new Error(`File not found: ${path}`); + const info = await this.getFileInfo(path, sessionId); + await onChunk(new Uint8Array(file.bytes), { name: info.name, size: info.size, mimeType: info.mimeType }); + return { name: info.name, size: info.size, mimeType: info.mimeType }; + } +} + +const PNG_BYTES = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', + 'base64' +); + +function fixtureFiles() { + return { + 'png/openbitfun-wordmark.png': { kind: 'png', size: PNG_BYTES.length, bytes: PNG_BYTES }, + 'png/photo.jpg': { kind: 'jpg', size: PNG_BYTES.length, bytes: PNG_BYTES }, + 'artifacts/REPORT.md': { kind: 'md', size: 21, bytes: Buffer.from('# Report\nhello md\n') }, + 'artifacts/notes.txt': { kind: 'txt', size: 11, bytes: Buffer.from('plain notes') }, + 'artifacts/bundle.zip': { kind: 'zip', size: 8, bytes: Buffer.from([0x50, 0x4b, 3, 4, 0, 0, 0, 0]) }, + 'artifacts/paper.pdf': { kind: 'pdf', size: 8, bytes: Buffer.from('%PDF-1.4') } + }; +} + +async function preview(path, label) { + const host = new FakeFileHost(fixtureFiles()); + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(host, state, () => true, () => 1); + await controller.open(resolve(path, label).target); + return { state, host }; +} + +function dataSource(state) { + return `data:${state.mimeType};base64,${state.contentBase64}`; +} + +test('stage 4: an image file reaches the Ready image renderer with usable pixels', async () => { + for (const file of ['computer://png/openbitfun-wordmark.png', 'computer://png/photo.jpg']) { + const { state } = await preview(file); + assert.equal(state.phase, FilePreviewPhase.Ready, `${file} is ready`); + assert.equal(state.rendererKind, FilePreviewRendererKind.Image, `${file} renders as an image`); + assert.equal(state.fileName, file.split('/').pop()); + assert.equal(state.contentBase64, PNG_BYTES.toString('base64'), 'the pixels survive base64'); + assert.equal(dataSource(state), `data:${state.mimeType};base64,${PNG_BYTES.toString('base64')}`); + assert.match(dataSource(state), /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/=]+$/, + 'the data source is a decodable image url'); + assert.deepEqual(Array.from(Buffer.from(state.contentBase64, 'base64')), Array.from(PNG_BYTES)); + } +}); + +test('stage 4: markdown and plain text reach the text renderers', async () => { + const markdown = await preview('computer://artifacts/REPORT.md'); + assert.equal(markdown.state.phase, FilePreviewPhase.Ready); + assert.equal(markdown.state.rendererKind, FilePreviewRendererKind.Markdown); + assert.equal(markdown.state.textContent, '# Report\nhello md\n'); + + const text = await preview('computer://artifacts/notes.txt'); + assert.equal(text.state.phase, FilePreviewPhase.Ready); + assert.equal(text.state.rendererKind, FilePreviewRendererKind.Text); + assert.equal(text.state.textContent, 'plain notes'); +}); + +test('stage 4: a type with no renderer is Unsupported, not an error', async () => { + for (const file of ['computer://artifacts/bundle.zip', 'computer://artifacts/paper.pdf']) { + const { state } = await preview(file); + assert.equal(state.phase, FilePreviewPhase.Unsupported, `${file} is honestly unsupported`); + assert.equal(state.rendererKind, FilePreviewRendererKind.Unsupported); + assert.notEqual(state.phase, FilePreviewPhase.Error, 'unsupported must not read as a failure'); + } +}); + +test('stage 4: a missing file is an error the ui can explain', async () => { + const { state } = await preview('computer://artifacts/missing.png'); + assert.equal(state.phase, FilePreviewPhase.Error); + assert.ok(state.errorText.length > 0); +}); + +test('stage 4: every common image extension the host can serve renders as an image', () => { + const extensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'ico']; + for (const extension of extensions) { + assert.equal(RemoteFilePreviewController.rendererFor(`preview.${extension}`, `image/${extension}`), + FilePreviewRendererKind.Image, `.${extension} renders as an image`); + } + assert.equal(RemoteFilePreviewController.rendererFor('diagram.svg', 'image/svg+xml'), + FilePreviewRendererKind.Unsupported, 'svg stays on the text path rather than the image decoder'); +}); + +/* ------------------------------------------------------------------ stage 5 */ + +const { RemoteFileDownloadController } = load('services/RemoteFileDownloadController'); + +class FakeSaver { + constructor() { + this.chunks = []; + this.beginCalls = []; + this.finished = false; + this.aborted = false; + } + async begin(name) { this.beginCalls.push(name); } + async write(bytes) { this.chunks.push(Buffer.from(bytes)); } + async finish() { this.finished = true; return 'file://docs/download'; } + async abort() { this.aborted = true; } +} + +function downloader(host, saver) { + const statuses = []; + let text = ''; + const controller = new RemoteFileDownloadController( + host, + (_downloading, _downloaded, status) => statuses.push(status), + () => {}, + (value) => { text = value; }, + () => {}, + 2500, + { setTimeout: () => 1, clearTimeout: () => {} }, + saver + ); + return { controller, statuses, statusText: () => text }; +} + +test('stage 5: downloading an image streams real bytes and names the destination', async () => { + const host = new FakeFileHost(fixtureFiles()); + const saver = new FakeSaver(); + const { controller, statuses, statusText } = downloader(host, saver); + + await controller.download('computer://png/openbitfun-wordmark.png', SESSION, false, true); + + assert.deepEqual(saver.beginCalls, ['openbitfun-wordmark.png'], + 'the destination keeps the host file name'); + assert.equal(Buffer.concat(saver.chunks).toString('base64'), PNG_BYTES.toString('base64'), + 'the downloaded bytes are the real file'); + assert.ok(saver.finished, 'the destination is committed'); + assert.ok(!saver.aborted, 'no failure path ran'); + assert.ok(statuses.length > 0 && statusText().length > 0, 'the ui gets a status to show'); +}); + +test('stage 5: a bare relative path is refused instead of saving an empty file', async () => { + const host = new FakeFileHost(fixtureFiles()); + const saver = new FakeSaver(); + const { controller } = downloader(host, saver); + await controller.download(' ', SESSION, false, true); + assert.equal(saver.beginCalls.length, 0); + assert.equal(saver.finished, false); +}); + +test('stage 5: a host failure aborts the destination and reports it', async () => { + const host = new FakeFileHost(fixtureFiles()); + const saver = new FakeSaver(); + const { controller, statusText } = downloader(host, saver); + await controller.download('computer://artifacts/missing.png', SESSION, false, true); + assert.ok(saver.aborted, 'the partial destination is removed'); + assert.equal(saver.finished, false); + assert.ok(statusText().length > 0, 'the failure is reported to the ui'); +}); + +/* ------------------------------------------------------- surface wiring */ + +const CARD_GAPS = [ + ['pages/components/FileReferenceCard.ets', /this\.onDownload\(this\.path\)/, 'the card download action passes the raw reference'], + ['pages/components/FileReferenceCard.ets', /this\.onPreview\(this\.path, this\.label\)/, 'the card opens the preview with the raw reference'], + ['pages/components/ChatMessageContent.ets', /FileReferenceCard\(\{/, 'the message card list mounts the real card'], + ['pages/components/FilePreviewSurface.ets', /Image\(`data:\$\{this\.state\.mimeType\};base64,\$\{this\.state\.contentBase64\}`\)/, 'the image preview builds its data source from the loaded bytes'] +]; + +test('the surface wiring that carries a card tap to the preview is intact', () => { + for (const [file, pattern, expectation] of CARD_GAPS) { + const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); + assert.match(source, pattern, expectation); + } +}); + +/* --------------------------------------------------- end-to-end coherence */ + +const { ConversationIntentDispatcher } = load('pages/actions/ConversationIntentDispatcher'); +const { ConversationIntents, ConversationIntentType } = load('pages/actions/ConversationIntent'); +const { AppRoute } = load('pages/navigation/AppRouteContract'); + +async function settled(state) { + for (let tick = 0; tick < 20 && state.phase === FilePreviewPhase.Loading; tick++) { + await new Promise(resolve => setImmediate(resolve)); + } +} + +/** The real intent → dispatcher → controller → surface chain, transport faked. */ +function appChain() { + const state = new FilePreviewState(); + const statuses = []; + const controller = new FilePreviewController(new FakeFileHost(fixtureFiles()), state, { + remoteAvailable: () => true, + activeSession: () => ({ sessionId: SESSION, workspacePath: WORKSPACE }), + workspacePath: () => WORKSPACE, + openExternalLink: async () => true, + onGeneralStatus: (value) => statuses.push(value), + onRemoteStatus: (value) => statuses.push(value) + }); + const dispatcher = new ConversationIntentDispatcher({ + mailbox: async () => {}, + openSidebar: () => {}, + back: () => {}, + newRemoteSession: () => {}, + unsupported: () => {}, + stop: async () => {}, + loadOlder: async () => {}, + approve: async () => {}, + reject: async () => {}, + cancel: async () => {}, + answer: async () => {}, + rename: async () => {}, + copy: async () => {}, + retry: async () => {}, + selectModel: async () => {}, + pickImages: async () => {}, + removeImage: () => {}, + openFilePreview: (route, request) => controller.open(route, request), + downloadFile: () => {}, + buildPlan: async () => {}, + send: async () => {}, + voiceInput: async () => {}, + inputChanged: () => {} + }); + return { state, controller, dispatcher, statuses }; +} + +test('the whole chain agrees on one path from message text to a rendered image', async () => { + const reference = projections('[openbitfun-wordmark.png](computer://png/openbitfun-wordmark.png)')[0]; + const { state, dispatcher, statuses } = appChain(); + + dispatcher.dispatch(AppRoute.RemoteChat, ConversationIntents.openFilePreview(reference.path, reference.label)); + await settled(state); + + assert.equal(state.phase, FilePreviewPhase.Ready, 'the card tap reached the preview'); + assert.equal(state.rendererKind, FilePreviewRendererKind.Image); + assert.equal(state.fileName, 'openbitfun-wordmark.png'); + assert.equal(state.mimeType, 'image/png'); + assert.equal(state.contentBase64, PNG_BYTES.toString('base64'), 'the pixels on screen are the file'); + assert.equal(state.target.sessionId, SESSION, 'the read targeted the active session'); + assert.equal(state.target.remotePath, 'png/openbitfun-wordmark.png', 'and the workspace-relative path'); + assert.deepEqual(statuses, [], 'nothing fell back to a status message'); +}); + +test('the same intent outside the remote conversation says so instead of opening nothing', async () => { + const { dispatcher, statuses } = appChain(); + dispatcher.dispatch(AppRoute.RemoteHome, ConversationIntents.openFilePreview('computer://png/a.png', 'a.png')); + assert.equal(statuses.length, 1, 'the user is told the file surface is unavailable here'); +}); + +test('a download started from a card reaches the destination with the file bytes', async () => { + const host = new FakeFileHost(fixtureFiles()); + const saver = new FakeSaver(); + const { controller, statusText } = downloader(host, saver); + await controller.download('computer://artifacts/REPORT.md', SESSION, false, true); + assert.deepEqual(saver.beginCalls, ['REPORT.md']); + assert.equal(Buffer.concat(saver.chunks).toString('utf8'), '# Report\nhello md\n'); + assert.ok(statusText().length > 0); +}); \ No newline at end of file diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs new file mode 100644 index 0000000000..0244883fa1 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -0,0 +1,308 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +// Source-level assertions. The immersive bottom is a contract between the +// window (which runs full-screen), the chat page (whose fade owns the bottom +// edge), every other scrolling surface (whose viewport runs to the screen edge +// and whose content ends in a tail spacer) and each surface's fixed bottom +// controls (which keep the navigation bar clear themselves). None of that is +// visible in a unit test of a policy object, and all of it is exactly what a +// later edit is most likely to undo by accident, so it is asserted against the +// files that carry it. +function source(relativePath) { + return fs.readFileSync(path.join(__dirname, '../..', relativePath), 'utf8'); +} + +const entryAbility = source('entry/src/main/ets/entryability/EntryAbility.ets'); +const conversationView = source('entry/src/main/ets/pages/components/ConversationView.ets'); +const windowService = source('entry/src/main/ets/services/WindowSystemBarService.ets'); +const appSidebar = source('entry/src/main/ets/pages/components/AppSidebar.ets'); +const miniAppSurface = source('entry/src/main/ets/pages/components/MiniAppSurface.ets'); +const settingsSheet = source('entry/src/main/ets/pages/components/SettingsSheet.ets'); +const workspaceToolsPanel = source('entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets'); +const workspacePicker = source('entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets'); +const connectView = source('entry/src/main/ets/pages/components/ConnectView.ets'); +const filePreviewSurface = source('entry/src/main/ets/pages/components/FilePreviewSurface.ets'); +const remoteSessionList = source('entry/src/main/ets/pages/components/RemoteSessionList.ets'); +const sessionActionSurface = source('entry/src/main/ets/pages/components/SessionActionSurface.ets'); +const sessionDetailsView = source('entry/src/main/ets/pages/components/SessionDetailsView.ets'); +const conversationViewSettings = source('entry/src/main/ets/pages/components/ConversationViewSettings.ets'); +const welcomeHome = source('entry/src/main/ets/pages/components/WelcomeHome.ets'); + +// Reads one @Builder out of a component, so an assertion can name the layer it +// is about instead of counting matches in the whole file. +function builderBody(sourceText, name) { + const start = sourceText.indexOf(`\n private ${name}() {`); + assert.notEqual(start, -1, `${name}() must stay a builder of the component`); + const end = sourceText.indexOf('\n @Builder', start); + assert.notEqual(end, -1, `${name}() must be followed by another builder`); + return sourceText.slice(start, end); +} + +// Collapses a slice to one line so an assertion is about the call, not about +// where the formatter happened to wrap it. Comments drop out first: an +// explanation between a call's arguments is part of the source, not of the call. +function normalize(sourceText) { + return sourceText.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ').replace(/\s+/g, ' '); +} + +// Reads one chained call out of a slice so the assertions survive rewrapping. +function call(sourceText, name) { + const start = sourceText.indexOf(`.${name}(`); + assert.notEqual(start, -1, `the slice must keep its .${name}() call`); + let depth = 0; + for (let index = start + name.length + 1; index < sourceText.length; index++) { + if (sourceText[index] === '(') { + depth += 1; + } else if (sourceText[index] === ')') { + depth -= 1; + if (depth === 0) { + return sourceText.slice(start, index + 1).replace(/\s+/g, ' '); + } + } + } + throw new Error(`unbalanced .${name}() call`); +} + +test('the shell runs the window full-screen for every page', () => { + // Not the welcome page's private trick any more: the transcript can only reach + // the bottom edge of the screen if the window stops reserving the strip above + // it, and that has to hold while the chat page is the mounted page. + assert.match(entryAbility, /mainWindow\.setWindowLayoutFullScreen\(true\)/, + 'the ability must lay the window out full-screen'); + assert.equal(entryAbility.includes('setWindowLayoutFullScreen(false)'), false, + 'no code path may put the window back into a non-immersive layout'); + assert.equal(entryAbility.includes('updateWindowLayout'), false, + 'the welcome page must not switch the window layout any more'); +}); + +test('the navigation bar is transparent and the status bar keeps the page colour', () => { + // The fade is what should be visible where the navigation bar sits, and the + // header band keeps its opaque page colour so the top does not regress. + assert.match(entryAbility, /navigationBarColor: MobileDesignColors\.transparent\.light/, + 'the navigation bar must be transparent so the page bottom shows through'); + assert.match(entryAbility, /statusBarColor: background/, + 'the status bar must keep the page colour'); +}); + +test('the window inset service is the only place the strip is measured', () => { + assert.match(windowService, /export class WindowInsetsBinding/, + 'the insets must be shared through one binding'); + assert.match(windowService, /bottomPadding\(designSpacing: number\): number \{[\s\S]*?Math\.max\(designSpacing, this\.bottom\)/, + 'the binding must keep the design spacing wherever it already clears the strip'); + assert.match(windowService, /appWindow\.on\('avoidAreaChange'/, 'the binding must follow avoid-area changes'); + assert.match(windowService, /appWindow\.off\('avoidAreaChange'/, 'the binding must release its listener'); +}); + +test('the binding owns both strip numbers a surface needs', () => { + // bottomPadding is for a fixed control: the design's spacing, or the strip if + // the strip is larger. tailSpacing is for the end of a scrolling surface: the + // strip plus the design's own breathing, because the last row has to rest + // above the bar once the surface that runs under it stops scrolling. + assert.match(windowService, + /tailSpacing\(designSpacing: number\): number \{[\s\S]*?return this\.bottom \+ designSpacing;/, + 'the binding must expose the tail spacer a scrolling surface ends with'); +}); + +test('the chat page bottom layer reaches the screen edge and its composer does not move', () => { + const bottom = builderBody(conversationView, 'BottomOverlay'); + // The layer's box grows by the strip, so its own gradient fills it... + assert.equal(call(bottom, 'padding'), '.padding({ bottom: this.bottomSafeInset })'); + // ...it may paint into the strip even where the page area still stops above + // it, and it is named so a layout dump can be read against the pixels. + assert.equal(call(bottom, 'expandSafeArea'), + '.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])'); + assert.equal(call(bottom, 'id'), ".id('conversation-bottom-fade')"); + // The transcript borrows the layer's measured height, which is what lets the + // last message scroll above the fade instead of under the composer. + assert.match(bottom, /this\.bottomInset = newArea\.height as number;/, + 'the transcript inset must keep following the layer it is measured from'); +}); + +test('the chat page header band reserves the status bar itself', () => { + // The shell is immersive, so the page draws from the top of the window and the + // band is what has to keep the header out of the status bar. Its own box is + // the transcript's content start offset, so the two stay in step. + const top = builderBody(conversationView, 'TopOverlay'); + assert.equal(call(top, 'padding'), '.padding({ top: this.topSafeInset })'); + assert.match(top, /\.backgroundColor\(PAGE_BG_OVERLAY\)/, + 'the band that carries the inset must stay the one that paints the header'); +}); + +test('the settings sheet viewport reaches the screen edge and its rows end in a tail spacer', () => { + // The sheet is a bindSheet, which the framework does not inset while the + // window is immersive, so its own box decides where content can scroll. The + // root must not carry a bottom padding any more: that shrinks the viewport + // and leaves a band of bare page colour under the navigation bar. + const build = normalize(settingsSheet.slice( + settingsSheet.indexOf('build() {'), + settingsSheet.indexOf('@Builder', settingsSheet.indexOf('build() {')))); + assert.equal(/\.padding\(\{[^}]*bottom/.test(build), false, + 'the sheet root must not shrink its own viewport away from the screen edge'); + assert.match(normalize(settingsSheet), + /\.padding\(\{ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING, top: 22, bottom: this\.insets\.tailSpacing\(34\) \}\)/, + "the sheet's scrolling column must end in the strip plus its own breathing"); +}); + +test('the mini-app gallery viewport reaches the screen edge and its grid ends in a tail spacer', () => { + // The gallery container must not carry a bottom padding: the grid is the + // surface's bottom edge and its tiles have to roll under the navigation bar. + assert.match(normalize(miniAppSurface), + /\.padding\(\{ left: 16, right: 16 \}\)/, + 'the gallery container must keep only its horizontal padding'); + assert.equal(/\.padding\(\{[^}]*bottomPadding/.test(miniAppSurface), false, + 'the gallery must not shrink its viewport away from the screen edge'); + const grid = normalize(miniAppSurface.slice(miniAppSurface.indexOf('Grid() {'))); + assert.match(grid, /GridItem\(\) \{ Column\(\) \.width\('100%'\) \.height\(this\.insets\.tailSpacing\(24\)\) \}/, + 'the grid must end with a tail spacer row'); + assert.match(grid, /\.columnStart\(0\)/, + 'the tail spacer must start at the first column'); + assert.match(grid, /\.columnEnd\(this\.galleryColumns\(\) - 1\)/, + 'the tail spacer must span to the last column'); + assert.match(grid, /\.columnsTemplate\(this\.galleryColumnsTemplate\(\)\)/, + 'the grid template and the spacer span must stay one decision'); +}); + +test('the sidebar list scrolls under the bar and its floating footer keeps the strip clear', () => { + // The sidebar is a scrolling session list with a floating footer, which is + // the chat page's shape: the viewport runs to the panel's own bottom edge, + // the footer keeps its distance from that edge itself, and the list ends in + // a tail spacer so its last row rests above the navigation bar. + const content = normalize(builderBody(appSidebar, 'SidebarContent')); + assert.match(content, /\.padding\(\{ left: 20, right: 20, top: 0 \}\)/, + 'the panel root must not shrink its scrolling viewport'); + assert.match(content, /\.padding\(\{ bottom: this\.scrollTailPadding\(\) \}\)/, + 'the session list must end in a tail spacer'); + assert.match(content, /\.margin\(\{ bottom: this\.footerBottomPadding\(\) \}\)/, + 'the footer and its fade must keep the strip clear themselves'); + assert.match(normalize(appSidebar), + /private scrollTailPadding\(\): number \{[\s\S]*?\(this\.usesCompactFooter\(\) \? 84 : 120\) \+ this\.insets\.bottom;/, + 'the tail must clear the floating footer and the strip'); + assert.match(normalize(appSidebar), + /private footerBottomPadding\(\): number \{[\s\S]*?return this\.insets\.bottomPadding\(16\);/, + 'the footer must keep the design spacing wherever the strip is already clear'); +}); + +test('the workspace tools sheet scrolls under the bar and its fixed controls do not', () => { + // The tools sheet is a full-height bindSheet: its file list runs to the screen + // edge and ends in a tail spacer, while the terminal key row and the upload + // controls are fixed bottom controls that keep the strip clear themselves. + const build = normalize(workspaceToolsPanel.slice( + workspaceToolsPanel.indexOf('build() {'), + workspaceToolsPanel.indexOf('@Builder', workspaceToolsPanel.indexOf('build() {')))); + assert.equal(build.includes('.padding({ left: 16, right: 16, bottom'), false, + 'the panel root must not shrink its scrolling viewport'); + assert.match(normalize(workspaceToolsPanel), + /ListItem\(\) \{ Column\(\)\.width\('100%'\)\.height\(this\.insets\.tailSpacing\(16\)\) \}/, + 'the file list must end in a tail spacer'); + assert.match(normalize(workspaceToolsPanel), + /\.margin\(\{ bottom: this\.insets\.bottomPadding\(16\) \}\)/, + 'the fixed bottom controls must keep the strip clear'); + // The file action form is a FIT_CONTENT sheet: its buttons are the fixed + // controls at the screen edge, so the form's own bottom padding is what keeps + // them out of the strip. + assert.match(normalize(workspaceToolsPanel), + /\.padding\(\{ left: 20, right: 20, top: 20, bottom: this\.insets\.bottomPadding\(24\) \}\)/, + 'the file action form must keep its buttons out of the strip'); +}); + +test('the workspace tools editor keeps its card above the bar', () => { + // The editor is a WebView: its scrolling and padding live inside the web + // renderer, so the ArkUI tail spacer cannot reach its content and the card is + // fixed content instead — its bottom edge stops at the navigation bar's top, + // the same clearance the terminal key row keeps. + const editor = normalize(builderBody(workspaceToolsPanel, 'Editor')); + assert.match(editor, + /\.backgroundColor\(CARD\) \.margin\(\{ bottom: this\.insets\.bottomPadding\(16\) \}\)/, + 'the editor card must stop at the bar top instead of running under it'); +}); + +test('the remaining bottom sheets keep their content out of the bar', () => { + // The session action sheet is a fixed-height bottom sheet: its action rows + // are fixed controls, so the surface's bottom padding is strip-aware — the + // sheet host resolves it, because only the host knows which presentation + // sits at the screen edge. + assert.match(normalize(sessionActionSurface), + /\.padding\(\{ left: 16, right: 16, top: 10, bottom: this\.bottomPadding \}\)/, + 'the action surface must take its bottom padding from its host'); + assert.match(normalize(remoteSessionList), + /bottomPadding: presentation === SessionActionPresentation\.BottomSheet \? this\.insets\.bottomPadding\(18\) : 18/, + 'the bottom-sheet presentation must clear the strip, the popover must not grow'); + // The session details and view settings sheets are scrolling surfaces: their + // viewport runs to the screen edge and their content ends in a strip-aware + // tail, the same contract the settings sheet keeps. + assert.match(normalize(sessionDetailsView), + /\.padding\(\{ left: 20, right: 20, top: 8, bottom: this\.insets\.tailSpacing\(24\) \}\)/, + 'the session details tail must clear the strip'); + assert.match(normalize(conversationViewSettings), + /\.padding\(\{ left: 20, right: 20, bottom: this\.insets\.tailSpacing\(24\) \}\)/, + 'the view settings tail must clear the strip'); +}); + +test('the session sheets each bind on their own node', () => { + // A node carries at most one bindSheet: chaining the action sheet and the + // details sheet on one node left the first sheet unopenable, so the action + // sheet binds on its own zero-height carrier row and the details sheet stays + // on the root — the same pattern the sidebar's section uses for its pair. + const build = normalize(remoteSessionList.slice( + remoteSessionList.indexOf('build() {'), + remoteSessionList.indexOf('@Builder', remoteSessionList.indexOf('build() {')))); + assert.match(build, /Row\(\) \{\}\.height\(0\) \.bindSheet\(\$\$this\.showSessionActionSheet/, + 'the action sheet must bind on its own carrier node'); + assert.match(build, /\.alignItems\(HorizontalAlign\.Start\) \.bindSheet\(\$\$this\.showSessionDetails/, + 'the details sheet must be the root chain\'s only sheet'); +}); + +test('the full-height sheets keep their fixed bottom controls out of the bar', () => { + // These surfaces do not scroll at their bottom edge; their fixed controls + // carry the strip (or their design spacing, where the strip is already clear) + // so no control sits in the navigation bar's strip. + assert.match(normalize(workspacePicker), + /\.padding\(\{ top: 12, bottom: this\.insets\.bottomPadding\(16\) \}\)/, + 'the workspace picker confirm button must clear the strip'); + assert.match(normalize(connectView), + /\.padding\(\{ bottom: this\.insets\.bottomPadding\(28\) \}\)/, + 'the connect sheet status strip must clear the strip'); +}); + +test('the file preview scrollers end in strip-aware tails', () => { + // The preview scrollers already run to the screen edge; their tails are what + // rests the last line above the navigation bar. + assert.match(normalize(filePreviewSurface), + /\.padding\(\{ left: 12, right: 12, top: 14, bottom: this\.insets\.tailSpacing\(24\) \}\)/, + 'the text preview tail must clear the strip'); + assert.match(normalize(filePreviewSurface), + /\.padding\(\{ left: 16, right: 16, top: 16, bottom: this\.insets\.tailSpacing\(28\) \}\)/, + 'the markdown preview tail must clear the strip'); +}); + +test('the welcome dock reads the strip through the shared binding', () => { + // The dock paints into the strip (expandSafeArea carries its background to + // the screen edge), so its buttons must keep their clearance from a measured + // strip: the design spacing where it is already taller, the strip itself + // where a device's indicator is higher. A hard-coded clearance only looked + // right on the reference device. + assert.match(normalize(welcomeHome), + /bottom: this\.wide\(\) \? 0 : this\.insets\.bottomPadding\(G\.welcomeDockBottom\)/, + 'the dock must lift its content by the strip wherever the indicator is taller'); + assert.match(welcomeHome, + /\.expandSafeArea\(\[SafeAreaType\.SYSTEM\], \[SafeAreaEdge\.BOTTOM\]\)/, + 'the dock fill must still reach the screen edge'); +}); + +test('every surface that owns a bottom edge reads the shared binding, not a constant', () => { + for (const [name, text] of [['AppSidebar', appSidebar], ['MiniAppSurface', miniAppSurface], + ['SettingsSheet', settingsSheet], ['ConversationView', conversationView], + ['WorkspaceToolsPanel', workspaceToolsPanel], ['SidebarWorkspacePicker', workspacePicker], + ['ConnectView', connectView], ['FilePreviewSurface', filePreviewSurface], + ['RemoteSessionList', remoteSessionList], ['SessionDetailsView', sessionDetailsView], + ['ConversationViewSettings', conversationViewSettings], ['WelcomeHome', welcomeHome]]) { + assert.match(text, /@Local insets: WindowInsetsBinding = new WindowInsetsBinding\(\);/, + `${name} must hold the shared inset binding`); + assert.match(text, /this\.insets\.bind\(this\.getUIContext\(\), context\)/, + `${name} must bind the insets while it is mounted`); + assert.match(text, /this\.insets\.unbind\(\)/, `${name} must release the insets when it goes`); + } +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/model-role-selection.test.cjs b/src/apps/mobile/harmonyos/tools/tests/model-role-selection.test.cjs new file mode 100644 index 0000000000..bad3937905 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/model-role-selection.test.cjs @@ -0,0 +1,223 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +function load(relativePath, resolve) { + const source = fs.readFileSync(path.join(__dirname, '../..', relativePath), 'utf8'); + const js = ts.transpileModule(source, { + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS} + }).outputText; + const exported = {}; + new Function('require', 'exports', js)(name => (resolve ? resolve(name) : undefined) || {}, exported); + return exported; +} + +const policy = load('entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets'); +const {ConversationModelPresentationPolicy: Policy} = policy; +const ui = load('entry/src/main/ets/services/RemoteUiState.ets', name => name.endsWith('RemoteI18n') ? + {RemoteI18n: {t: key => key, f: (key, value) => `${key}:${value}`}} : + name.endsWith('RemoteSessionIdentity') ? {remoteSessionIdentity: () => ''} : {TranscriptIntegrityPolicy: {}}); +const {RemoteUiState} = ui; + +function model(id, options = {}) { + return { + id, + name: options.name || id, + provider: options.provider || 'openai', + base_url: '', + model_name: options.model_name || id, + context_window: options.context_window, + enabled: options.enabled !== false, + capabilities: [] + }; +} + +const primary = model('anthropic/claude-sonnet-4', { + name: 'Anthropic', provider: 'anthropic', model_name: 'anthropic/claude-sonnet-4', context_window: 200000 +}); +const fast = model('openbitfun:gpt-5-mini', {name: 'Fast', provider: 'openai', model_name: 'openbitfun:gpt-5-mini'}); +const other = model('local:llama', {name: 'Llama', provider: 'local'}); +const disabled = model('disabled-model', {name: 'Disabled', provider: 'test', enabled: false}); + +function catalog(defaults = {}, extra = {}) { + return { + version: 1, + models: [primary, fast, other, disabled], + default_models: defaults, + ...extra + }; +} + +test('resolves role selectors to enabled concrete models', () => { + const both = catalog({primary: primary.id, fast: fast.id}); + assert.equal(Policy.resolveModel(both, 'primary').id, primary.id); + assert.equal(Policy.resolveModel(both, 'fast').id, fast.id); + assert.equal(Policy.resolveModel(both, primary.id).id, primary.id); + assert.equal(Policy.resolveModel(both, ' primary ').id, primary.id); +}); + +test('fast falls back to primary exactly like the host', () => { + const primaryOnly = catalog({primary: primary.id}); + assert.equal(Policy.resolveModel(primaryOnly, 'fast').id, primary.id); + const disabledFast = catalog({primary: primary.id, fast: disabled.id}); + assert.equal(Policy.resolveModel(disabledFast, 'fast').id, primary.id); + const unknownFast = catalog({primary: primary.id, fast: 'missing-model'}); + assert.equal(Policy.resolveModel(unknownFast, 'fast').id, primary.id); +}); + +test('role selectors never resolve disabled, unknown, auto or default selectors', () => { + const both = catalog({primary: primary.id, fast: fast.id}); + assert.equal(Policy.resolveModel(both, disabled.id), undefined); + assert.equal(Policy.resolveModel(both, 'missing-model'), undefined); + assert.equal(Policy.resolveModel(both, 'auto'), undefined); + assert.equal(Policy.resolveModel(both, 'default'), undefined); + assert.equal(Policy.resolveModel(both, ''), undefined); + assert.equal(Policy.resolveModel(catalog({primary: disabled.id}), 'primary'), undefined); +}); + +test('maps a concrete model id to the roles it serves', () => { + assert.deepEqual(Policy.modelRoles(catalog({primary: primary.id, fast: fast.id}), primary.id), ['primary']); + assert.deepEqual(Policy.modelRoles(catalog({primary: primary.id, fast: fast.id}), fast.id), ['fast']); + assert.deepEqual(Policy.modelRoles(catalog({primary: fast.id, fast: fast.id}), fast.id), ['primary', 'fast']); + assert.deepEqual(Policy.modelRoles(catalog({primary: primary.id, fast: fast.id}), other.id), []); + assert.deepEqual(Policy.modelRoles(catalog({primary: primary.id}), ''), []); + assert.deepEqual(Policy.modelRoles(catalog({}), primary.id), []); +}); + +test('selectedModel resolves a semantic session_model_id instead of the primary fallback', () => { + assert.equal(Policy.selectedModel(catalog({primary: primary.id, fast: fast.id}, {session_model_id: 'fast'})).id, fast.id); + assert.equal(Policy.selectedModel(catalog({primary: primary.id, fast: fast.id}, {session_model_id: 'primary'})).id, primary.id); + assert.equal(Policy.selectedModel(catalog({primary: primary.id}, {session_model_id: 'fast'})).id, primary.id); +}); + +test('selectedModel keeps its precedence for concrete and empty selections', () => { + const both = catalog({primary: primary.id, fast: fast.id}); + assert.equal(Policy.selectedModel(both, other.id).id, other.id); + assert.equal(Policy.selectedModel({...both, session_model_id: fast.id}, '').id, fast.id); + assert.equal(Policy.selectedModel(both, '').id, primary.id); + assert.equal(Policy.selectedModel({...both, session_model_id: fast.id}, disabled.id).id, fast.id); + assert.equal(Policy.selectedModel({...both, session_model_id: 'fast'}, 'primary').id, primary.id); +}); + +test('role selection is normalized and never inferred from auto, default or concrete ids', () => { + assert.equal(Policy.selectedRole('primary'), 'primary'); + assert.equal(Policy.selectedRole(' fast '), 'fast'); + assert.equal(Policy.selectedRole('auto'), ''); + assert.equal(Policy.selectedRole('default'), ''); + assert.equal(Policy.selectedRole(''), ''); + assert.equal(Policy.selectedRole(primary.id), ''); + assert.equal(Policy.isSemanticModelId('primary'), true); + assert.equal(Policy.isSemanticModelId(primary.id), false); +}); + +test('trigger label key follows a semantic selection that still resolves', () => { + const both = catalog({primary: primary.id, fast: fast.id}); + assert.equal(Policy.selectedModelLabelKey(both, 'primary'), 'chat.modelPrimary'); + assert.equal(Policy.selectedModelLabelKey(both, 'fast'), 'chat.modelFast'); + assert.equal(Policy.selectedModelLabelKey(both, ' fast '), 'chat.modelFast'); + assert.equal(Policy.selectedModelLabelKey(catalog({fast: fast.id}), 'fast'), 'chat.modelFast'); + assert.equal(Policy.selectedModelLabelKey(catalog({primary: primary.id}), 'fast'), 'chat.modelFast'); + assert.equal(Policy.selectedModelLabelKey(both, 'auto'), ''); + assert.equal(Policy.selectedModelLabelKey(both, ''), ''); + assert.equal(Policy.selectedModelLabelKey(both, fast.id), ''); + assert.equal(Policy.roleLabelKey('primary'), 'chat.modelPrimary'); + assert.equal(Policy.roleLabelKey('fast'), 'chat.modelFast'); + assert.equal(Policy.roleLabelKey(other.id), ''); +}); + +test('an unresolvable role is presented as no role selection at all', () => { + const neither = catalog({}); + assert.equal(Policy.selectedModelLabelKey(neither, 'primary'), ''); + assert.equal(Policy.selectedModelLabelKey(neither, 'fast'), ''); + assert.equal(Policy.resolvedSelectedRole(neither, 'primary'), ''); + assert.equal(Policy.resolvedSelectedRole(neither, 'fast'), ''); + assert.deepEqual(Policy.roleEntries(neither), []); + assert.equal(Policy.selectedModel(neither, 'fast'), undefined); + + const disabledDefaults = catalog({primary: disabled.id, fast: disabled.id}); + assert.equal(Policy.selectedModelLabelKey(disabledDefaults, 'fast'), ''); + assert.equal(Policy.resolvedSelectedRole(disabledDefaults, 'primary'), ''); + assert.equal(Policy.selectedModelLabelKey(disabledDefaults, primary.id), ''); + + const primaryOnly = catalog({primary: primary.id}); + assert.equal(Policy.resolvedSelectedRole(primaryOnly, 'primary'), 'primary'); + assert.equal(Policy.resolvedSelectedRole(primaryOnly, 'fast'), 'fast'); + assert.equal(Policy.resolvedSelectedRole(primaryOnly, ''), ''); + assert.equal(Policy.resolvedSelectedRole(primaryOnly, primary.id), ''); + assert.equal(Policy.resolvedSelectedRole(primaryOnly, 'auto'), ''); + + const fastOnly = catalog({fast: fast.id}); + assert.equal(Policy.resolvedSelectedRole(fastOnly, 'fast'), 'fast'); + assert.equal(Policy.selectedModelLabelKey(fastOnly, 'primary'), ''); +}); + +test('a concrete row is checked only for its own concrete selection', () => { + assert.equal(Policy.isConcreteModelSelected(fast.id, fast.id), true); + assert.equal(Policy.isConcreteModelSelected(fast.id, primary.id), false); + assert.equal(Policy.isConcreteModelSelected('primary', primary.id), false); + assert.equal(Policy.isConcreteModelSelected('fast', fast.id), false); + assert.equal(Policy.isConcreteModelSelected('auto', primary.id), false); + assert.equal(Policy.isConcreteModelSelected('', primary.id), false); +}); + +test('defaults section lists both roles and describes the fast fallback', () => { + const both = Policy.roleEntries(catalog({primary: primary.id, fast: fast.id})); + assert.equal(both.length, 2); + assert.deepEqual(both.map(entry => entry.role), ['primary', 'fast']); + assert.deepEqual(both.map(entry => entry.labelKey), ['chat.modelPrimary', 'chat.modelFast']); + assert.equal(both[0].model.id, primary.id); + assert.equal(both[1].model.id, fast.id); + assert.equal(both[0].fallsBackToPrimary, false); + assert.equal(both[1].fallsBackToPrimary, false); + assert.equal(Policy.roleMetaNoticeKey(both[1]), ''); + + const primaryOnly = Policy.roleEntries(catalog({primary: primary.id})); + assert.equal(primaryOnly[1].model.id, primary.id); + assert.equal(primaryOnly[1].fallsBackToPrimary, true); + assert.equal(Policy.roleMetaNoticeKey(primaryOnly[1]), 'chat.modelFastNotConfigured'); + + const fastOnly = Policy.roleEntries(catalog({fast: fast.id})); + assert.equal(fastOnly[0].model, undefined); + assert.equal(fastOnly[1].model.id, fast.id); + assert.equal(fastOnly[1].fallsBackToPrimary, false); + + assert.deepEqual(Policy.roleEntries(catalog({})), []); + assert.deepEqual(Policy.roleEntries(catalog({primary: disabled.id, fast: disabled.id})), []); +}); + +test('role rows describe the resolved model with provider and context metadata', () => { + assert.equal(Policy.roleMetaLabel(fast, 'Model'), 'gpt-5-mini · openai'); + assert.equal(Policy.roleMetaLabel(primary, 'Model'), 'claude-sonnet-4 · anthropic · 200k'); + assert.equal(Policy.roleMetaLabel(undefined, 'Model'), ''); + assert.equal(Policy.primaryLabel(primary, 'Model'), 'claude-sonnet-4'); + assert.equal(Policy.secondaryLabel(fast, 'Model'), 'openai · Fast'); +}); + +test('catalog hydration keeps a resolvable semantic selection', () => { + const both = catalog({primary: primary.id, fast: fast.id}); + assert.equal(RemoteUiState.selectedModelIdForCatalog(both, 'primary'), 'primary'); + assert.equal(RemoteUiState.selectedModelIdForCatalog(both, 'fast'), 'fast'); + assert.equal(RemoteUiState.selectedModelIdForCatalog({...both, session_model_id: 'fast'}, 'primary'), 'fast'); + assert.equal(RemoteUiState.selectedModelIdForCatalog(both, other.id), other.id); + assert.equal(RemoteUiState.selectedModelIdForCatalog(both, 'missing-model'), primary.id); + assert.equal(RemoteUiState.selectedModelIdForCatalog({...both, models: [primary, fast]}, 'fast'), 'fast'); + assert.equal(RemoteUiState.selectedModelIdForCatalog(catalog({fast: fast.id}), 'primary'), fast.id); + assert.equal(RemoteUiState.selectedModelIdForCatalog(catalog({}), ''), ''); +}); + +test('service and policy semantic helpers agree on padded selectors and defaults', () => { + const padded = catalog({primary: ` ${primary.id} `, fast: ` ${fast.id} `}); + assert.equal(Policy.resolveModel(padded, 'primary').id, primary.id); + assert.equal(Policy.resolveModel(padded, ' fast ').id, fast.id); + assert.equal(Policy.resolvedSelectedRole(padded, ' fast '), 'fast'); + assert.deepEqual(Policy.modelRoles(padded, fast.id), ['fast']); + assert.equal(RemoteUiState.selectedModelIdForCatalog(padded, 'primary'), 'primary'); + assert.equal(RemoteUiState.selectedModelIdForCatalog(padded, ' fast '), 'fast'); + assert.equal(RemoteUiState.selectedModelIdForCatalog(catalog({primary: primary.id, fast: fast.id}), ' primary '), 'primary'); + + const paddedFastDisabled = catalog({primary: ` ${primary.id} `, fast: ` ${disabled.id} `}); + assert.equal(Policy.resolveModel(paddedFastDisabled, 'fast').id, primary.id); + assert.equal(RemoteUiState.selectedModelIdForCatalog(paddedFastDisabled, 'fast'), 'fast'); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs b/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs new file mode 100644 index 0000000000..e78af739f3 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs @@ -0,0 +1,340 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const ROOT = path.join(__dirname, '../../entry/src/main/ets'); +const cache = new Map(); + +/** Loads one .ets module and resolves its relative imports from the source tree. */ +function load(relative) { + if (cache.has(relative)) return cache.get(relative); + const source = fs.readFileSync(path.join(ROOT, relative + '.ets'), 'utf8') + .replace(/@ObservedV2\s*/g, '').replace(/@Trace\s*/g, ''); + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText; + const exported = {}; + cache.set(relative, exported); + new Function('require', 'exports', js)(name => { + // Platform kits are only reached by code paths these tests do not run. + if (!name.startsWith('.')) return {}; + const target = path.relative(ROOT, path.resolve(path.join(ROOT, relative), '..', name)) + .split(path.sep).join('/'); + return fs.existsSync(path.join(ROOT, `${target}.ets`)) ? load(target) : {}; + }, exported); + return exported; +} + +const { DurableSessionReducer } = load('services/DurableSessionReducer'); +const { ChatTimelineStore } = load('services/ChatTimelineStore'); +const { ChatSessionController } = load('services/ChatSessionController'); +const { ChatTimelineRowStore } = load('model/ChatTimelineModels'); +const ui = load('pages/state/ConversationUiModels'); +const { ChatMessageStructurePolicy: Policy } = load('pages/policy/ChatMessageStructurePolicy'); + +const SESSION = 'session'; +const TURN = 'turn'; +const CHILD_SESSION = 'child-session'; +const TASK_CALL_ID = 'task_call_1'; + +/** One durable session-record event exactly as the host publishes it. */ +function record(revision, id, type, order, data) { + return { + session_id: SESSION, + event: 'session-record', + payload: { + sessionId: SESSION, + id: `item/${id}`, + revision, + turn: { + turnId: TURN, turnIndex: 0, sessionId: SESSION, timestamp: 1, + userMessage: { id: 'user', content: 'run the audit', timestamp: 1 }, + status: 'inprogress' + }, + round: { id: 'round', turnId: TURN, roundIndex: 0, timestamp: 2, status: 'inprogress' }, + item: { type, data: Object.assign({ id, orderIndex: order, timestamp: 3 }, data) } + } + }; +} + +/** + * The owner Task tool record. + * + * The host records the child Session id on the Task tool item — the durable + * record contract is `is_subagent_item` / `subagent_session_id` on the item + * data in `src/crates/services/services-core/src/session/types.rs`, published + * as `isSubagentItem` / `subagentSessionId` — and every mobile client maps that + * field onto the subagent marker (`DurableSessionReducer.ets:157`), so the + * owner Task arrives marked exactly like the records it owns. + */ +function ownerTaskRecord(revision) { + return record(revision, TASK_CALL_ID, 'tool', 0, { + toolName: 'Task', + toolCall: { id: TASK_CALL_ID, input: { description: 'Audit the payment module', subagent_type: 'Explore' } }, + startTime: 3, + status: 'running', + subagentSessionId: CHILD_SESSION + }); +} + +function childThinkingRecord(revision) { + return record(revision, 'child-think', 'thinking', 1, { + content: 'Reading the payment module', subagentSessionId: CHILD_SESSION + }); +} + +function childToolRecord(revision) { + return record(revision, 'child-tool', 'tool', 2, { + toolName: 'Read', + toolCall: { id: 'child-read', input: { path: 'src/payments.ts' } }, + startTime: 4, + status: 'completed', + subagentSessionId: CHILD_SESSION + }); +} + +function childTextRecord(revision) { + return record(revision, 'child-text', 'text', 3, { + content: 'Found no hard-coded keys', subagentSessionId: CHILD_SESSION + }); +} + +/** Every record a running Task publishes, in host order. */ +function runningTaskRecords() { + return [ + ownerTaskRecord(1), + childThinkingRecord(2), + childToolRecord(3), + childTextRecord(4) + ]; +} + +async function timelineHarness() { + const timeline = new ChatTimelineStore(); + timeline.reset(SESSION); + let stream; + const controller = new ChatSessionController({ + getModelCatalog: async () => ({ version: 1, models: [], default_models: {} }), + subscribeSession: (_id, callbacks) => { + stream = callbacks; + return { wake() {}, close() {} }; + } + }, { + onSnapshot: snapshot => timeline.applySnapshot(snapshot), + canPoll: () => true, + onError: error => { throw error; } + }); + controller.start(SESSION, { pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0 }); + await stream.onCaughtUp(); + for (const event of runningTaskRecords()) await stream.onEvent(event); + return { timeline, stream, controller }; +} + +/** + * The production chain the live assistant row walks: durable records -> timeline + * store -> projector -> keyed V2 rows -> conversation UI DTO -> grouped render + * model. Returns the Task card the bubble would draw. + */ +function taskCardFromActiveTurn(timeline) { + const rows = new ChatTimelineRowStore().reconcile(timeline.project(false)); + const liveRow = rows.find(row => row.type === 'assistant_live_turn'); + assert.ok(liveRow, 'the running turn is projected as a live assistant row'); + const message = ui.toConversationUiMessage(liveRow.message); + const groups = Policy.structuredGroups(message.items || [], 'item', true, message.status || 'active'); + const cards = groups + .filter(group => group.type === 'item' && group.items.length === 1) + .map(group => group.items[0]) + .filter(entry => entry.tool && entry.tool.name === 'Task'); + assert.equal(cards.length, 1, 'exactly one Task card is drawn for one Task tool call'); + return { message, card: cards[0], groups }; +} + +test('a running Task card receives the subagent process published flat behind it', async () => { + const { timeline, controller } = await timelineHarness(); + const { card } = taskCardFromActiveTurn(timeline); + + assert.equal((card.subItems || []).length, 3, 'thinking, tool and text are Task children'); + assert.deepEqual((card.subItems || []).map(child => child.type), ['thinking', 'tool', 'text']); + assert.equal(card.subItems[0].content, 'Reading the payment module'); + assert.equal(card.subItems[1].tool.name, 'Read'); + assert.equal(card.subItems[2].content, 'Found no hard-coded keys'); + controller.stop(); +}); + +test('the subagent process never leaks into the parent transcript as sibling rows', async () => { + const { timeline, controller } = await timelineHarness(); + const { message, groups } = taskCardFromActiveTurn(timeline); + + const siblingTypes = groups.flatMap(group => group.items.concat(group.tools.map(tool => ({ type: 'tool', tool })))) + .filter(entry => entry.type !== 'tool' || !entry.tool || entry.tool.name !== 'Task') + .map(entry => entry.type); + assert.deepEqual(siblingTypes, [], 'only the Task card represents the running subagent'); + assert.equal(message.text, '', 'the subagent answer is not the parent answer'); + assert.equal(message.thinking, '', 'the subagent reasoning is not the parent reasoning'); + controller.stop(); +}); + +test('the subagent process stays visible after the turn completes', async () => { + const { timeline, stream, controller } = await timelineHarness(); + const finished = ownerTaskRecord(5); + finished.payload.item.data.status = 'completed'; + finished.payload.turn.status = 'completed'; + await stream.onEvent(finished); + + const assistant = timeline.snapshot().persistedMessages.find(row => row.role === 'assistant'); + assert.ok(assistant, 'the completed turn is persisted'); + const message = ui.toConversationUiMessage(assistant); + const groups = Policy.structuredGroups(message.items || [], 'item', false, message.status || 'done'); + const card = groups.map(group => group.items[0]).find(entry => entry.tool && entry.tool.name === 'Task'); + assert.ok(card, 'the Task card survives completion'); + assert.equal((card.subItems || []).length, 3, 'its process stays under it'); + controller.stop(); +}); + +test('an owner Task marked as a subagent is still the branch owner', () => { + const task = { + type: 'tool', + is_subagent: true, + tool: { id: TASK_CALL_ID, name: 'Task', status: 'running', tool_input: { description: 'Audit' } } + }; + const children = [ + { type: 'thinking', is_subagent: true, content: 'child reasoning' }, + { type: 'tool', is_subagent: true, tool: { id: 'child-read', name: 'Read', status: 'running' } }, + { type: 'text', is_subagent: true, content: 'child output' } + ]; + const scoped = Policy.scopeSubagentItems([task].concat(children)); + assert.equal(scoped.length, 1, 'the marked children fold into the marked owner Task'); + assert.equal((scoped[0].subItems || []).length, 3); +}); + +test('every Task name variant the host can send opens the branch', () => { + for (const name of ['Task', 'task', 'TASK', ' Task ', 'Task']) { + const task = { type: 'tool', tool: { id: 'call', name, status: 'running' } }; + const child = { type: 'text', is_subagent: true, content: 'child output' }; + const scoped = Policy.scopeSubagentItems([task, child]); + assert.equal((scoped[0].subItems || []).length, 1, `Task name ${JSON.stringify(name)} owns the child`); + } +}); + +test('a marked record that arrives before its Task is not lost', () => { + const orphan = { type: 'thinking', is_subagent: true, content: 'first child reasoning' }; + const task = { type: 'tool', is_subagent: true, tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const later = { type: 'text', is_subagent: true, content: 'later child output' }; + const scoped = Policy.scopeSubagentItems([orphan, task, later]); + + assert.equal(scoped.length, 2, 'the early record keeps its own branch'); + const orphanBranch = scoped[0]; + assert.equal(orphanBranch.type, 'thinking'); + assert.equal((orphanBranch.subItems || []).length, 1, 'its content survives as a child, not as parent output'); + assert.equal(orphanBranch.subItems[0].is_subagent, false, 'the inner copy renders as subagent process'); + assert.equal(scoped[1].tool.name, 'Task'); + assert.equal((scoped[1].subItems || []).length, 1, 'the later record still joins its Task'); +}); + +test('an orphaned subagent tool still renders inside its own branch', () => { + const orphanTool = { type: 'tool', is_subagent: true, tool: { id: 'orphan-read', name: 'Read', status: 'running' } }; + const groups = Policy.structuredGroups([orphanTool], 'item', true, 'active'); + assert.equal(groups.length, 1, 'the orphan is drawn'); + assert.equal(groups[0].items.length, 1); + assert.equal(groups[0].items[0].tool.name, 'Read', 'with its tool identity for the card title'); + assert.equal((groups[0].items[0].subItems || []).length, 1, 'and its own process child'); +}); + +test('an orphaned subagent record labels its branch without repeating its own text', () => { + const orphan = { type: 'thinking', is_subagent: true, content: 'first child reasoning' }; + const branch = Policy.scopeSubagentItems([orphan])[0]; + + assert.equal(branch.content, '', 'the branch label does not repeat the step it wraps'); + assert.equal(Policy.subagentBody(branch), '', 'and the branch carries no second copy as its body'); + assert.equal(Policy.subagentTitle(branch), '', + 'with no text left the title falls back to the generic Task name'); + assert.equal((branch.subItems || []).length, 1, 'the step itself survives'); + assert.equal(branch.subItems[0].content, 'first child reasoning', 'and keeps the text as its body'); + assert.equal(branch.subItems[0].is_subagent, false, 'the step renders as content, not as a nested branch'); + + const orphanTool = { type: 'tool', is_subagent: true, tool: { id: 'orphan-read', name: 'Read', status: 'running' } }; + const toolBranch = Policy.scopeSubagentItems([orphanTool])[0]; + assert.equal(Policy.subagentTitle(toolBranch), 'Read', 'a tool orphan keeps its tool-derived label'); + assert.equal(toolBranch.subItems[0].tool.name, 'Read', 'and its step is the tool row'); +}); + +test('an unmarked Task with flat marked children is unchanged', () => { + const task = { type: 'tool', tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const children = [ + { type: 'thinking', is_subagent: true, content: 'child reasoning' }, + { type: 'tool', is_subagent: true, tool: { id: 'child-read', name: 'Read', status: 'completed' } } + ]; + const scoped = Policy.scopeSubagentItems([task].concat(children)); + assert.equal(scoped.length, 1); + assert.equal((scoped[0].subItems || []).length, 2); +}); + +/** + * The branch view key is what ArkUI keys the Task card node by, and an unchanged + * key never re-runs the item builder. The card therefore cannot rely on a + * rebuild to receive a step that arrives later: it re-reads its branch from the + * observed message on every render. These tests pin that contract, so a future + * "fix" that instead churns the key (and so destroys an open branch on every + * streamed step) fails here. + */ +test('the Task branch view key stays stable while its subagent publishes steps', () => { + const task = { type: 'tool', tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const child = { type: 'thinking', is_subagent: true, content: 'child reasoning' }; + const branchKeys = items => Policy.structuredGroups(Policy.scopeSubagentItems(items), 'item', true, 'active') + .map(group => group.key).join('|'); + + assert.equal(branchKeys([task, child]), branchKeys([task]), + 'an arriving step must not recreate the branch node that is already open'); +}); + +test('a card re-reads its branch, so a later step reaches the open card', () => { + const owner = { type: 'tool', is_subagent: true, tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const firstStep = { type: 'thinking', is_subagent: true, content: 'step one' }; + const secondStep = { type: 'tool', is_subagent: true, tool: { id: 'child-read', name: 'Read', status: 'running' } }; + + // What the frozen item builder captured when the Task card was first drawn. + const captured = Policy.structuredGroups([owner], 'item', true, 'active')[0].items[0]; + assert.equal((captured.subItems || []).length, 0, 'a Task card is born with no process items'); + + // What the card reads on every later render, from the transcript as it is now. + const first = Policy.subagentBranchAt([owner, firstStep], 0, TASK_CALL_ID); + assert.equal((first.subItems || []).length, 1, 'the first step is visible'); + const second = Policy.subagentBranchAt([owner, firstStep, secondStep], 0, TASK_CALL_ID); + assert.equal((second.subItems || []).length, 2, 'a step published later is visible too'); + assert.equal(second.subItems[1].tool.name, 'Read'); + assert.notEqual(second, captured, 'the stale captured snapshot is not what the card draws'); +}); + +test('a branch is found by its Task tool call and by its render ordinal', () => { + const owner = { type: 'tool', is_subagent: true, tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const child = { type: 'text', is_subagent: true, content: 'child output' }; + + assert.equal(Policy.subagentBranchAt([owner, child], 0, TASK_CALL_ID).tool.id, TASK_CALL_ID); + assert.equal(Policy.subagentBranchAt([owner, child], 0, '').tool.id, TASK_CALL_ID, + 'the ordinal identifies the branch when the host sent no tool call id'); + assert.equal(Policy.subagentBranchAt([owner, child], 1, TASK_CALL_ID).tool.id, TASK_CALL_ID, + 'an unknown ordinal still resolves through the owning tool call'); + assert.equal(Policy.subagentBranchAt([owner, child], 3, ''), undefined, + 'an ordinal with no branch resolves to nothing, so the card keeps its snapshot'); +}); + +test('a branch path reports the ordinal the render path numbered it with', () => { + assert.equal(Policy.subagentOrdinal('item-subagent-0'), 0); + assert.equal(Policy.subagentOrdinal('item-subagent-2'), 2); + assert.equal(Policy.subagentOrdinal('item-subagent-0-adjacent-1'), 0); + assert.equal(Policy.subagentOrdinal('item-tool-0'), 0); +}); + +test('the Task card is fed the branch re-read from the observed message', () => { + const card = fs.readFileSync(path.join(ROOT, 'pages/components/SubagentTaskCard.ets'), 'utf8'); + assert.match(card, /@Param items: ConversationUiMessageItem\[\]/, + 'the card takes the branch process items as its param'); + + const bubble = fs.readFileSync(path.join(ROOT, 'pages/components/ChatMessageBubble.ets'), 'utf8'); + assert.match(bubble, /items: this\.currentSubagentBranch\(entry, path\)\.subItems/, + 'the bubble re-resolves the branch on every render instead of using the captured entry'); + assert.match(bubble, /ChatMessageStructurePolicy\.subagentBranchAt\(/, + 'the re-resolution goes through the shared policy'); +});