From 4432148778cc4daf2bc703f54478d1d8f5d05e50 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 14 Aug 2026 12:43:17 +0800 Subject: [PATCH] feat(harmonyos): fold settled process cards and recover hollow remote transcripts Keep thinking, tools, and Task cards collapsed after a turn, stop empty poll stubs from wiping assistant bodies, and keep the compact header on the menu glyph. Co-authored-by: Cursor --- src/apps/mobile/harmonyos/AGENTS.md | 2 +- .../wide-conversation-navigation-design.md | 32 +- .../pages/components/AppRootPresentation.ets | 50 +++- .../main/ets/pages/components/AppShell.ets | 3 +- .../pages/components/ChatMessageBubble.ets | 274 +++++++++--------- .../ets/pages/components/ChatTimeline.ets | 95 ++++-- .../pages/components/ConversationHeader.ets | 2 +- .../components/ConversationRouteSurface.ets | 3 + .../ets/pages/components/ConversationView.ets | 98 +++++-- .../pages/components/ConversationViewHost.ets | 2 + .../components/RemoteCreateSessionView.ets | 2 + .../ets/pages/components/SubagentTaskCard.ets | 87 ++++++ .../ets/pages/components/ThinkingBlock.ets | 33 +-- .../ets/pages/components/ToolStatusList.ets | 118 ++------ .../pages/components/WideConversationHost.ets | 3 +- .../ets/pages/policy/ActivityGroupPolicy.ets | 25 ++ .../policy/AssistantProcessLayoutPolicy.ets | 53 ++++ .../pages/policy/ConversationLayoutPolicy.ets | 101 +++++-- .../ConversationMessageRenderPolicy.ets | 44 +++ .../policy/ThinkingPresentationPolicy.ets | 60 ++++ .../ets/pages/policy/ToolCollapsePolicy.ets | 133 +++++++++ .../services/RemoteChatCommandController.ets | 31 +- .../ets/services/RemoteSessionManager.ets | 9 +- .../src/main/ets/services/RemoteUiState.ets | 17 +- .../services/TranscriptIntegrityPolicy.ets | 80 +++++ .../ConversationPresentationUnit.test.ets | 190 ++++++++++++ .../src/test/ConversationStateUnit.test.ets | 41 +++ .../src/test/RemoteControllersUnit.test.ets | 154 +++++++--- 28 files changed, 1328 insertions(+), 414 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ActivityGroupPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/AssistantProcessLayoutPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationMessageRenderPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolCollapsePolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/TranscriptIntegrityPolicy.ets diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index 39103495fc..fdcefa6d23 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -47,7 +47,7 @@ source scripts/ohos-env.sh ## Visual reference fidelity - Before drawing a system glyph, text approximation, or new bitmap, search the existing HarmonyOS media resources and the approved desktop reference images. Reuse the established asset when one exists. -- Conversation header controls must use the approved `remote_ref_back` and `remote_ref_more` assets. Do not replace them with a system chevron or text such as `...` / bullet characters. +- Conversation header controls must use the approved `remote_ref_back` and `remote_ref_more` assets. Do not replace them with a system chevron or text such as `...` / bullet characters. Compact top-left open-sidebar uses `CompactMenuButton` (`gpt_home_menu_glyph`). Wide restore/collapse uses `SidebarToggleButton` (the sidebar-pane glyph). - Render monochrome reference assets in template mode and tint them with semantic theme colors such as `INK`. Never rely on the bitmap's original black or white pixels; the same control must remain legible in light and dark themes. - Keep paired header controls on the same fixed touch-target size and optical alignment. A responsive layout may reposition a control, but must not silently change its icon geometry or visual weight. - Keep `SymbolGlyph` geometry separate from its touch target. When a glyph is clickable or sits in a decorated control, wrap it in `Stack({ alignContent: Alignment.Center })` (or use a centered `Button`) and give the glyph its visual size; do not stretch the glyph itself to the full 32vp/40vp/44vp target, because font metrics can make the icon look off-center. diff --git a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md index 3ee7e61021..efba5ae6bd 100644 --- a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md +++ b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md @@ -6,25 +6,30 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 ## 实施状态 -截至 2026-08-07: +截至 2026-08-13: ### 已实现 -- 展开态只要存在可用纵向折痕(双折叠一道、三折叠两道),且当前视口放得下 master + detail,就进入 master-detail;合盖仍强制单屏。无折痕时只有平板走宽屏,普通手机即使超宽也保持单屏。 +- 布局判定对齐官方 GridRow 横向断点:md `600vp` 起双栏,lg `840vp` 起 extra-wide;合盖与悬停(`HALF_FOLDED`)强制不走 master-detail。不再用 `deviceType === tablet` 决定宽屏。 +- 展开折叠屏若 `getCurrentFoldCreaseRegion()` 为空,合成中线铰链(16vp)作为 master/detail 分界,避免内容压在铰链上。 +- 悬停态会话使用官方 `FolderStack`:上半为显示区(时间线),下半为操作区(Composer)。 +- 展开态只要存在可用纵向折痕(双折叠一道、三折叠两道),且当前视口放得下 master + detail,就进入 master-detail。 - 宽屏本地与远程左栏在相同位置复用固定的“本地 / Remote”分段选择器,选中态由当前路由推导。 - 来源选择器使用独立来源切换动作,不复用单屏 `enterCodeEntry()`;具体会话行只选择当前来源内的会话。 - 宽屏来源切换使用 `clear(false)` 与 `pushPath(..., false)` 无转场替换路由,不播放页面进入或退出动画。 - 宽屏本地和远程 master pane 均不提供全局侧边栏入口;远程宽屏标题栏已移除会叠加本地 drawer 的菜单按钮,单屏标题栏菜单保持不变。 - 本地与远程来源切换使用路由合同计算目标,不累积跨来源返回路径。 - 切回本地时停止手机侧远程轮询,但不清空远程活动会话或断开桌面。 -- 通过 `display.getCurrentFoldCreaseRegion()` 读取折痕;第一道有效纵向折痕用于 master/detail 分界,无有效折痕时回退到 `344`。 +- 通过 `display.getCurrentFoldCreaseRegion()` 读取折痕;第一道有效纵向折痕用于 master/detail 分界。 - 双折叠展开打开文件预览时,会话/预览分界对齐那一道折痕,而不是按视口对半切。 -- 两道折痕或超宽宽度被识别为 extra-wide,但继续使用同一个 master-detail,不创建第三栏。 +- 两道折痕或 lg 宽度被识别为 extra-wide,但会话仍使用同一个 master-detail,不创建第三会话栏。官方聊天分栏在 md/lg/xl 均为列表|会话;第三窗格只用于文件预览。 - 第二道折痕会把 detail 划分为无折痕候选内容带;会话视图选择最宽的候选带,同宽时优先右侧,避免 Composer、菜单和消息内操作热区跨越折痕。 - extra-wide detail 外层仍覆盖中间和右侧两屏;上述内容带只是 presentation 几何约束,不增加第三个业务 pane。 +- 监听 `foldStatusChange` 与 `foldDisplayModeChange`,折叠形态变化时重算几何。 ### 已验证 +- 下列真机项仍以改前 HAP 为准,本轮官方断点/合成铰链/悬停改动需重新安装后复验。 - `assembleHap` 在固定来源选择器和第二道折痕安全内容带实现后重新构建通过;主题颜色治理检查通过。 - HarmonyOS 本地单元测试任务通过,覆盖折叠优先级、来源路由、连续来源切换不增长返回栈、零/一/两道折痕、非法折痕和非对称三屏几何。 - `ConversationLayoutPolicy` 的新增行、函数和分支覆盖率均为 100%。 @@ -41,13 +46,14 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 ### 待验证 +- 本轮改动后的 Mate X7 展开态:折痕为空时应走中线铰链,而不是固定 344。 +- Mate X7 悬停态:`FolderStack` 将 Composer 放到下半操作区。 - 两道折痕的三折叠真机完整展开态(Mate X7 不覆盖此项)。 - 三屏与双屏之间的真实折叠切换连续性。 -- 固定来源选择器版本的最终 HAP 在折叠单屏下复验;代码路径仍通过默认关闭的显式宽屏开关隔离。 当前 Mate X7 的 DisplayManagerService 报告 `dms.hidumper.supportdebug false`,因此单屏验收使用设备真实折叠状态完成,没有用显示调试命令模拟合盖。 -在上述真机项完成前,不应把三屏手工验收标记为完成。折叠单屏基线矩阵已经完成,但固定来源选择器最终 HAP 仍需一次真实折叠复验后才能关闭回归项。 +在上述真机项完成前,不应把三屏手工验收标记为完成。 ## 背景 @@ -78,8 +84,9 @@ HarmonyOS 手机在展开双屏、完整展开三屏或其他宽屏形态下, - 不改变远程连接、配对、鉴权或断线重连协议。 - 不因为切换会话来源而停止桌面端正在执行的任务。 - 第一阶段不调整 `RemoteCreate` 的页面结构。 -- 宽度阈值(720 / 1080)和合盖强制单屏保持不变;展开后是否进入宽屏由纵向折痕与设备类型共同决定。 -- 不因为三屏增加一个独立的第三栏、第二套会话列表或新的业务路由模式。 +- 宽度阈值对齐官方 md/lg(600 / 840);合盖与悬停强制单屏(悬停走 FolderStack 上下分带)。展开后是否进入宽屏由宽度、纵向折痕和折叠状态决定,不使用 `deviceType`。 +- 不因为三屏增加一个独立的第三会话栏、第二套会话列表或新的业务路由模式。文件预览可以占用第三窗格。 +- 展开会话不改造成 `FoldSplitContainer` 外壳:该组件只有预设比例,无法对齐实测折痕像素。折叠屏展开继续用折痕几何;悬停用官方 `FolderStack`。 ## 当前逻辑 @@ -89,13 +96,14 @@ HarmonyOS 手机在展开双屏、完整展开三屏或其他宽屏形态下, ```text 设备处于折叠状态 -> 单屏 -设备非折叠,宽视口,有可见纵向折痕,且放得下两栏 -> 宽屏(双折/三折展开) -设备非折叠,宽视口,折叠状态为展开/半折,即使折痕缺失 -> 宽屏(双折展开兜底) -设备非折叠,宽视口,无折痕,且设备类型为平板 -> 宽屏 +设备处于悬停(HALF_FOLDED) -> 单屏 + FolderStack(上显示 / 下操作) +设备非折叠,宽视口(≥600vp 或 media query),有纵向折痕,且放得下两栏 -> 宽屏 +设备非折叠,宽视口,折叠屏展开且折痕缺失 -> 宽屏,合成中线铰链 +设备非折叠,宽视口,无折痕 -> 宽屏(断点驱动,含平板) 其他 -> 单屏 ``` -折叠状态优先于宽度和折痕。因此即使报告的可用宽度较大,只要设备处于折叠状态,也必须继续使用单屏布局。双折叠展开的一道折痕与三折叠的两道折痕使用同一套 master-detail,不新增第三会话栏。 +折叠与悬停优先于宽度和折痕。双折叠展开的一道折痕与三折叠的两道折痕使用同一套 master-detail,不新增第三会话栏。 ### 路由分流 diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index 08340952f0..09e36f4262 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -1,5 +1,4 @@ import display from '@ohos.display'; -import deviceInfo from '@ohos.deviceInfo'; import mediaQuery from '@ohos.mediaquery'; import { RemoteUiState } from '../../services/RemoteUiState'; import { AppShell } from './AppShell'; @@ -46,14 +45,6 @@ function safeFoldStatus(): display.FoldStatus { } } -function safeDeviceType(): string { - try { - return deviceInfo.deviceType || ''; - } catch (_err) { - return ''; - } -} - @ComponentV2 export struct AppRootPresentation { @Param shellState: AppShellState = new AppShellState(); @@ -78,7 +69,6 @@ export struct AppRootPresentation { @Local restoreCollapsedMasterAfterPreview: boolean = false; @Local showRemoteViewSettings: boolean = false; @Local remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); - private readonly deviceType: string = safeDeviceType(); private verticalCreases: ConversationLayoutCrease[] = []; private wideQueryListener?: mediaQuery.MediaQueryListener; private foldStatusChanged: (status: display.FoldStatus) => void = @@ -86,6 +76,10 @@ export struct AppRootPresentation { this.foldStatus = status; this.refreshWideGeometry(); }; + private foldDisplayModeChanged: (mode: display.FoldDisplayMode) => void = + (_mode: display.FoldDisplayMode): void => { + this.refreshWideGeometry(); + }; private wideQueryChanged: (result: mediaQuery.MediaQueryResult) => void = (result: mediaQuery.MediaQueryResult): void => { this.wideLayoutMatched = result.matches; @@ -181,6 +175,7 @@ export struct AppRootPresentation { // Compact conversations own the drawer, not a back control: Local and // Remote both open the sidebar over the chat instead of leaving it. showBackButton: false, + hoverOperateMode: this.isHoverLayout(), onRestoreSidebar: () => this.restoreWideMasterPane() }) } @@ -271,7 +266,7 @@ export struct AppRootPresentation { try { const appMediaQuery = this.getUIContext().getMediaQuery(); this.wideQueryListener = appMediaQuery.matchMediaSync( - `screen and (${ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH} < width)` + `screen and (${ConversationLayoutPolicy.MD_MIN_WIDTH} < width)` ); this.wideLayoutMatched = this.wideQueryListener.matches; this.wideQueryListener.on('change', this.wideQueryChanged); @@ -287,6 +282,7 @@ export struct AppRootPresentation { this.foldStatus = display.getFoldStatus(); this.refreshWideGeometry(); display.on('foldStatusChange', this.foldStatusChanged); + display.on('foldDisplayModeChange', this.foldDisplayModeChanged); } catch (_err) { this.foldStatus = display.FoldStatus.FOLD_STATUS_UNKNOWN; this.unbindFoldStatus(); @@ -296,6 +292,7 @@ export struct AppRootPresentation { private unbindFoldStatus(): void { try { display.off('foldStatusChange', this.foldStatusChanged); + display.off('foldDisplayModeChange', this.foldDisplayModeChanged); } catch (_err) { } } @@ -319,9 +316,9 @@ export struct AppRootPresentation { this.viewportWidth, this.wideLayoutMatched, this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED, - this.deviceType, verticalCreases, - this.isExpandedFoldable() + this.isExpandedFoldable(), + this.isHoverLayout() ); const geometry = ConversationLayoutPolicy.resolveWideGeometry( this.viewportWidth, @@ -339,15 +336,36 @@ export struct AppRootPresentation { } } + private isHoverLayout(): boolean { + return this.foldStatus === display.FoldStatus.FOLD_STATUS_HALF_FOLDED; + } + private isExpandedFoldable(): boolean { - return this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED || - this.foldStatus === display.FoldStatus.FOLD_STATUS_HALF_FOLDED; + return this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED && this.isFoldableDevice(); + } + + private isFoldableDevice(): boolean { + try { + return display.isFoldable(); + } catch (_err) { + return this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED || + this.foldStatus === display.FoldStatus.FOLD_STATUS_HALF_FOLDED || + this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED; + } } private currentVerticalCreases(): ConversationLayoutCrease[] { - if (this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED) { + if (this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED || this.isHoverLayout()) { return []; } + return ConversationLayoutPolicy.effectiveVerticalCreases( + this.viewportWidth, + this.reportedVerticalCreases(), + this.isExpandedFoldable() + ); + } + + private reportedVerticalCreases(): ConversationLayoutCrease[] { try { const region = display.getCurrentFoldCreaseRegion(); const creaseRects = region.creaseRects; 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 770503e23c..5a9b66ffb1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets @@ -1,4 +1,5 @@ import { AppShellState } from '../state/AppShellState'; +import { ConversationLayoutPolicy } from '../policy/ConversationLayoutPolicy'; import { PAGE_BG } from './Theme'; const WIDE_SETTINGS_SHEET_MAX_WIDTH: number = 680; @@ -133,7 +134,7 @@ export struct AppShell { if (this.shellWidth <= 0) { return 280; } - if (this.shellWidth < 720) { + if (this.shellWidth < ConversationLayoutPolicy.MD_MIN_WIDTH) { return Math.min(280, Math.max(220, Math.round(this.shellWidth * 0.68))); } return Math.min(420, Math.max(280, Math.round(this.shellWidth * 0.35))); 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 2f2a0cea0a..fb82970c09 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -1,7 +1,12 @@ import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from '../state/ConversationUiModels'; -import { INK, LINE, MUTED, SOFT } from './Theme'; +import { ActivityGroupPolicy } from '../policy/ActivityGroupPolicy'; +import { AssistantProcessLayoutPolicy } from '../policy/AssistantProcessLayoutPolicy'; +import { ConversationMessageRenderPolicy } from '../policy/ConversationMessageRenderPolicy'; +import { ThinkingPresentationPolicy } from '../policy/ThinkingPresentationPolicy'; +import { LINE } from './Theme'; import { MessageFileCards, MessageImageGallery, MessageMarkdown } from './ChatMessageContent'; import { ChatMessageRetryAction, ChatTypingDots, ChatUserMessageBubble } from './ChatMessageChrome'; +import { SubagentTaskCard } from './SubagentTaskCard'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolStatusList } from './ToolStatusList'; import { MessageFileReference, MessageFileReferenceProjectionCache } from '../../services/MessageFileReferenceProjector'; @@ -105,24 +110,32 @@ export struct ChatMessageBubble { @Builder ProcessContent(item: ConversationUiMessage) { + if (this.leadingThinkingText(item).length > 0) { + this.Thinking( + this.leadingThinkingText(item), + this.leadingThinkingStatus(item), + `${item.id}-thinking`, + ThinkingPresentationPolicy.isRunning(this.leadingThinkingStatus(item)) + ) + } if (this.shouldRenderStructuredItems(item)) { this.StructuredItems(item.items || [], this.shouldOmitStructuredThinking(item)) if (this.uncoveredTools(item).length > 0) { this.Tools(this.uncoveredTools(item)) } } else { + if (item.tools && item.tools.length > 0) { + this.Tools(item.tools) + } + if (this.shouldShowTypingDots(item)) { + ChatTypingDots() + } if (item.role !== 'user' && this.visibleMessageText(item).length > 0 && this.visibleMessageText(item) !== item.thinking) { this.MessageText(this.visibleMessageText(item), this.isStreamingAssistant(), `${item.id}-body`) if (this.fileReferences(this.visibleMessageText(item)).length > 0) { this.FileCards(this.visibleMessageText(item)) } } - if (this.shouldShowTypingDots(item)) { - ChatTypingDots() - } - if (item.tools && item.tools.length > 0) { - this.Tools(item.tools) - } } if (this.pinnedThinkingText(item).length > 0) { this.Thinking( @@ -179,22 +192,19 @@ export struct ChatMessageBubble { @Builder ActivityGroup(group: StructuredRenderGroup, omitActiveThinking: boolean = false) { Column({ space: 6 }) { + if (!omitActiveThinking && + ActivityGroupPolicy.thinkingHeaderCount(this.activityThinkingCount(group)) > 0 && + (this.activityThinkingText(group).length > 0 || this.activityThinkingStreaming(group))) { + this.Thinking( + this.activityThinkingText(group), + this.activityThinkingStatus(group), + `${this.item.id}-${group.path}-activity-thinking`, + this.activityThinkingStreaming(group) + ) + } if (this.activityGroupTools(group).length > 0) { this.Tools(this.activityGroupTools(group)) } - ForEach(group.items, (entry: ConversationUiMessageItem, index: number) => { - if (!omitActiveThinking && - this.isRenderableActivityThinking(entry, group.itemStatuses[index] || '', group.itemStreaming[index] || false)) { - this.Thinking( - entry.content || '', - group.itemStatuses[index] || this.item.status, - `${this.item.id}-${group.path}-activity-${index}-thinking`, - group.itemStreaming[index] || false - ) - } - }, (entry: ConversationUiMessageItem, index: number) => { - return this.structuredItemKey(entry, `${group.path}-activity-thinking-${index}`); - }) } .width('100%') } @@ -233,37 +243,17 @@ export struct ChatMessageBubble { @Builder SubagentGroup(entry: ConversationUiMessageItem, path: string, activeScope: boolean = false) { - Column({ space: 8 }) { - Row({ space: 8 }) { - Text('↳') - .width(18) - .fontSize(13) - .fontColor(MUTED) - .textAlign(TextAlign.Center) - Text(this.subagentTitle(entry)) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - if (entry.tool && this.isRunningTool(entry.tool)) { - ChatTypingDots() - } - if (entry.content && entry.content.trim().length > 0 && !this.isTextEntry(entry) && !this.isThinkingEntry(entry)) { - this.MessageText(entry.content, activeScope && (!entry.subItems || entry.subItems.length === 0), `${this.item.id}-${path}-subagent`) - } - if (entry.subItems && entry.subItems.length > 0) { - this.ChildItems(entry.subItems, path, activeScope) + SubagentTaskCard({ + title: this.subagentTitle(entry), + running: !!(entry.tool && this.isRunningTool(entry.tool)), + content: this.subagentBody(entry), + hasChildren: !!(entry.subItems && entry.subItems.length > 0), + childItems: () => { + if (entry.subItems && entry.subItems.length > 0) { + this.ChildItems(entry.subItems, path, activeScope) + } } - } - .width('100%') - .padding({ left: 10, right: 10, top: 8, bottom: 8 }) - .backgroundColor(SOFT) - .borderRadius(12) - .border({ width: 1, color: LINE }) + }) } @Builder @@ -285,10 +275,7 @@ export struct ChatMessageBubble { status, keepExpandedWhenDone: false, streaming, - streamKey, - onCopyText: (body: string) => { - this.onCopyMessage(body); - } + streamKey }) } @@ -350,11 +337,7 @@ export struct ChatMessageBubble { } private visibleMessageText(item: ConversationUiMessage): string { - const text = item.text.trim(); - if (text === '(空消息)' || text === '(empty message)') { - return ''; - } - return text; + return ConversationMessageRenderPolicy.visibleText(item.text); } private shouldShowTypingDots(item: ConversationUiMessage): boolean { @@ -384,7 +367,8 @@ export struct ChatMessageBubble { } private shouldPinThinkingToBottom(item: ConversationUiMessage): boolean { - return this.isStreamingAssistant() && + return AssistantProcessLayoutPolicy.pinsThinkingAfterTools() && + this.isStreamingAssistant() && this.currentThinkingText(item).length > 0; } @@ -395,17 +379,52 @@ export struct ChatMessageBubble { return this.currentThinkingText(item); } - private shouldOmitStructuredThinking(item: ConversationUiMessage): boolean { - return this.shouldPinThinkingToBottom(item) || - (this.isStreamingAssistant() && this.hasVisibleAssistantOutput(item)); + private leadingThinkingText(item: ConversationUiMessage): string { + if (this.shouldPinThinkingToBottom(item)) { + return ''; + } + if (this.lastThinkingText(item.items || []).length > 0) { + return ''; + } + return (item.thinking || '').trim(); + } + + private leadingThinkingStatus(item: ConversationUiMessage): string { + return ThinkingPresentationPolicy.liveThinkingStatus( + item.status, + this.hasLaterProcessOutput(item) + ); } - private hasVisibleAssistantOutput(item: ConversationUiMessage): boolean { - if (this.lastTopLevelText(item.items || []).length > 0) { + private hasLaterProcessOutput(item: ConversationUiMessage): boolean { + if (item.tools && item.tools.length > 0) { return true; } const visible = this.visibleMessageText(item); - return visible.length > 0 && visible !== (item.thinking || '').trim(); + if (visible.length > 0 && visible !== (item.thinking || '').trim()) { + return true; + } + return this.hasToolOrTextItems(item.items || []); + } + + private hasToolOrTextItems(items: ConversationUiMessageItem[]): boolean { + return items.some((entry: ConversationUiMessageItem) => { + if (entry.tool || this.isTextEntry(entry)) { + return true; + } + return !!entry.subItems && this.hasToolOrTextItems(entry.subItems); + }); + } + + private subagentBody(entry: ConversationUiMessageItem): string { + if (!entry.content || this.isTextEntry(entry) || this.isThinkingEntry(entry)) { + return ''; + } + return entry.content; + } + + private shouldOmitStructuredThinking(_item: ConversationUiMessage): boolean { + return AssistantProcessLayoutPolicy.omitsInFlowThinkingWhileStreaming(); } private currentThinkingText(item: ConversationUiMessage): string { @@ -655,54 +674,11 @@ export struct ChatMessageBubble { } private shouldFoldIntoActivityGroup(entry: ConversationUiMessageItem): boolean { - if (this.isThinkingEntry(entry)) { - return true; - } - if (!entry.tool) { - return false; - } - return this.shouldFoldToolWithThinking(entry.tool); - } - - private shouldFoldToolWithThinking(tool: ConversationUiToolStatus): boolean { - if (!this.isCollapsibleActivityTool(tool)) { - return false; - } - if (this.hasToolError(tool) || this.isPendingTool(tool) || this.isRunningTool(tool) || this.isQuestionTool(tool)) { - return false; - } - return this.isCompletedTool(tool) || this.isCancelledTool(tool); - } - - private isCollapsibleActivityTool(tool: ConversationUiToolStatus): boolean { - const kind = this.activityToolKind(tool); - return kind === 'read' || kind === 'search'; - } - - private activityToolKind(tool: ConversationUiToolStatus): string { - const raw = tool.name || ''; - const normalized = this.normalizedToolName(tool); - if (raw === 'Read' || raw === 'LS' || - normalized === 'read' || - normalized === 'read_file' || - normalized === 'ls' || - normalized === 'list_directory') { - return 'read'; - } - if (raw === 'Grep' || - raw === 'Glob' || - raw === 'WebSearch' || - raw === 'SemanticSearch' || - normalized === 'grep' || - normalized === 'glob' || - normalized === 'websearch' || - normalized === 'web_search' || - normalized === 'semanticsearch' || - normalized === 'semantic_search' || - normalized.indexOf('search') >= 0) { - return 'search'; - } - return ''; + return ActivityGroupPolicy.shouldFoldEntry( + this.isThinkingEntry(entry), + this.isSubagentEntry(entry), + entry.tool + ); } private activityGroupTools(group: StructuredRenderGroup): ConversationUiToolStatus[] { @@ -715,19 +691,46 @@ export struct ChatMessageBubble { return tools; } - private isRenderableActivityThinking( - entry: ConversationUiMessageItem, - status: string, - streaming: boolean - ): boolean { - if (!this.isThinkingEntry(entry)) { - return false; - } - if (this.hasVisibleAssistantOutput(this.item)) { - return false; - } - const normalized = (status || '').toLowerCase(); - return streaming || normalized === 'active' || normalized === 'running'; + private activityThinkingCount(group: StructuredRenderGroup): number { + let count = 0; + group.items.forEach((entry: ConversationUiMessageItem) => { + if (this.isThinkingEntry(entry)) { + count += 1; + } + }); + return count; + } + + private activityThinkingText(group: StructuredRenderGroup): string { + const parts: string[] = []; + group.items.forEach((entry: ConversationUiMessageItem) => { + if (this.isThinkingEntry(entry)) { + parts.push(entry.content || ''); + } + }); + return ThinkingPresentationPolicy.mergedText(parts); + } + + private activityThinkingStatus(group: StructuredRenderGroup): string { + const statuses: string[] = []; + const streamings: boolean[] = []; + group.items.forEach((entry: ConversationUiMessageItem, index: number) => { + if (this.isThinkingEntry(entry)) { + statuses.push(group.itemStatuses[index] || ''); + streamings.push(group.itemStreaming[index] || false); + } + }); + return ThinkingPresentationPolicy.mergedStatusValue(statuses, streamings); + } + + private activityThinkingStreaming(group: StructuredRenderGroup): boolean { + const streamings: boolean[] = []; + group.items.forEach((entry: ConversationUiMessageItem, index: number) => { + if (this.isThinkingEntry(entry)) { + streamings.push(group.itemStreaming[index] || false); + } + }); + return ThinkingPresentationPolicy.mergedStreaming(streamings); } private stableToolHash(tools: ConversationUiToolStatus[]): string { @@ -901,6 +904,9 @@ export struct ChatMessageBubble { } private structuredItemKey(entry: ConversationUiMessageItem, path: string): string { + if (this.isThinkingEntry(entry)) { + return `${path}-thinking`; + } if (entry.tool && entry.tool.id) { return `${path}-tool-${entry.tool.id}`; } @@ -920,12 +926,12 @@ export struct ChatMessageBubble { private activityGroupKey(items: ConversationUiMessageItem[], path: string): string { const parts: string[] = []; items.forEach((entry: ConversationUiMessageItem, index: number) => { - const content = entry.content || ''; const tool = entry.tool; + const thinking = this.isThinkingEntry(entry); parts.push([ `${index}`, entry.type || '', - `${content.length}:${this.stableTextHash(content)}`, + thinking ? 'thinking' : `${(entry.content || '').length}:${this.stableTextHash(entry.content || '')}`, tool ? `${tool.id || ''}:${tool.name || ''}:${tool.status || ''}` : '' ].join(':')); }); @@ -936,16 +942,6 @@ export struct ChatMessageBubble { return (tool.name || 'Tool').replace(/[\s-]/g, '_').toLowerCase(); } - private isCompletedTool(tool: ConversationUiToolStatus): boolean { - const normalized = (tool.status || '').toLowerCase(); - return normalized === 'completed' || normalized === 'done' || normalized === 'success'; - } - - private isCancelledTool(tool: ConversationUiToolStatus): boolean { - const normalized = (tool.status || '').toLowerCase(); - return normalized === 'cancelled' || normalized === 'canceled'; - } - private isRunningTool(tool: ConversationUiToolStatus): boolean { const normalized = (tool.status || '').toLowerCase(); return normalized === 'running' || normalized === 'active'; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index 06f325649c..afe6df14f7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -1,4 +1,5 @@ import { ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, toConversationUiMessage } from '../state/ConversationUiModels'; +import { ConversationMessageRenderPolicy } from '../policy/ConversationMessageRenderPolicy'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; import { ChatSurface } from '../state/ChatSurface'; @@ -44,6 +45,7 @@ export struct ChatTimeline { // difference between the two readings. aboutToAppear(): void { RemoteLogger.info(`chat timeline mounted surface=${this.surface} items=${this.timelineItems.length}`); + this.logTimelineProjection('mount'); } aboutToDisappear(): void { @@ -54,6 +56,7 @@ export struct ChatTimeline { // new bubble arriving is a layout change the list does not chase on its own. @Monitor('timelineRevision') onTimelineChanged(): void { + this.logTimelineProjection('revision'); if (!this.stickToBottom) { return; } @@ -163,7 +166,7 @@ export struct ChatTimeline { .onAppear(() => { this.logRenderedItem(item); }) - } else if (item.message && this.shouldRenderMessage(toConversationUiMessage(item.message))) { + } else if (item.message && this.shouldRenderTimelineMessage(item)) { ListItem() { this.MessageBubble(item) } @@ -175,7 +178,37 @@ export struct ChatTimeline { private logRenderedItem(item: ChatTimelineItem): void { const message = item.message; - RemoteLogger.info(`chat timeline item appear revision=${this.timelineRevision} type=${item.type} id=${item.id} status=${message ? message.status : ''} textLength=${message ? message.text.length : 0}`); + const uiMessage = message ? toConversationUiMessage(message) : undefined; + const thinkingLength = uiMessage && uiMessage.thinking ? uiMessage.thinking.length : 0; + const toolCount = uiMessage && uiMessage.tools ? uiMessage.tools.length : 0; + const itemCount = uiMessage && uiMessage.items ? uiMessage.items.length : 0; + RemoteLogger.info( + `chat timeline item appear revision=${this.timelineRevision} type=${item.type} id=${item.id} ` + + `status=${uiMessage ? uiMessage.status : ''} textLength=${uiMessage ? uiMessage.text.length : 0} ` + + `thinkingLength=${thinkingLength} toolCount=${toolCount} itemCount=${itemCount} ` + + `itemTypes=${this.itemTypeSummary(uiMessage && uiMessage.items ? uiMessage.items : [])}` + ); + } + + private itemTypeSummary(items: ConversationUiMessageItem[]): string { + if (items.length === 0) { + return ''; + } + const parts: string[] = []; + items.forEach((entry: ConversationUiMessageItem) => { + if (entry.tool && entry.tool.name) { + parts.push(entry.tool.name); + } else if ((entry.type || '').length > 0) { + parts.push(entry.type || ''); + } else { + parts.push('item'); + } + }); + const summary = parts.join(','); + if (summary.length > 80) { + return summary.substring(0, 80); + } + return summary; } @Builder @@ -218,32 +251,44 @@ export struct ChatTimeline { }) } - private shouldRenderMessage(item: ConversationUiMessage): boolean { - return item.role === 'user' || - this.visibleMessageText(item).length > 0 || - (item.thinking || '').trim().length > 0 || - (item.tools && item.tools.length > 0) || - this.hasRenderableItems(item.items || []) || - (item.images && item.images.length > 0) || - (item.status || '').toLowerCase() === 'active' || - item.status === 'failed'; + private shouldRenderTimelineMessage(item: ChatTimelineItem): boolean { + if (!item.message) { + return false; + } + try { + const uiMessage = toConversationUiMessage(item.message); + const render = ConversationMessageRenderPolicy.shouldRender(uiMessage); + RemoteLogger.info( + `chat timeline decide type=${item.type} id=${item.id} role=${uiMessage.role} ` + + `render=${render ? 1 : 0} text=${uiMessage.text ? uiMessage.text.length : -1} ` + + `thinking=${uiMessage.thinking ? uiMessage.thinking.length : 0} ` + + `tools=${uiMessage.tools ? uiMessage.tools.length : 0} ` + + `items=${uiMessage.items ? uiMessage.items.length : 0}` + ); + return render; + } catch (err) { + RemoteLogger.error(`chat timeline decide failed type=${item.type} id=${item.id} err=${err}`); + return false; + } } - private hasRenderableItems(items: ConversationUiMessageItem[]): boolean { - return items.some((entry: ConversationUiMessageItem) => { - if ((entry.content || '').trim().length > 0 || !!entry.tool) { - return true; - } - return !!entry.subItems && this.hasRenderableItems(entry.subItems); + private logTimelineProjection(reason: string): void { + const parts: string[] = []; + this.timelineItems.forEach((item: ChatTimelineItem) => { + const message = item.message; + parts.push( + `${item.type}:${item.id}:role=${message ? message.role : 'none'}:` + + `text=${message && message.text ? message.text.length : -1}:` + + `thinking=${message && message.thinking ? message.thinking.length : 0}:` + + `tools=${message && message.tools ? message.tools.length : 0}:` + + `items=${message && message.items ? message.items.length : 0}` + ); }); - } - - private visibleMessageText(item: ConversationUiMessage): string { - const text = item.text.trim(); - if (text === '(空消息)' || text === '(empty message)') { - return ''; - } - return text; + const summary = parts.join(' | '); + RemoteLogger.info( + `chat timeline project reason=${reason} revision=${this.timelineRevision} ` + + `count=${this.timelineItems.length} ${summary.length > 600 ? summary.substring(0, 600) : summary}` + ); } private emptyChatIcon(): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets index b3cba9367c..a2f5606373 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationHeader.ets @@ -85,7 +85,7 @@ export struct ConversationHeader { if (this.showSidebarRestoreButton) { SidebarToggleButton({ restore: true, - controlSize: 48, + controlSize: 44, onToggle: this.onRestoreSidebar }) } else if (this.showBackButton) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets index e94ff9e9cf..835c78d0f4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets @@ -33,6 +33,7 @@ export struct ConversationRouteSurface { @Param showBackButton: boolean = false; @Param showSidebarRestoreButton: boolean = false; @Param useWidePresentation: boolean = false; + @Param hoverOperateMode: boolean = false; @Param contentHorizontalOffset: number = 0; @Event onRestoreSidebar: () => void = () => {}; @@ -54,6 +55,7 @@ export struct ConversationRouteSurface { modelCatalog: toConversationUiModelCatalog(this.remotePageState.conversation.modelCatalog), selectedModelId: this.remoteCreateState.selectedModelId, showSidebarRestoreButton: this.showSidebarRestoreButton, + hoverOperateMode: this.hoverOperateMode, onRestoreSidebar: this.onRestoreSidebar, onBack: this.actions.onRemoteCreate.back, onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, @@ -83,6 +85,7 @@ export struct ConversationRouteSurface { showSidebarRestoreButton: this.showSidebarRestoreButton, composerPresentation: this.useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Compact, contentHorizontalOffset: this.contentHorizontalOffset, + hoverOperateMode: this.hoverOperateMode, onRestoreSidebar: this.onRestoreSidebar, onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(this.route, intent) }) 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 51f4dd3e04..6f2951c77f 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 @@ -61,6 +61,7 @@ export struct ConversationView { @Param composerPresentation: ComposerPresentation = ComposerPresentation.Compact; @Param composerInputId: string = 'conversation-composer-input'; @Param contentHorizontalOffset: number = 0; + @Param hoverOperateMode: boolean = false; @BuilderParam aboveComposer: () => void = this.EmptyBuilder; @Event onOpenSidebar: () => void = () => {}; @Event onRestoreSidebar: () => void = () => {}; @@ -108,38 +109,11 @@ export struct ConversationView { Stack() { Column() { this.Header() - Column() { - if (this.shouldShowStatusBar()) { - this.ExecutionStatusBar() - } - if (this.isLoadingConversation) { - ConversationLoadingState({ - maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 800 : 0 - }) - .layoutWeight(1) - } else if (this.surface === ChatSurface.Create) { - Blank() - .layoutWeight(1) - .width('100%') - .onClick(() => { - this.onBackgroundTap(); - }) - } else if (this.shouldCenterInlineStatus()) { - this.CenteredInlineStatus() - } else { - this.MessageList() - } - if (this.inlineStatusText.length > 0 && !this.shouldCenterInlineStatus()) { - Text(this.inlineStatusText) - .fontSize(12).lineHeight(18).fontColor(MUTED).width('100%') - .padding({ left: 20, right: 20, bottom: 8 }) - } - this.Composer() + if (this.hoverOperateMode) { + this.HoverBody() + } else { + this.StandardBody() } - .layoutWeight(1) - .width('100%') - .translate({ x: this.contentHorizontalOffset, y: 0 }) - .animation({ duration: 220, curve: Curve.EaseInOut }) } .width('100%').height('100%').backgroundColor(PAGE_BG) } @@ -148,6 +122,68 @@ export struct ConversationView { .backgroundColor(PAGE_BG) } + @Builder + private StandardBody() { + Column() { + this.ConversationBody() + this.Composer() + } + .layoutWeight(1) + .width('100%') + .translate({ x: this.contentHorizontalOffset, y: 0 }) + .animation({ duration: 220, curve: Curve.EaseInOut }) + } + + @Builder + private HoverBody() { + FolderStack() { + Column() { + this.ConversationBody() + } + .width('100%') + .height('100%') + .padding({ bottom: 12 }) + Column() { + this.Composer() + } + .width('100%') + } + .alignContent(Alignment.Bottom) + .layoutWeight(1) + .width('100%') + .enableAnimation(true) + .autoHalfFold(true) + } + + @Builder + private ConversationBody() { + if (this.shouldShowStatusBar()) { + this.ExecutionStatusBar() + } + if (this.isLoadingConversation) { + ConversationLoadingState({ + maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 800 : 0 + }) + .layoutWeight(1) + } else if (this.surface === ChatSurface.Create) { + Blank() + .layoutWeight(1) + .width('100%') + .onClick(() => { + this.onBackgroundTap(); + }) + } else if (this.shouldCenterInlineStatus()) { + this.CenteredInlineStatus() + } else { + this.MessageList() + } + if (this.inlineStatusText.length > 0 && !this.shouldCenterInlineStatus()) { + Text(this.inlineStatusText) + .fontSize(12).lineHeight(18).fontColor(MUTED).width('100%') + .padding({ left: 20, right: 20, bottom: 8 }) + } + } + @Builder Header() { ConversationHeader({ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets index c19ef8aa74..5a64e50332 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets @@ -18,6 +18,7 @@ export struct ConversationViewHost { @Param showSidebarRestoreButton: boolean = false; @Param composerPresentation: ComposerPresentation = ComposerPresentation.Compact; @Param contentHorizontalOffset: number = 0; + @Param hoverOperateMode: boolean = false; @Param onRestoreSidebar: () => void = () => {}; @Param onIntent: (intent: ConversationIntent) => void = (_intent: ConversationIntent) => {}; @@ -53,6 +54,7 @@ export struct ConversationViewHost { showSidebarRestoreButton: this.showSidebarRestoreButton, composerPresentation: this.composerPresentation, contentHorizontalOffset: this.contentHorizontalOffset, + hoverOperateMode: this.hoverOperateMode, onOpenSidebar: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.OpenSidebar)), onRestoreSidebar: this.onRestoreSidebar, onBack: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Back)), diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets index 2f501cdc29..38b9c38a6b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets @@ -14,6 +14,7 @@ export struct RemoteCreateSessionView { @Param state: RemoteCreateSessionState = new RemoteCreateSessionState(); @Param presentation: ComposerPresentation = ComposerPresentation.Create; @Param showSidebarRestoreButton: boolean = false; + @Param hoverOperateMode: boolean = false; @Param isVoiceListening: boolean = false; @Param modelCatalog: ConversationUiModelCatalog = { version: 0, @@ -55,6 +56,7 @@ export struct RemoteCreateSessionView { showBackButton: true, showSidebarButton: false, showSidebarRestoreButton: this.showSidebarRestoreButton, + hoverOperateMode: this.hoverOperateMode, aboveComposer: () => { this.ContextControls(); }, 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 new file mode 100644 index 0000000000..b40887f65a --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets @@ -0,0 +1,87 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ToolCollapsePolicy } from '../policy/ToolCollapsePolicy'; +import { ChatTypingDots } from './ChatMessageChrome'; +import { INK, LINE, MUTED, SOFT } from './Theme'; + +@ComponentV2 +export struct SubagentTaskCard { + @Param title: string = ''; + @Param running: boolean = false; + @Param content: string = ''; + @Param hasChildren: boolean = false; + @BuilderParam childItems: () => void = this.EmptyChildren; + @Local expanded: boolean = false; + @Local userToggled: boolean = false; + + aboutToAppear(): void { + this.userToggled = false; + this.expanded = ToolCollapsePolicy.defaultSubagentExpanded(this.running); + } + + @Monitor('running') + onRunningChanged(): void { + this.expanded = ToolCollapsePolicy.subagentExpandedForStatus( + this.running, + this.userToggled, + this.expanded + ); + if (!this.running) { + this.userToggled = false; + } + } + + build() { + Column({ space: 8 }) { + Row({ space: 8 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph(this.expanded ? $r('sys.symbol.chevron_down') : $r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + .width(16) + .height(16) + } + .width(18) + .height(32) + Text(this.title) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .height(32) + .alignItems(VerticalAlign.Center) + .onClick(() => { + this.userToggled = this.running; + this.expanded = !this.expanded; + }) + .accessibilityText(this.expanded ? RemoteI18n.t('chat.showLess') : RemoteI18n.t('chat.showMore')) + if (this.expanded) { + if (this.running) { + ChatTypingDots() + } + if (this.content.trim().length > 0) { + Text(this.content) + .fontSize(13) + .lineHeight(19) + .fontColor(MUTED) + .width('100%') + } + if (this.hasChildren) { + this.childItems() + } + } + } + .width('100%') + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .backgroundColor(SOFT) + .borderRadius(12) + .border({ width: 1, color: LINE }) + } + + @Builder + EmptyChildren() { + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets index c1f35ba898..216b458b39 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets @@ -1,6 +1,6 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ThinkingPresentationPolicy } from '../policy/ThinkingPresentationPolicy'; -import { INK, MUTED, SOFT } from './Theme'; +import { MUTED } from './Theme'; @ComponentV2 export struct ThinkingBlock { @@ -9,12 +9,13 @@ export struct ThinkingBlock { @Param keepExpandedWhenDone: boolean = false; @Param streaming: boolean = false; @Param streamKey: string = ''; - @Event onCopyText: (text: string) => void = (_text: string) => {}; @Local dotPhase: number = 0; @Local expanded: boolean = false; + @Local userToggled: boolean = false; private dotTimerId: number = 0; aboutToAppear(): void { + this.userToggled = false; this.expanded = ThinkingPresentationPolicy.defaultExpanded(this.isRunning(), this.keepExpandedWhenDone); this.syncDotTimer(); } @@ -25,7 +26,15 @@ export struct ThinkingBlock { @Monitor('status') onStatusChanged(): void { - this.expanded = ThinkingPresentationPolicy.defaultExpanded(this.isRunning(), this.keepExpandedWhenDone); + this.expanded = ThinkingPresentationPolicy.expandedForStatus( + this.isRunning(), + this.keepExpandedWhenDone, + this.userToggled, + this.expanded + ); + if (!this.isRunning()) { + this.userToggled = false; + } this.syncDotTimer(); } @@ -75,25 +84,15 @@ export struct ThinkingBlock { .height(32) .alignItems(VerticalAlign.Center) .onClick(() => { - if (!this.isRunning() && this.hasBody()) { - this.expanded = !this.expanded; + if (!ThinkingPresentationPolicy.allowsToggle(this.hasBody())) { + return; } + this.userToggled = this.isRunning(); + this.expanded = !this.expanded; }) .accessibilityText(this.isRunning() ? RemoteI18n.t('chat.thinkingInProgress') : RemoteI18n.t('chat.thinkingComplete')) - if (this.hasBody() && !this.isRunning()) { - Text(RemoteI18n.t('common.copy')) - .fontSize(13) - .fontColor(INK) - .padding({ left: 8, right: 8, top: 6, bottom: 6 }) - .backgroundColor(SOFT) - .borderRadius(8) - .accessibilityText(RemoteI18n.t('common.copy')) - .onClick(() => { - this.onCopyText(this.text); - }) - } } .width('100%') .height(32) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index 6bbe8f7641..76129e0c28 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -1,6 +1,7 @@ import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from '../state/ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ToolFileReference, ToolFileReferenceResolver } from '../../services/ToolFileReferenceResolver'; +import { ToolCollapseGroup, ToolCollapsePolicy } from '../policy/ToolCollapsePolicy'; import { CARD, FILE_LINK, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; import { ToolGlyph } from './ToolGlyphs'; import { ToolConfirmationPanel, ToolQuestionAnswerPanel } from './ToolInteractionPanels'; @@ -336,47 +337,31 @@ export struct ToolStatusList { } private toolRenderEntries(): ToolRenderEntry[] { - if (this.expanded) { - return this.expandedToolRenderEntries(); - } - + const groups = this.expanded ? + ToolCollapsePolicy.expandedGroups(this.tools) : + ToolCollapsePolicy.collapsedGroups(this.tools); const entries: ToolRenderEntry[] = []; - let collapsed: ConversationUiToolStatus[] = []; - let collapsedStart = 0; - this.tools.forEach((tool: ConversationUiToolStatus, index: number) => { - if (this.shouldCollapseExploreTool(tool)) { - if (collapsed.length === 0) { - collapsedStart = index; - } - collapsed.push(tool); - return; - } - this.flushCollapsedTools(entries, collapsed, collapsedStart); - collapsed = []; - entries.push(this.toolEntry(tool, index)); + groups.forEach((group: ToolCollapseGroup) => { + entries.push(this.entryFromGroup(group)); }); - this.flushCollapsedTools(entries, collapsed, collapsedStart); return entries; } - private expandedToolRenderEntries(): ToolRenderEntry[] { - const entries: ToolRenderEntry[] = []; - let collapsed: ConversationUiToolStatus[] = []; - let collapsedStart = 0; - this.tools.forEach((tool: ConversationUiToolStatus, index: number) => { - if (this.shouldCollapseExploreTool(tool)) { - if (collapsed.length === 0) { - collapsedStart = index; - } - collapsed.push(tool); - return; - } - this.flushExpandedCollapsedTools(entries, collapsed, collapsedStart); - collapsed = []; - entries.push(this.toolEntry(tool, index)); - }); - this.flushExpandedCollapsedTools(entries, collapsed, collapsedStart); - return entries; + private entryFromGroup(group: ToolCollapseGroup): ToolRenderEntry { + if (group.type === 'summary') { + const stats = this.collapsedToolStats(group.tools); + return { + type: 'summary', + key: `summary-${group.startIndex}-${group.tools.length}-${stats.readCount}-${stats.searchCount}-${stats.commandCount}-${stats.otherCount}`, + tools: group.tools, + readCount: stats.readCount, + searchCount: stats.searchCount, + commandCount: stats.commandCount, + otherCount: stats.otherCount + }; + } + const tool = group.tools[0]; + return this.toolEntry(tool, group.startIndex); } private toolEntry(tool: ConversationUiToolStatus, index: number): ToolRenderEntry { @@ -392,52 +377,6 @@ export struct ToolStatusList { }; } - private flushCollapsedTools( - entries: ToolRenderEntry[], - tools: ConversationUiToolStatus[], - startIndex: number - ): void { - if (tools.length === 0) { - return; - } - if (tools.length < 2) { - tools.forEach((tool: ConversationUiToolStatus, index: number) => { - entries.push(this.toolEntry(tool, startIndex + index)); - }); - return; - } - const stats = this.collapsedToolStats(tools); - entries.push({ - type: 'summary', - key: `summary-${startIndex}-${tools.length}-${stats.readCount}-${stats.searchCount}-${stats.commandCount}-${stats.otherCount}`, - tools, - readCount: stats.readCount, - searchCount: stats.searchCount, - commandCount: stats.commandCount, - otherCount: stats.otherCount - }); - } - - private flushExpandedCollapsedTools( - entries: ToolRenderEntry[], - tools: ConversationUiToolStatus[], - startIndex: number - ): void { - if (tools.length === 0) { - return; - } - if (tools.length < 2) { - tools.forEach((tool: ConversationUiToolStatus, index: number) => { - entries.push(this.toolEntry(tool, startIndex + index)); - }); - return; - } - this.flushCollapsedTools(entries, tools, startIndex); - tools.forEach((tool: ConversationUiToolStatus, index: number) => { - entries.push(this.toolEntry(tool, startIndex + index)); - }); - } - private collapsedToolStats(tools: ConversationUiToolStatus[]): ToolRenderEntry { let readCount = 0; let searchCount = 0; @@ -449,7 +388,7 @@ export struct ToolStatusList { readCount += 1; } else if (kind === 'search') { searchCount += 1; - } else if (kind === 'command') { + } else if (this.isCommandTool(tool)) { commandCount += 1; } else { otherCount += 1; @@ -466,19 +405,6 @@ export struct ToolStatusList { }; } - private shouldCollapseExploreTool(tool: ConversationUiToolStatus): boolean { - if (this.hasToolError(tool) || this.isPendingConfirmation(tool) || this.isQuestionTool(tool) || - this.isRunningTool(tool)) { - return false; - } - return this.isCollapsibleExploreTool(tool) && (this.isCompletedTool(tool) || this.isCancelledTool(tool)); - } - - private isCollapsibleExploreTool(tool: ConversationUiToolStatus): boolean { - const kind = this.exploreToolKind(tool); - return kind === 'read' || kind === 'search'; - } - private exploreToolKind(tool: ConversationUiToolStatus): string { const raw = tool.name || ''; const normalized = this.normalizedToolName(tool); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index 8db93d9fac..616833fc5d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -246,7 +246,8 @@ export struct WideConversationHost { if (this.wideMasterPaneCollapsed) { SidebarToggleButton({ restore: true, controlSize: 44, onToggle: this.onRestoreMasterPane }) - .position({ x: this.currentDetailOffset() + 12, y: 12 }).zIndex(2) + .position({ x: 12, y: 12 }).zIndex(2) + .hitTestBehavior(HitTestMode.Default) .transition(TransitionEffect.scale({ x: 0.9, y: 0.9 }).combine(TransitionEffect.opacity(0)) .animation({ duration: 180, curve: Curve.EaseOut })) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ActivityGroupPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ActivityGroupPolicy.ets new file mode 100644 index 0000000000..762bf9d127 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ActivityGroupPolicy.ets @@ -0,0 +1,25 @@ +import { ConversationUiToolStatus } from '../state/ConversationUiModels'; +import { ToolCollapsePolicy } from './ToolCollapsePolicy'; + +export class ActivityGroupPolicy { + static shouldFoldEntry( + isThinking: boolean, + isSubagent: boolean, + tool?: ConversationUiToolStatus + ): boolean { + if (isThinking) { + return true; + } + if (isSubagent || !tool) { + return false; + } + return ToolCollapsePolicy.shouldFoldIntoSummary(tool); + } + + static thinkingHeaderCount(thinkingPartCount: number): number { + if (thinkingPartCount <= 0) { + return 0; + } + return 1; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/AssistantProcessLayoutPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/AssistantProcessLayoutPolicy.ets new file mode 100644 index 0000000000..703986caab --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/AssistantProcessLayoutPolicy.ets @@ -0,0 +1,53 @@ +export class AssistantProcessLayoutPolicy { + /** + * Streaming used to pin the current thinking block after tools so the + * viewport could chase it. That inverts chronology: a Task card appears + * above the thought that produced it. Keep thinking in document order. + */ + static pinsThinkingAfterTools(): boolean { + return false; + } + + static omitsInFlowThinkingWhileStreaming(): boolean { + return false; + } + + static shouldRenderActivityThinking(hasContent: boolean): boolean { + return hasContent; + } + + static processBlockOrder( + thinkingText: string, + hasStructuredThinking: boolean, + visibleText: string, + showTyping: boolean, + toolCount: number + ): string[] { + const order: string[] = []; + const hasLeadingThinking = thinkingText.trim().length > 0 && !hasStructuredThinking; + if (hasLeadingThinking || hasStructuredThinking) { + order.push('thinking'); + } + if (toolCount > 0) { + order.push('tools'); + } + if (showTyping) { + order.push('typing'); + } + if (visibleText.trim().length > 0 && visibleText.trim() !== thinkingText.trim()) { + order.push('text'); + } + return order; + } + + static activityGroupBlockOrder(hasThinking: boolean, toolCount: number): string[] { + const order: string[] = []; + if (hasThinking) { + order.push('thinking'); + } + if (toolCount > 0) { + order.push('tools'); + } + return order; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets index e01b5e890f..600e44f52f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets @@ -47,35 +47,40 @@ class ConversationLayoutSegment { } export class ConversationLayoutPolicy { - static readonly TABLET_DEVICE_TYPE: string = 'tablet'; - static readonly WIDE_LAYOUT_MIN_WIDTH: number = 720; + /** Official GridRow sm|md boundary: 320 / 600 / 840 / 1440 vp. */ + static readonly MD_MIN_WIDTH: number = 600; + static readonly LG_MIN_WIDTH: number = 840; + static readonly XL_MIN_WIDTH: number = 1440; + static readonly WIDE_LAYOUT_MIN_WIDTH: number = 600; + static readonly EXTRA_WIDE_MIN_WIDTH: number = 840; static readonly FALLBACK_MASTER_PANE_WIDTH: number = 344; static readonly MIN_MASTER_PANE_WIDTH: number = 280; static readonly MIN_DETAIL_PANE_WIDTH: number = 360; - static readonly EXTRA_WIDE_MIN_WIDTH: number = 1080; + static readonly SYNTHETIC_HINGE_WIDTH: number = 16; static useMasterDetail( viewportWidth: number, mediaQueryMatched: boolean, isFolded: boolean, - deviceType: string, creases: ConversationLayoutCrease[], - isExpandedFoldable: boolean = false + isExpandedFoldable: boolean = false, + isHover: boolean = false ): boolean { - if (isFolded) { + if (isFolded || isHover) { return false; } if (!ConversationLayoutPolicy.hasWideViewport(viewportWidth, mediaQueryMatched)) { return false; } - const visibleCreases = ConversationLayoutPolicy.visibleCreases(viewportWidth, creases); + const visibleCreases = ConversationLayoutPolicy.effectiveVerticalCreases( + viewportWidth, + creases, + isExpandedFoldable + ); if (visibleCreases.length > 0) { return ConversationLayoutPolicy.canFitMasterDetail(viewportWidth, visibleCreases); } - if (isExpandedFoldable) { - return ConversationLayoutPolicy.canFitMasterDetail(viewportWidth, []); - } - return ConversationLayoutPolicy.isTabletDevice(deviceType); + return ConversationLayoutPolicy.canFitMasterDetail(viewportWidth, []); } static resolveWideGeometry( @@ -85,23 +90,69 @@ export class ConversationLayoutPolicy { const visibleCreases = ConversationLayoutPolicy.visibleCreases(viewportWidth, creases); const firstCrease = ConversationLayoutPolicy.firstUsableCrease(viewportWidth, visibleCreases); const masterPaneWidth = firstCrease ? firstCrease.left : - ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH; + ConversationLayoutPolicy.fallbackMasterPaneWidth(viewportWidth); const masterDetailGap = firstCrease ? Math.max(0, firstCrease.width) : 0; const detailStart = masterPaneWidth + masterDetailGap; const detailSegments = ConversationLayoutPolicy.detailSegments(viewportWidth, detailStart, visibleCreases); const contentSegment = ConversationLayoutPolicy.widestSegment(detailSegments); - const collapsedContentSegment = ConversationLayoutPolicy.widestSegment( - ConversationLayoutPolicy.detailSegments(viewportWidth, 0, visibleCreases) + const collapsedContentSegment = ConversationLayoutPolicy.collapsedContentSegment( + viewportWidth, + visibleCreases ); return new ConversationLayoutGeometry( masterPaneWidth, masterDetailGap, - visibleCreases.length > 1 || viewportWidth >= ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + visibleCreases.length > 1 || viewportWidth >= ConversationLayoutPolicy.LG_MIN_WIDTH, contentSegment ? contentSegment.left - detailStart : 0, contentSegment ? contentSegment.width : 0, - collapsedContentSegment ? collapsedContentSegment.left : 0, - collapsedContentSegment ? collapsedContentSegment.width : 0 + collapsedContentSegment.left, + collapsedContentSegment.width + ); + } + + private static collapsedContentSegment( + viewportWidth: number, + visibleCreases: ConversationLayoutCrease[] + ): ConversationLayoutSegment { + if (visibleCreases.length <= 1) { + return new ConversationLayoutSegment(0, Math.max(0, viewportWidth)); + } + const widest = ConversationLayoutPolicy.widestSegment( + ConversationLayoutPolicy.detailSegments(viewportWidth, 0, visibleCreases) + ); + return widest ? widest : new ConversationLayoutSegment(0, Math.max(0, viewportWidth)); + } + + static effectiveVerticalCreases( + viewportWidth: number, + creases: ConversationLayoutCrease[], + synthesizeCenterHinge: boolean + ): ConversationLayoutCrease[] { + const visibleCreases = ConversationLayoutPolicy.visibleCreases(viewportWidth, creases); + if (visibleCreases.length > 0) { + return visibleCreases; + } + if (!synthesizeCenterHinge) { + return []; + } + const synthetic = ConversationLayoutPolicy.syntheticCenterCrease(viewportWidth); + return synthetic ? [synthetic] : []; + } + + static syntheticCenterCrease(viewportWidth: number): ConversationLayoutCrease | undefined { + const hinge = ConversationLayoutPolicy.SYNTHETIC_HINGE_WIDTH; + const minimum = ConversationLayoutPolicy.MIN_MASTER_PANE_WIDTH + + ConversationLayoutPolicy.MIN_DETAIL_PANE_WIDTH + hinge; + if (viewportWidth < minimum) { + return undefined; + } + const centered = Math.floor((viewportWidth - hinge) / 2); + const maximumMaster = viewportWidth - hinge - ConversationLayoutPolicy.MIN_DETAIL_PANE_WIDTH; + const masterPaneWidth = Math.max( + ConversationLayoutPolicy.MIN_MASTER_PANE_WIDTH, + Math.min(centered, maximumMaster) ); + return new ConversationLayoutCrease(masterPaneWidth, hinge); } private static detailSegments( @@ -142,12 +193,20 @@ export class ConversationLayoutPolicy { ): boolean { const firstCrease = ConversationLayoutPolicy.firstUsableCrease(viewportWidth, visibleCreases); const masterPaneWidth = firstCrease ? firstCrease.left : - ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH; + ConversationLayoutPolicy.fallbackMasterPaneWidth(viewportWidth); const masterDetailGap = firstCrease ? Math.max(0, firstCrease.width) : 0; return masterPaneWidth >= ConversationLayoutPolicy.MIN_MASTER_PANE_WIDTH && viewportWidth - masterPaneWidth - masterDetailGap >= ConversationLayoutPolicy.MIN_DETAIL_PANE_WIDTH; } + private static fallbackMasterPaneWidth(viewportWidth: number): number { + const maximumMaster = viewportWidth - ConversationLayoutPolicy.MIN_DETAIL_PANE_WIDTH; + if (maximumMaster < ConversationLayoutPolicy.MIN_MASTER_PANE_WIDTH) { + return ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH; + } + return Math.min(ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH, maximumMaster); + } + private static firstUsableCrease( viewportWidth: number, visibleCreases: ConversationLayoutCrease[] @@ -159,11 +218,7 @@ export class ConversationLayoutPolicy { } private static hasWideViewport(viewportWidth: number, mediaQueryMatched: boolean): boolean { - return mediaQueryMatched || viewportWidth >= ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH; - } - - private static isTabletDevice(deviceType: string): boolean { - return deviceType.toLowerCase() === ConversationLayoutPolicy.TABLET_DEVICE_TYPE; + return mediaQueryMatched || viewportWidth >= ConversationLayoutPolicy.MD_MIN_WIDTH; } private static visibleCreases( diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationMessageRenderPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationMessageRenderPolicy.ets new file mode 100644 index 0000000000..e9c075c733 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationMessageRenderPolicy.ets @@ -0,0 +1,44 @@ +import { ConversationUiMessage, ConversationUiMessageItem } from '../state/ConversationUiModels'; + +export class ConversationMessageRenderPolicy { + static visibleText(text?: string): string { + const value = (text || '').trim(); + if (value === '(空消息)' || value === '(empty message)') { + return ''; + } + return value; + } + + static shouldRender(item: ConversationUiMessage): boolean { + if ((item.role || '').toLowerCase() === 'user') { + return true; + } + if (ConversationMessageRenderPolicy.visibleText(item.text).length > 0) { + return true; + } + if ((item.thinking || '').trim().length > 0) { + return true; + } + if (item.tools && item.tools.length > 0) { + return true; + } + if (ConversationMessageRenderPolicy.hasRenderableItems(item.items || [])) { + return true; + } + if (item.images && item.images.length > 0) { + return true; + } + const status = (item.status || '').toLowerCase(); + return status === 'active' || status === 'failed'; + } + + static hasRenderableItems(items: ConversationUiMessageItem[]): boolean { + return items.some((entry: ConversationUiMessageItem) => { + if ((entry.content || '').trim().length > 0 || !!entry.tool) { + return true; + } + return !!entry.subItems && ConversationMessageRenderPolicy.hasRenderableItems(entry.subItems); + }); + } + +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ThinkingPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ThinkingPresentationPolicy.ets index bba7401fc2..84df0b152f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ThinkingPresentationPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ThinkingPresentationPolicy.ets @@ -14,4 +14,64 @@ export class ThinkingPresentationPolicy { static defaultExpanded(isRunning: boolean, keepExpandedWhenDone: boolean): boolean { return isRunning || keepExpandedWhenDone; } + + static allowsToggle(hasBody: boolean): boolean { + return hasBody; + } + + /** + * Option 2: the live thinking block starts expanded. The user may collapse it + * while it streams. Completing always folds it back unless keepExpandedWhenDone. + */ + static expandedForStatus( + isRunning: boolean, + keepExpandedWhenDone: boolean, + userToggled: boolean, + currentlyExpanded: boolean + ): boolean { + if (!isRunning) { + return keepExpandedWhenDone; + } + if (userToggled) { + return currentlyExpanded; + } + return true; + } + + static liveThinkingStatus(messageStatus: string, hasLaterOutput: boolean): string { + if (hasLaterOutput) { + return 'done'; + } + return messageStatus; + } + + static mergedText(parts: string[]): string { + const kept: string[] = []; + parts.forEach((part: string) => { + const text = (part || '').trim(); + if (text.length > 0) { + kept.push(text); + } + }); + return kept.join('\n\n'); + } + + static mergedStatusValue(statuses: string[], streamings: boolean[]): string { + const count = statuses.length > streamings.length ? statuses.length : streamings.length; + for (let index = count - 1; index >= 0; index--) { + const streaming = index < streamings.length ? streamings[index] : false; + const status = index < statuses.length ? statuses[index] : ''; + if (streaming || ThinkingPresentationPolicy.isRunning(status)) { + return status.length > 0 ? status : 'active'; + } + } + if (statuses.length > 0 && statuses[statuses.length - 1].length > 0) { + return statuses[statuses.length - 1]; + } + return 'done'; + } + + static mergedStreaming(streamings: boolean[]): boolean { + return streamings.some((streaming: boolean) => streaming); + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolCollapsePolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolCollapsePolicy.ets new file mode 100644 index 0000000000..98ea29c0f4 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolCollapsePolicy.ets @@ -0,0 +1,133 @@ +import { ConversationUiToolStatus } from '../state/ConversationUiModels'; + +export class ToolCollapseGroup { + readonly type: string; + readonly startIndex: number; + readonly tools: ConversationUiToolStatus[]; + + constructor(type: string, startIndex: number, tools: ConversationUiToolStatus[]) { + this.type = type; + this.startIndex = startIndex; + this.tools = tools; + } +} + +export class ToolCollapsePolicy { + static readonly MIN_SUMMARY_COUNT: number = 2; + + static defaultSubagentExpanded(isRunning: boolean): boolean { + return isRunning; + } + + static subagentExpandedForStatus( + isRunning: boolean, + userToggled: boolean, + currentlyExpanded: boolean + ): boolean { + if (!isRunning) { + return false; + } + if (userToggled) { + return currentlyExpanded; + } + return true; + } + + static shouldFoldIntoSummary(tool: ConversationUiToolStatus): boolean { + if (ToolCollapsePolicy.hasError(tool) || + ToolCollapsePolicy.isPending(tool) || + ToolCollapsePolicy.isQuestion(tool) || + ToolCollapsePolicy.isRunning(tool)) { + return false; + } + return ToolCollapsePolicy.isCompleted(tool) || ToolCollapsePolicy.isCancelled(tool); + } + + static collapsedGroups(tools: ConversationUiToolStatus[]): ToolCollapseGroup[] { + return ToolCollapsePolicy.groupTools(tools, false); + } + + static expandedGroups(tools: ConversationUiToolStatus[]): ToolCollapseGroup[] { + return ToolCollapsePolicy.groupTools(tools, true); + } + + private static groupTools( + tools: ConversationUiToolStatus[], + includeMembers: boolean + ): ToolCollapseGroup[] { + const groups: ToolCollapseGroup[] = []; + let folded: ConversationUiToolStatus[] = []; + let foldedStart = 0; + tools.forEach((tool: ConversationUiToolStatus, index: number) => { + if (ToolCollapsePolicy.shouldFoldIntoSummary(tool)) { + if (folded.length === 0) { + foldedStart = index; + } + folded.push(tool); + return; + } + ToolCollapsePolicy.flushFolded(groups, folded, foldedStart, includeMembers); + folded = []; + groups.push(new ToolCollapseGroup('tool', index, [tool])); + }); + ToolCollapsePolicy.flushFolded(groups, folded, foldedStart, includeMembers); + return groups; + } + + private static flushFolded( + groups: ToolCollapseGroup[], + tools: ConversationUiToolStatus[], + startIndex: number, + includeMembers: boolean + ): void { + if (tools.length === 0) { + return; + } + if (tools.length < ToolCollapsePolicy.MIN_SUMMARY_COUNT) { + tools.forEach((tool: ConversationUiToolStatus, offset: number) => { + groups.push(new ToolCollapseGroup('tool', startIndex + offset, [tool])); + }); + return; + } + groups.push(new ToolCollapseGroup('summary', startIndex, tools)); + if (includeMembers) { + tools.forEach((tool: ConversationUiToolStatus, offset: number) => { + groups.push(new ToolCollapseGroup('tool', startIndex + offset, [tool])); + }); + } + } + + private static isRunning(tool: ConversationUiToolStatus): boolean { + const normalized = (tool.status || '').toLowerCase(); + return normalized === 'running' || normalized === 'active'; + } + + private static isCompleted(tool: ConversationUiToolStatus): boolean { + const normalized = (tool.status || '').toLowerCase(); + return normalized === 'completed' || normalized === 'done' || normalized === 'success'; + } + + private static isCancelled(tool: ConversationUiToolStatus): boolean { + const normalized = (tool.status || '').toLowerCase(); + return normalized === 'cancelled' || normalized === 'canceled'; + } + + private static isPending(tool: ConversationUiToolStatus): boolean { + const normalized = (tool.status || '').toLowerCase(); + return normalized === 'pending' || normalized === 'queued' || + normalized === 'pending_confirmation' || normalized === 'needs_confirmation'; + } + + private static isQuestion(tool: ConversationUiToolStatus): boolean { + const raw = tool.name || ''; + const normalized = raw.replace(/[\s-]/g, '_').toLowerCase(); + return raw === 'AskUserQuestion' || normalized === 'askuserquestion' || + normalized === 'ask_user_question'; + } + + private static hasError(tool: ConversationUiToolStatus): boolean { + const normalized = (tool.status || '').toLowerCase(); + return normalized === 'failed' || normalized === 'error' || normalized === 'timeout' || + (tool.error_preview || '').length > 0; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets index 304422b1be..dc1d234f43 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatCommandController.ets @@ -9,6 +9,7 @@ import { RemoteI18n } from '../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; import { RemoteChatCache } from './RemoteChatCache'; import { RemoteLogger } from './RemoteLogger'; +import { TranscriptIntegrityPolicy } from './TranscriptIntegrityPolicy'; export interface RemoteChatCommandClient { getSessionMessages(sessionId: string): Promise; @@ -70,10 +71,15 @@ export class RemoteChatCommandController { /** * Puts a session's transcript on screen, from disk when there is one. * - * A cache hit sends nothing at all: the poller that starts right after this - * asks for `known_msg_count` messages onwards, so whatever arrived while the - * session was closed comes back as an ordinary tail rather than as another - * copy of the whole transcript. + * A cache hit with complete rows sends nothing: the poller that starts right + * after this asks for `known_msg_count` messages onwards, so whatever arrived + * while the session was closed comes back as an ordinary tail rather than as + * another copy of the whole transcript. + * + * Hollow assistants are a miss. The poller will not refill them — it skips + * the rows the phone already counted — so the only way back to a body is a + * full `get_session_messages`. The hollow rows still go on screen first so + * the user messages do not wait on that fetch. */ async loadMessages( sessionId: string, @@ -89,6 +95,15 @@ export class RemoteChatCommandController { } RemoteLogger.info(`messages from cache session=${RemoteChatCommandController.shortId(sessionId)} count=${cached.length}`); this.applyMessages(cached); + if (TranscriptIntegrityPolicy.hasHollowAssistants(cached)) { + RemoteLogger.info( + `hollow assistants in cache session=${RemoteChatCommandController.shortId(sessionId)} ` + + `${TranscriptIntegrityPolicy.describeMessages(cached)}` + ); + await this.cache.forget(sessionId); + await this.reloadMessages(sessionId, canApply); + return; + } this.callbacks.onStatusText(RemoteI18n.t('status.messagesRestored')); return; } @@ -118,6 +133,10 @@ export class RemoteChatCommandController { this.callbacks.onStatusText(RemoteI18n.t('status.loadMessages')); } const result = await this.client.getSessionMessages(sessionId); + RemoteLogger.info( + `get_session_messages mapped session=${RemoteChatCommandController.shortId(sessionId)} ` + + `${TranscriptIntegrityPolicy.describeMessages(result.messages)}` + ); if (canApply(sessionId)) { this.applyMessages(result.messages); this.callbacks.onStatusText(RemoteI18n.t('status.messagesSynced')); @@ -166,7 +185,9 @@ export class RemoteChatCommandController { this.callbacks.onTimelineReady(); // The last thing that happens before ArkUI owns the delay: everything after // this line is build, measure and paint. - RemoteLogger.info(`messages applied to state count=${messages.length}`); + RemoteLogger.info( + `messages applied to state ${TranscriptIntegrityPolicy.describeMessages(messages)}` + ); } /** Drops a session's cached transcript once it no longer exists upstream. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 4dede047bc..58074a47c9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -10,6 +10,7 @@ import { RemoteFileDownloadClient } from './RemoteFileDownloadController'; import { RemoteLogger } from './RemoteLogger'; import { RemoteModelClient } from './RemoteModelController'; import { RemoteResponseMapper } from './RemoteResponseMapper'; +import { TranscriptIntegrityPolicy } from './TranscriptIntegrityPolicy'; import { RemoteSessionClient } from './RemoteSessionController'; import { RemoteToolActionClient } from './RemoteToolActionController'; @@ -261,9 +262,15 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile } async getSessionMessages(sessionId: string): Promise { + const startedAt = Date.now(); const response = await this.send(RemoteCommandFactory.getSessionMessages(sessionId)); const rawMessages = response.messages || []; const messages = rawMessages.map((item: ChatMessageResponse) => RemoteResponseMapper.chatMessage(item)); + RemoteLogger.info( + `get_session_messages done session=${RemoteSessionManager.shortId(sessionId)} ` + + `raw=${rawMessages.length} ${TranscriptIntegrityPolicy.describeMessages(messages)} ` + + `ms=${Date.now() - startedAt}` + ); const result: SessionMessagesResult = { messages, hasMore: false @@ -310,7 +317,7 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile modelCatalog: response.model_catalog }; const rawActive = response.active_turn; - RemoteLogger.info(`poll_session done session=${RemoteSessionManager.shortId(sessionId)} changed=${result.changed ? '1' : '0'} state=${result.sessionState} active=${rawActive ? rawActive.turn_id : ''} activeStatus=${rawActive ? rawActive.status : ''} rawText=${rawActive && rawActive.text ? rawActive.text.length : 0} rawThinking=${rawActive && rawActive.thinking ? rawActive.thinking.length : 0} rawItems=${rawActive && rawActive.items ? rawActive.items.length : 0} rawItemText=${RemoteSessionManager.itemsContentLength(rawActive ? rawActive.items : undefined)} projectedText=${result.activeTurn ? result.activeTurn.text.length : 0} new=${newMessages.length} ms=${Date.now() - startedAt}`); + RemoteLogger.info(`poll_session done session=${RemoteSessionManager.shortId(sessionId)} changed=${result.changed ? '1' : '0'} state=${result.sessionState} active=${rawActive ? rawActive.turn_id : ''} activeStatus=${rawActive ? rawActive.status : ''} rawText=${rawActive && rawActive.text ? rawActive.text.length : 0} rawThinking=${rawActive && rawActive.thinking ? rawActive.thinking.length : 0} rawItems=${rawActive && rawActive.items ? rawActive.items.length : 0} rawItemText=${RemoteSessionManager.itemsContentLength(rawActive ? rawActive.items : undefined)} projectedText=${result.activeTurn ? result.activeTurn.text.length : 0} new=${newMessages.length} ${newMessages.length > 0 ? TranscriptIntegrityPolicy.describeMessages(newMessages) + ' ' : ''}ms=${Date.now() - startedAt}`); return result; } 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 4c1d998cc8..8fbbb14e3b 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 @@ -6,6 +6,7 @@ import { RemoteSession } from '../model/RemoteModels'; import { RemoteI18n } from '../i18n/RemoteI18n'; +import { TranscriptIntegrityPolicy } from './TranscriptIntegrityPolicy'; export class RemoteUiState { static canUseRemote(connectionState: string): boolean { @@ -216,6 +217,10 @@ export class RemoteUiState { private static mergeMessageSnapshot(previous: ChatMessage, incoming: ChatMessage): ChatMessage { const hasIncomingText = (incoming.text || '').trim().length > 0; + const incomingThinking = (incoming.thinking || '').trim(); + const incomingTools = incoming.tools && incoming.tools.length > 0 ? incoming.tools : undefined; + const incomingItems = TranscriptIntegrityPolicy.hasRenderableItems(incoming.items) ? incoming.items : undefined; + const incomingImages = incoming.images && incoming.images.length > 0 ? incoming.images : undefined; return { id: incoming.id, turnId: incoming.turnId || previous.turnId, @@ -224,10 +229,14 @@ export class RemoteUiState { status: incoming.status || previous.status, detail: incoming.detail || previous.detail, timestamp: incoming.timestamp || previous.timestamp, - thinking: incoming.thinking !== undefined ? incoming.thinking : (hasIncomingText ? undefined : previous.thinking), - tools: incoming.tools !== undefined ? incoming.tools : previous.tools, - items: incoming.items !== undefined ? incoming.items : previous.items, - images: incoming.images !== undefined ? incoming.images : previous.images, + // Final text without a thinking field still clears streamed thinking. + // An empty stub must not wipe a body the previous snapshot already had. + thinking: incomingThinking.length > 0 + ? incoming.thinking + : (hasIncomingText ? incoming.thinking : previous.thinking), + tools: incomingTools !== undefined ? incomingTools : previous.tools, + items: incomingItems !== undefined ? incomingItems : previous.items, + images: incomingImages !== undefined ? incomingImages : previous.images, renderVersion: incoming.renderVersion !== undefined ? incoming.renderVersion : previous.renderVersion }; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/TranscriptIntegrityPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/TranscriptIntegrityPolicy.ets new file mode 100644 index 0000000000..aac1b97eaf --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/TranscriptIntegrityPolicy.ets @@ -0,0 +1,80 @@ +import { ChatMessage, ChatMessageItemResponse } from '../model/RemoteModels'; + +/** + * Whether a persisted or polled transcript row still has a body the timeline + * can show. A hollow assistant is a real id with no text, thinking, tools, + * items, or images — the shape left behind when a slim poll stub overwrites a + * live turn, or when a cache row was written before the desktop settled. + */ +export class TranscriptIntegrityPolicy { + static hasDisplayableBody(message: ChatMessage): boolean { + if ((message.text || '').trim().length > 0) { + return true; + } + if ((message.thinking || '').trim().length > 0) { + return true; + } + if (message.tools && message.tools.length > 0) { + return true; + } + if (TranscriptIntegrityPolicy.hasRenderableItems(message.items)) { + return true; + } + if (message.images && message.images.length > 0) { + return true; + } + return false; + } + + static isHollowAssistant(message: ChatMessage): boolean { + return (message.role || '').toLowerCase() === 'assistant' && + !TranscriptIntegrityPolicy.hasDisplayableBody(message); + } + + static hasHollowAssistants(messages: ChatMessage[]): boolean { + return messages.some((message: ChatMessage) => { + return TranscriptIntegrityPolicy.isHollowAssistant(message); + }); + } + + static hasRenderableItems(items?: ChatMessageItemResponse[]): boolean { + if (!items || items.length === 0) { + return false; + } + return items.some((entry: ChatMessageItemResponse) => { + if ((entry.content || '').trim().length > 0 || !!entry.tool) { + return true; + } + return TranscriptIntegrityPolicy.hasRenderableItems(entry.subItems); + }); + } + + static describeMessages(messages: ChatMessage[]): string { + const assistants = messages.filter((message: ChatMessage) => { + return (message.role || '').toLowerCase() === 'assistant'; + }); + let hollow = 0; + const parts: string[] = []; + assistants.forEach((message: ChatMessage) => { + if (TranscriptIntegrityPolicy.isHollowAssistant(message)) { + hollow += 1; + } + parts.push( + `id=${TranscriptIntegrityPolicy.shortId(message.id)} ` + + `text=${(message.text || '').length} ` + + `thinking=${(message.thinking || '').length} ` + + `tools=${message.tools ? message.tools.length : 0} ` + + `items=${message.items ? message.items.length : 0}` + ); + }); + const detail = parts.length > 0 ? ` ${parts.join(' ')}` : ''; + return `total=${messages.length} assistants=${assistants.length} hollow=${hollow}${detail}`; + } + + private static shortId(value: string): string { + if (value.length <= 10) { + return value; + } + return `${value.slice(0, 6)}...${value.slice(-4)}`; + } +} 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 d645eba246..33238c0154 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -14,6 +14,11 @@ import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; import { ConversationHeaderPolicy } from '../main/ets/pages/policy/ConversationHeaderPolicy'; import { ThinkingPresentationPolicy } from '../main/ets/pages/policy/ThinkingPresentationPolicy'; +import { ActivityGroupPolicy } from '../main/ets/pages/policy/ActivityGroupPolicy'; +import { AssistantProcessLayoutPolicy } from '../main/ets/pages/policy/AssistantProcessLayoutPolicy'; +import { ConversationMessageRenderPolicy } from '../main/ets/pages/policy/ConversationMessageRenderPolicy'; +import { ToolCollapsePolicy } from '../main/ets/pages/policy/ToolCollapsePolicy'; +import { ConversationUiMessage, ConversationUiToolStatus } from '../main/ets/pages/state/ConversationUiModels'; import { ConnectSheetLandingPolicy, ConnectSheetStatusPolicy, @@ -71,6 +76,183 @@ export default function conversationPresentationUnitTest() { expect(ThinkingPresentationPolicy.defaultExpanded(false, false)).assertFalse(); expect(ThinkingPresentationPolicy.defaultExpanded(false, true)).assertTrue(); }); + + it('lets the user collapse live thinking and folds it when the turn moves on', 0, () => { + expect(ThinkingPresentationPolicy.allowsToggle(true)).assertTrue(); + expect(ThinkingPresentationPolicy.allowsToggle(false)).assertFalse(); + expect(ThinkingPresentationPolicy.expandedForStatus(true, false, false, false)).assertTrue(); + expect(ThinkingPresentationPolicy.expandedForStatus(true, false, true, false)).assertFalse(); + expect(ThinkingPresentationPolicy.expandedForStatus(false, false, true, true)).assertFalse(); + expect(ThinkingPresentationPolicy.expandedForStatus(false, true, false, true)).assertTrue(); + }); + + it('marks leading thinking done once tools or answer text have started', 0, () => { + expect(ThinkingPresentationPolicy.liveThinkingStatus('active', false)).assertEqual('active'); + expect(ThinkingPresentationPolicy.liveThinkingStatus('active', true)).assertEqual('done'); + expect(ThinkingPresentationPolicy.liveThinkingStatus('running', true)).assertEqual('done'); + }); + + it('folds consecutive thinking segments into one body', 0, () => { + expect(ThinkingPresentationPolicy.mergedText(['先看结构', '', '再跑工具'])).assertEqual('先看结构\n\n再跑工具'); + expect(ThinkingPresentationPolicy.mergedStatusValue(['done', 'done'], [false, false])).assertEqual('done'); + expect(ThinkingPresentationPolicy.mergedStatusValue(['done', 'active'], [false, true])).assertEqual('active'); + expect(ThinkingPresentationPolicy.mergedStreaming([false, false, true])).assertTrue(); + expect(ThinkingPresentationPolicy.mergedStreaming([false, false])).assertFalse(); + }); + }); + + describe('ActivityGroupPolicy', () => { + it('keeps consecutive thinking and completed tools in one foldable group', 0, () => { + expect(ActivityGroupPolicy.shouldFoldEntry(true, false)).assertTrue(); + expect(ActivityGroupPolicy.shouldFoldEntry(false, false, toolStatus('1', 'Grep', 'completed'))).assertTrue(); + expect(ActivityGroupPolicy.shouldFoldEntry(false, false, toolStatus('2', 'ExecCommand', 'done'))).assertTrue(); + expect(ActivityGroupPolicy.thinkingHeaderCount(4)).assertEqual(1); + }); + + it('does not swallow Task cards, questions, or live tools into the thinking group', 0, () => { + expect(ActivityGroupPolicy.shouldFoldEntry(false, true, toolStatus('1', 'Task', 'completed'))).assertFalse(); + expect(ActivityGroupPolicy.shouldFoldEntry(false, false, toolStatus('2', 'AskUserQuestion', 'completed'))).assertFalse(); + expect(ActivityGroupPolicy.shouldFoldEntry(false, false, toolStatus('3', 'Grep', 'running'))).assertFalse(); + expect(ActivityGroupPolicy.thinkingHeaderCount(0)).assertEqual(0); + }); + }); + + describe('ConversationMessageRenderPolicy', () => { + it('does not throw when assistant text is missing and still shows structured content', 0, () => { + expect(ConversationMessageRenderPolicy.visibleText(undefined)).assertEqual(''); + expect(ConversationMessageRenderPolicy.visibleText('(空消息)')).assertEqual(''); + const item: ConversationUiMessage = { + id: 'a1', + role: 'assistant', + text: '', + status: 'done', + items: [{ type: 'thinking', content: '先看仓库结构' }] + }; + expect(ConversationMessageRenderPolicy.shouldRender(item)).assertTrue(); + }); + + it('hides an assistant row that has no text, thinking, tools, or items', 0, () => { + const item: ConversationUiMessage = { + id: 'a2', + role: 'assistant', + text: '', + status: 'done' + }; + expect(ConversationMessageRenderPolicy.shouldRender(item)).assertFalse(); + }); + }); + + describe('AssistantProcessLayoutPolicy', () => { + it('does not pin thinking below tools or omit in-flow thinking while streaming', 0, () => { + expect(AssistantProcessLayoutPolicy.pinsThinkingAfterTools()).assertFalse(); + expect(AssistantProcessLayoutPolicy.omitsInFlowThinkingWhileStreaming()).assertFalse(); + }); + + it('keeps a live Task card below the thinking that produced it', 0, () => { + const order = AssistantProcessLayoutPolicy.processBlockOrder( + '先看现有会话结构', + false, + '', + false, + 1 + ); + expect(order.length).assertEqual(2); + expect(order[0]).assertEqual('thinking'); + expect(order[1]).assertEqual('tools'); + }); + + it('keeps thinking above tools when assistant text has also started', 0, () => { + const order = AssistantProcessLayoutPolicy.processBlockOrder( + '先看现有会话结构', + true, + '已经开始改布局', + false, + 1 + ); + expect(order[0]).assertEqual('thinking'); + expect(order[1]).assertEqual('tools'); + expect(order[2]).assertEqual('text'); + }); + + it('renders activity-group thinking before folded read or search cards', 0, () => { + const order = AssistantProcessLayoutPolicy.activityGroupBlockOrder(true, 2); + expect(order.length).assertEqual(2); + expect(order[0]).assertEqual('thinking'); + expect(order[1]).assertEqual('tools'); + }); + + it('still renders completed thinking inside an activity group', 0, () => { + expect(AssistantProcessLayoutPolicy.shouldRenderActivityThinking(true)).assertTrue(); + expect(AssistantProcessLayoutPolicy.shouldRenderActivityThinking(false)).assertFalse(); + }); + + it('shows typing dots only when the live turn has no thinking or tools yet', 0, () => { + const order = AssistantProcessLayoutPolicy.processBlockOrder('', false, '', true, 0); + expect(order.length).assertEqual(1); + expect(order[0]).assertEqual('typing'); + }); + + it('keeps a tool-only activity group without inventing a thinking block', 0, () => { + const order = AssistantProcessLayoutPolicy.activityGroupBlockOrder(false, 1); + expect(order.length).assertEqual(1); + expect(order[0]).assertEqual('tools'); + }); + }); + + describe('ToolCollapsePolicy', () => { + it('folds consecutive completed tools of any kind into one summary', 0, () => { + const groups = ToolCollapsePolicy.collapsedGroups([ + toolStatus('1', 'Grep', 'completed'), + toolStatus('2', 'ExecCommand', 'done'), + toolStatus('3', 'Task', 'success') + ]); + expect(groups.length).assertEqual(1); + expect(groups[0].type).assertEqual('summary'); + expect(groups[0].tools.length).assertEqual(3); + }); + + it('keeps a single completed tool as its own row', 0, () => { + const groups = ToolCollapsePolicy.collapsedGroups([ + toolStatus('1', 'ExecCommand', 'completed') + ]); + expect(groups.length).assertEqual(1); + expect(groups[0].type).assertEqual('tool'); + }); + + it('does not fold running, failed, or blocking tools into the summary', 0, () => { + const groups = ToolCollapsePolicy.collapsedGroups([ + toolStatus('1', 'Grep', 'completed'), + toolStatus('2', 'Read', 'completed'), + toolStatus('3', 'ExecCommand', 'running'), + toolStatus('4', 'AskUserQuestion', 'pending'), + toolStatus('5', 'Write', 'failed') + ]); + expect(groups.length).assertEqual(4); + expect(groups[0].type).assertEqual('summary'); + expect(groups[0].tools.length).assertEqual(2); + expect(groups[1].type).assertEqual('tool'); + expect(groups[1].tools[0].name).assertEqual('ExecCommand'); + expect(groups[2].tools[0].name).assertEqual('AskUserQuestion'); + expect(groups[3].tools[0].name).assertEqual('Write'); + }); + + it('expands a subagent card only while that task is running', 0, () => { + expect(ToolCollapsePolicy.defaultSubagentExpanded(true)).assertTrue(); + expect(ToolCollapsePolicy.defaultSubagentExpanded(false)).assertFalse(); + expect(ToolCollapsePolicy.subagentExpandedForStatus(true, true, false)).assertFalse(); + expect(ToolCollapsePolicy.subagentExpandedForStatus(false, true, true)).assertFalse(); + }); + + it('keeps the summary row and lists members when the group is expanded', 0, () => { + const groups = ToolCollapsePolicy.expandedGroups([ + toolStatus('1', 'Grep', 'completed'), + toolStatus('2', 'ExecCommand', 'completed') + ]); + expect(groups.length).assertEqual(3); + expect(groups[0].type).assertEqual('summary'); + expect(groups[1].type).assertEqual('tool'); + expect(groups[2].type).assertEqual('tool'); + }); }); describe('ChatComposerPolicy add button', () => { @@ -295,3 +477,11 @@ export default function conversationPresentationUnitTest() { }); }); } + +function toolStatus(id: string, name: string, status: string): ConversationUiToolStatus { + return { + id, + name, + status + }; +} diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets index 761653212a..d58ad9167f 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -73,6 +73,7 @@ import { RemoteWorkspaceCoordinator } from '../main/ets/services/RemoteWorkspace import { RemoteWorkspaceDataSource } from '../main/ets/services/RemoteWorkspaceRepository'; import { RemoteToolActionClient, RemoteToolActionController } from '../main/ets/services/RemoteToolActionController'; import { RemoteUiState } from '../main/ets/services/RemoteUiState'; +import { TranscriptIntegrityPolicy } from '../main/ets/services/TranscriptIntegrityPolicy'; import { VoiceInputLifecycleCallbacks, VoiceInputLifecycleController, @@ -290,6 +291,46 @@ export default function conversationStateUnitTest() { expect(merged[0].thinking || '').assertEqual(''); }); + it('does not let an empty poll stub wipe a richer assistant body', 0, () => { + const rich = chatMessage('assistant-rich-1', 'assistant', 'Done with the edit'); + rich.thinking = 'Plan the change first'; + rich.tools = [{ id: 'tool-1', name: 'Grep', status: 'completed' }]; + rich.items = [{ + type: 'thinking', + content: 'Plan the change first' + }, { + type: 'tool', + tool: { id: 'tool-1', name: 'Grep', status: 'completed' } + }]; + const stub = chatMessage('assistant-rich-1', 'assistant', ''); + stub.thinking = ''; + stub.tools = []; + stub.items = []; + const merged = RemoteUiState.mergeMessages([rich], [stub]); + + expect(TranscriptIntegrityPolicy.isHollowAssistant(stub)).assertTrue(); + expect(TranscriptIntegrityPolicy.hasDisplayableBody(merged[0])).assertTrue(); + expect(merged[0].text).assertEqual('Done with the edit'); + expect(merged[0].thinking).assertEqual('Plan the change first'); + expect(merged[0].tools ? merged[0].tools.length : 0).assertEqual(1); + expect(merged[0].items ? merged[0].items.length : 0).assertEqual(2); + }); + + it('keeps structured items when later text arrives without them', 0, () => { + const rich = chatMessage('assistant-rich-2', 'assistant', ''); + rich.thinking = 'Still reasoning'; + rich.items = [{ type: 'thinking', content: 'Still reasoning' }]; + rich.tools = [{ id: 'tool-2', name: 'Read', status: 'completed' }]; + const merged = RemoteUiState.mergeMessages([rich], [ + chatMessage('assistant-rich-2', 'assistant', 'Final answer') + ]); + + expect(merged[0].text).assertEqual('Final answer'); + expect(merged[0].thinking || '').assertEqual(''); + expect(merged[0].items ? merged[0].items.length : 0).assertEqual(1); + expect(merged[0].tools ? merged[0].tools.length : 0).assertEqual(1); + }); + it('owns optimistic merge and active turn cleanup rules', 0, () => { const store = new ChatTimelineStore(); store.reset('session-1'); diff --git a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets index 59f6637d4c..7de0608c5c 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -1544,6 +1544,61 @@ export default function remoteControllersUnitTest() { expect(statuses[statuses.length - 1]).assertEqual(RemoteI18n.t('status.messagesRestored')); }); + it('refetches when a cached assistant has no body', 0, async () => { + const client = new FakeRemoteChatCommandClient(); + const store = new FakeRemoteChatCacheStore(); + const projection = new RemoteChatMessagesProjectionRecord(); + const cache = await readyRemoteChatCache(store); + const hollow = chatMessage('message-2', 'assistant', ''); + hollow.thinking = ''; + hollow.tools = []; + hollow.items = []; + await cache.sync('session-1', [ + chatMessage('message-1', 'user', 'Hello'), + hollow + ]); + client.messageResults = [{ + messages: [ + chatMessage('message-1', 'user', 'Hello'), + chatMessage('message-2', 'assistant', 'Recovered reply') + ], + hasMore: false + }]; + const controller = new RemoteChatCommandController(client, { + onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { + projection.messages = messages; + projection.hasMoreMessages = hasMoreMessages; + }, + onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { + projection.pollVersion = pollVersion; + projection.knownMessageCount = knownMessageCount; + }, + onTimelineReady: () => {}, + onSendSucceeded: (_turnId: string, _pendingActiveId: string) => {}, + onSendFailed: ( + _rawText: string, + _images: SelectedImageAttachment[], + _localMessageId: string, + _pendingActiveId: string + ) => {}, + onActiveSession: (_session: SessionSummary) => {}, + onSessionTitleChanged: (_sessionId: string, _title: string) => {}, + onStatusText: (_statusText: string) => {}, + onToast: (_message: string) => {}, + onBusy: (_isBusy: boolean) => {}, + onPollRequested: () => {} + }, cache); + + await controller.loadMessages('session-1', (_sessionId: string) => true); + + expect(client.messageRequests.length).assertEqual(1); + expect(projection.messages.length).assertEqual(2); + expect(projection.messages[1].text).assertEqual('Recovered reply'); + + await controller.loadMessages('session-1', (_sessionId: string) => true); + expect(client.messageRequests.length).assertEqual(1); + }); + it('caches a transcript the user switched away from before it arrived', 0, async () => { const client = new FakeRemoteChatCommandClient(); const store = new FakeRemoteChatCacheStore(); @@ -2422,69 +2477,57 @@ export default function remoteControllersUnitTest() { }); describe('ConversationLayoutPolicy', () => { - it('keeps folded and narrow surfaces compact', 0, () => { - expect(ConversationLayoutPolicy.useMasterDetail(480, false, false, 'tablet', [])).assertFalse(); - expect(ConversationLayoutPolicy.useMasterDetail(900, true, true, 'tablet', [])).assertFalse(); - expect(ConversationLayoutPolicy.useMasterDetail(900, false, true, 'phone', [ + it('keeps folded, hover and narrow surfaces compact', 0, () => { + expect(ConversationLayoutPolicy.useMasterDetail(480, false, false, [])).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail(900, true, true, [])).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail(900, false, true, [ new ConversationLayoutCrease(300, 8), new ConversationLayoutCrease(600, 8) ])).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail( + 2210, + true, + false, + [new ConversationLayoutCrease(1100, 16)], + true, + true + )).assertFalse(); }); - it('uses master-detail for width-qualified tablets', 0, () => { + it('uses official md width rather than device type for master-detail', 0, () => { expect(ConversationLayoutPolicy.useMasterDetail( - ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH, + ConversationLayoutPolicy.MD_MIN_WIDTH + + ConversationLayoutPolicy.MIN_DETAIL_PANE_WIDTH, false, false, - 'tablet', [] )).assertTrue(); + expect(ConversationLayoutPolicy.useMasterDetail(480, true, false, [])).assertFalse(); expect(ConversationLayoutPolicy.useMasterDetail( - 480, + ConversationLayoutPolicy.LG_MIN_WIDTH, true, false, - 'tablet', [] )).assertTrue(); }); - it('keeps crease-less phones compact even when the viewport is extra-wide', 0, () => { - expect(ConversationLayoutPolicy.useMasterDetail( - ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, - true, - false, - 'phone', - [] - )).assertFalse(); - }); - it('uses master-detail for unfolded dual-fold surfaces', 0, () => { expect(ConversationLayoutPolicy.useMasterDetail( - ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + ConversationLayoutPolicy.LG_MIN_WIDTH, true, false, - 'phone', [new ConversationLayoutCrease(520, 8)] )).assertTrue(); expect(ConversationLayoutPolicy.useMasterDetail( 2210, false, false, - 'phone', [new ConversationLayoutCrease(1100, 16)] )).assertTrue(); - expect(ConversationLayoutPolicy.useMasterDetail( - ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, - true, - false, - 'tablet', - [new ConversationLayoutCrease(520, 8)] - )).assertTrue(); expect(ConversationLayoutPolicy.useMasterDetail( 2210, false, false, - 'phone', [], true )).assertTrue(); @@ -2495,41 +2538,56 @@ export default function remoteControllersUnitTest() { 500, true, false, - 'phone', [new ConversationLayoutCrease(200, 8)] )).assertFalse(); }); it('uses master-detail for unfolded tri-fold surfaces', 0, () => { expect(ConversationLayoutPolicy.useMasterDetail( - ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + ConversationLayoutPolicy.LG_MIN_WIDTH, false, false, - 'phone', [new ConversationLayoutCrease(352, 8), new ConversationLayoutCrease(712, 8)] )).assertTrue(); expect(ConversationLayoutPolicy.useMasterDetail( - ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH, + 720, false, false, - 'phone', [new ConversationLayoutCrease(230, 8), new ConversationLayoutCrease(480, 8)] )).assertTrue(); }); + it('synthesizes a centered hinge when expanded foldables omit crease data', 0, () => { + const synthetic = ConversationLayoutPolicy.syntheticCenterCrease(2210); + expect(synthetic !== undefined).assertTrue(); + expect(synthetic ? synthetic.left : 0).assertEqual(1097); + expect(synthetic ? synthetic.width : 0).assertEqual(ConversationLayoutPolicy.SYNTHETIC_HINGE_WIDTH); + expect(ConversationLayoutPolicy.syntheticCenterCrease(500) === undefined).assertTrue(); + expect(ConversationLayoutPolicy.effectiveVerticalCreases(500, [], true).length).assertEqual(0); + const creases = ConversationLayoutPolicy.effectiveVerticalCreases(2210, [], true); + expect(creases.length).assertEqual(1); + expect(creases[0].left).assertEqual(1097); + const realCrease = ConversationLayoutPolicy.effectiveVerticalCreases( + 2210, + [new ConversationLayoutCrease(1100, 16)], + true + ); + expect(realCrease[0].left).assertEqual(1100); + }); + it('keeps the existing master width when no usable crease is available', 0, () => { - const geometry = ConversationLayoutPolicy.resolveWideGeometry(860, []); + const geometry = ConversationLayoutPolicy.resolveWideGeometry(820, []); const invalidCrease = ConversationLayoutPolicy.resolveWideGeometry( - 860, + 820, [new ConversationLayoutCrease(120, 10)] ); expect(geometry.masterPaneWidth).assertEqual(ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH); expect(geometry.masterDetailGap).assertEqual(0); expect(geometry.isExtraWide).assertFalse(); expect(geometry.detailContentOffset).assertEqual(0); - expect(geometry.detailContentWidth).assertEqual(516); + expect(geometry.detailContentWidth).assertEqual(476); expect(geometry.collapsedDetailContentOffset).assertEqual(0); - expect(geometry.collapsedDetailContentWidth).assertEqual(860); + expect(geometry.collapsedDetailContentWidth).assertEqual(820); expect(invalidCrease.masterPaneWidth).assertEqual(ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH); }); @@ -2540,6 +2598,9 @@ export default function remoteControllersUnitTest() { expect(geometry.detailContentWidth).assertEqual(0); expect(geometry.collapsedDetailContentOffset).assertEqual(0); expect(geometry.collapsedDetailContentWidth).assertEqual(300); + const emptyViewport = ConversationLayoutPolicy.resolveWideGeometry(0, []); + expect(emptyViewport.detailContentWidth).assertEqual(0); + expect(emptyViewport.collapsedDetailContentWidth).assertEqual(0); }); it('aligns the master boundary with the first usable fold crease', 0, () => { @@ -2552,6 +2613,17 @@ export default function remoteControllersUnitTest() { expect(geometry.isExtraWide).assertFalse(); expect(geometry.detailContentOffset).assertEqual(0); expect(geometry.detailContentWidth).assertEqual(430); + expect(geometry.collapsedDetailContentOffset).assertEqual(0); + expect(geometry.collapsedDetailContentWidth).assertEqual(820); + }); + + it('keeps collapsed dual-fold content full-width so restore stays at the window origin', 0, () => { + const geometry = ConversationLayoutPolicy.resolveWideGeometry( + 2210, + [new ConversationLayoutCrease(1097, 16)] + ); + expect(geometry.collapsedDetailContentOffset).assertEqual(0); + expect(geometry.collapsedDetailContentWidth).assertEqual(2210); }); it('accepts a zero-width crease without creating an empty detail segment', 0, () => { @@ -2592,12 +2664,12 @@ export default function remoteControllersUnitTest() { it('uses width as the extra-wide fallback when crease data is missing', 0, () => { const geometry = ConversationLayoutPolicy.resolveWideGeometry( - ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + ConversationLayoutPolicy.LG_MIN_WIDTH, [] ); expect(geometry.isExtraWide).assertTrue(); expect(geometry.detailContentOffset).assertEqual(0); - expect(geometry.detailContentWidth).assertEqual(736); + expect(geometry.detailContentWidth).assertEqual(496); }); it('ignores crease regions outside the application viewport', 0, () => {