diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index a5e11d4441..64771415e2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -12,7 +12,10 @@ export default class EntryAbility extends UIAbility { private previewStorage?: LocalStorage; onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { - const scenarioId = want.parameters?.['openbitfunDesignPreview']; + // `want.parameters` is untyped, so the scenario id is narrowed to a string + // once here; every branch below then compares or matches on text. + const previewId = want.parameters?.['openbitfunDesignPreview'] as string | undefined; + const scenarioId: string = previewId ?? ''; if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || @@ -20,16 +23,17 @@ export default class EntryAbility extends UIAbility { scenarioId === 'cold-start-home' || scenarioId === 'cold-start-home-dark' || scenarioId === 'copy-selection' || scenarioId === 'copy-selection-dark' || scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' || - scenarioId === 'interaction-mailbox' || scenarioId === 'interaction-mailbox-dark') { + scenarioId === 'interaction-mailbox' || scenarioId === 'interaction-mailbox-dark' || + scenarioId.startsWith('command-palette')) { this.initialPage = 'pages/preview/MobileDesignGallery'; this.previewStorage = new LocalStorage(); this.previewStorage.setOrCreate('scenarioId', scenarioId); AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' || scenarioId === 'copy-selection-dark' + const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' || scenarioId === 'copy-selection-dark' || (scenarioId.startsWith('command-palette') && scenarioId.endsWith('-dark')) ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK - : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' || scenarioId === 'copy-selection' || scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' + : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' || scenarioId === 'copy-selection' || scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' || (scenarioId.startsWith('command-palette') && !scenarioId.endsWith('-dark')) ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT : ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; this.context.getApplicationContext().setColorMode(colorMode); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 6ed5dbbe03..8618b196ca 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -366,6 +366,206 @@ export const REMOTE_CAPABILITY_PLAN_BUILD_V1: string = 'plan_build_v1'; export const REMOTE_CAPABILITY_HARNESS_PROFILES_V1: string = 'harness_profiles_v1'; /** The host resolves `workspace_id` in commands and stamps it on its projections. */ export const REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1: string = 'workspace_id_references_v1'; +/** + * The composer command palette has no capability of its own. + * + * Skills, compact, init and usage all reach the desktop through the existing + * `host_invoke` bridge, so the phone cannot ask for a capability that a host + * would have to be taught: it probes one of the verbs instead and degrades on + * the answer. Session goals are plain message text and need no host support at + * all. Only the skills picker's grouping is local, so these are display values. + */ +export const REMOTE_SKILL_SOURCE_BUILTIN: string = 'builtin'; +export const REMOTE_SKILL_SOURCE_USER: string = 'user'; +export const REMOTE_SKILL_SOURCE_PROJECT: string = 'project'; + +/** + * One palette entry, projected from the host's `get_mode_skill_configs` rows. + * + * The host payload is a `ModeSkillInfo`: a `SkillInfo` flattened beside the + * mode's availability flags. `id` is the skill key, because that is what the + * `[$key]` message token carries; the flags are kept on the entry so the picker + * can filter with host truth instead of guessing. + */ +export interface RemoteSkillCatalogEntry { + id: string; + name: string; + description: string; + /** Host group taxonomy; empty for user-authored skills. */ + group: string; + /** `builtin`, `project` or `user`; the picker's fallback grouping. */ + source: string; + /** Another skill of the same name wins in this mode. */ + shadowed: boolean; + selectedForRuntime: boolean; + effectiveEnabled: boolean; + globallyEnabled: boolean; + allowUserInvocation: boolean; +} + +export interface RemoteCommandCatalog { + skills: RemoteSkillCatalogEntry[]; +} + +/** + * Answer to `compact_session` / `run_init_agents_md`. + * + * Both handlers return `StartDialogTurnResponse`, so the accepted shape is the + * host's own success/message pair rather than a palette-specific envelope. + */ +export interface RemoteTurnAcceptedValue { + success?: boolean; + message?: string; +} + +/** + * The session usage report, mirrored from the desktop contract. + * + * Every field is optional on purpose: an older or a partially instrumented host + * reports the subset it can measure, and the sheet renders exactly the rows the + * payload carries instead of inventing zeroes for what is missing. Both the + * desktop camelCase spelling and the snake_case spelling are accepted, because + * this payload crosses a relay between two independently built versions. + */ +export interface RemoteUsageWorkspace { + kind?: string; + path_label?: string; + pathLabel?: string; + workspace_id?: string; + workspaceId?: string; +} + +export interface RemoteUsageScope { + kind?: string; + turn_count?: number; + turnCount?: number; + includes_subagents?: boolean; + includesSubagents?: boolean; +} + +export interface RemoteUsageCoverage { + level?: string; + available?: string[]; + missing?: string[]; + notes?: string[]; +} + +export interface RemoteUsageTime { + accounting?: string; + denominator?: string; + wall_time_ms?: number; + wallTimeMs?: number; + active_turn_ms?: number; + activeTurnMs?: number; + model_ms?: number; + modelMs?: number; + tool_ms?: number; + toolMs?: number; + idle_gap_ms?: number; + idleGapMs?: number; +} + +export interface RemoteUsageTokens { + source?: string; + input_tokens?: number; + inputTokens?: number; + output_tokens?: number; + outputTokens?: number; + total_tokens?: number; + totalTokens?: number; + cached_tokens?: number; + cachedTokens?: number; + cache_coverage?: string; + cacheCoverage?: string; +} + +export interface RemoteUsageModelRow { + model_id?: string; + modelId?: string; + call_count?: number; + callCount?: number; + total_tokens?: number; + totalTokens?: number; +} + +export interface RemoteUsageToolRow { + tool_name?: string; + toolName?: string; + category?: string; + call_count?: number; + callCount?: number; + error_count?: number; + errorCount?: number; +} + +export interface RemoteUsageFiles { + scope?: string; + changed_files?: number; + changedFiles?: number; + added_lines?: number; + addedLines?: number; + deleted_lines?: number; + deletedLines?: number; +} + +export interface RemoteUsageCompression { + compaction_count?: number; + compactionCount?: number; + manual_compaction_count?: number; + manualCompactionCount?: number; + automatic_compaction_count?: number; + automaticCompactionCount?: number; + saved_tokens?: number; + savedTokens?: number; +} + +export interface RemoteUsageErrors { + total_errors?: number; + totalErrors?: number; + tool_errors?: number; + toolErrors?: number; + model_errors?: number; + modelErrors?: number; +} + +export interface RemoteUsagePrivacy { + prompt_content_included?: boolean; + promptContentIncluded?: boolean; + tool_inputs_included?: boolean; + toolInputsIncluded?: boolean; + command_outputs_included?: boolean; + commandOutputsIncluded?: boolean; + file_contents_included?: boolean; + fileContentsIncluded?: boolean; +} + +/** + * The `get_session_usage_report` value, as the desktop contract serializes it. + * + * The command answers with the report itself rather than a wrapper, so this is + * the `value` of the `host_invoke` result and not a response envelope. + */ +export interface RemoteSessionUsageReport { + schema_version?: number; + schemaVersion?: number; + report_id?: string; + reportId?: string; + session_id?: string; + sessionId?: string; + generated_at?: number; + generatedAt?: number; + workspace?: RemoteUsageWorkspace; + scope?: RemoteUsageScope; + coverage?: RemoteUsageCoverage; + time?: RemoteUsageTime; + tokens?: RemoteUsageTokens; + models?: RemoteUsageModelRow[]; + tools?: RemoteUsageToolRow[]; + files?: RemoteUsageFiles; + compression?: RemoteUsageCompression; + errors?: RemoteUsageErrors; + privacy?: RemoteUsagePrivacy; +} export interface RemoteModelConfig { id: string; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets index e073742131..55b030edf9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets @@ -26,11 +26,61 @@ export enum ConversationIntentType { OpenFilePreview = 'open_file_preview', DownloadFile = 'download_file', BuildPlan = 'build_plan', + CommandPalette = 'command_palette', Send = 'send', VoiceInput = 'voice_input', ChatInputChanged = 'chat_input_changed' } +/** + * What one tap in the composer command row asks for. + * + * The row is a single funnel by construction: the two verbs keep their wire ids, + * and everything else is a local decision about the draft or a sheet. None of + * these carry a session — the active conversation is resolved once, where the + * command is sent. + */ +export enum CommandPaletteAction { + Compact = 'compact', + InitAgentsMd = 'init_agents_md', + ToggleGoal = 'toggle_goal', + OpenUsage = 'open_usage', + RetryUsage = 'retry_usage', + OpenSkills = 'open_skills', + RefreshCatalog = 'refresh_catalog', + SelectSkill = 'select_skill', + ClearSkill = 'clear_skill', + RetryCommand = 'retry_command' +} + +export class CommandPaletteRequest { + readonly action: CommandPaletteAction; + /** The picked skill's key, for `SelectSkill`; empty otherwise. */ + readonly skillId: string; + /** The verb a `RetryCommand` repeats, empty otherwise. */ + readonly wireAction: string; + /** + * The picked skill's name, for `SelectSkill`; empty otherwise. + * + * It travels beside the key because the two are not the same string and the + * user sees only this one: the message carries `[$]` while the row's + * identity, and every comparison the picker makes, stays the key. + */ + readonly skillLabel: string; + + constructor( + action: CommandPaletteAction, + skillId: string = '', + wireAction: string = '', + skillLabel: string = '' + ) { + this.action = action; + this.skillId = skillId; + this.wireAction = wireAction; + this.skillLabel = skillLabel; + } +} + export class PlanBuildRequest { readonly path: string; readonly name: string; @@ -66,6 +116,7 @@ export class ConversationIntent { readonly filePreviewRequest?: FilePreviewRequest; readonly planBuildRequest?: PlanBuildRequest; readonly copyScope: MessageCopyScope; + readonly commandPaletteRequest?: CommandPaletteRequest; mailboxAction?: InteractionMailboxAction; constructor( @@ -76,7 +127,8 @@ export class ConversationIntent { answers?: ConversationUiQuestionAnswer, filePreviewRequest?: FilePreviewRequest, planBuildRequest?: PlanBuildRequest, - copyScope: MessageCopyScope = MessageCopyScope.Plain + copyScope: MessageCopyScope = MessageCopyScope.Plain, + commandPaletteRequest?: CommandPaletteRequest ) { this.type = type; this.value = value; @@ -86,6 +138,7 @@ export class ConversationIntent { this.filePreviewRequest = filePreviewRequest; this.planBuildRequest = planBuildRequest; this.copyScope = copyScope; + this.commandPaletteRequest = commandPaletteRequest; } } @@ -154,4 +207,19 @@ export class ConversationIntents { new PlanBuildRequest(path, name) ); } + + /** One tap in the composer command row, as a typed request rather than a parameter list. */ + static commandPalette(request: CommandPaletteRequest): ConversationIntent { + return new ConversationIntent( + ConversationIntentType.CommandPalette, + '', + '', + undefined, + undefined, + undefined, + undefined, + MessageCopyScope.Plain, + request + ); + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index 3d285970ea..82978e854b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -1,7 +1,7 @@ import { InteractionMailboxAction } from '../../model/InteractionMailbox'; import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteModels'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; -import { ConversationIntent, ConversationIntentType, MessageCopyScope } from './ConversationIntent'; +import { ConversationIntent, ConversationIntentType, CommandPaletteRequest, MessageCopyScope } from './ConversationIntent'; import { toRemoteQuestionAnswer } from '../state/ConversationUiModels'; import { FilePreviewRequest } from '../../model/FilePreviewTarget'; @@ -26,6 +26,7 @@ export interface ConversationIntentDispatcherHooks { readonly openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void; readonly downloadFile: (path: string) => void; readonly buildPlan: (path: string, name: string) => Promise; + readonly commandPalette: (request: CommandPaletteRequest) => Promise; readonly send: () => Promise; readonly voiceInput: () => Promise; readonly inputChanged: (route: AppRoute, value: string) => void; @@ -73,6 +74,13 @@ export class ConversationIntentDispatcher { case ConversationIntentType.Send: void this.hooks.send(); return; case ConversationIntentType.VoiceInput: void this.hooks.voiceInput(); return; case ConversationIntentType.ChatInputChanged: this.hooks.inputChanged(route, intent.value); return; + case ConversationIntentType.CommandPalette: + // Command palette verbs ride the same remote conversation as everything + // else, so a general-chat route must not reach the remote host with one. + if (route === AppRoute.RemoteChat && intent.commandPaletteRequest) { + void this.hooks.commandPalette(intent.commandPaletteRequest); + } + return; } } 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 bb2215827f..0a3a17d88f 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 @@ -100,6 +100,10 @@ export struct AppRootPresentation { SettingsPlacementPolicy.compactBottom(SettingsSheetKind.RemoteViewSettings); @Local fullTextPlacement: SettingsPlacement = SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UserMessageFullText); + @Local skillPickerPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SkillPicker); + @Local usageReportPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UsageReport); private verticalCreases: ConversationLayoutCrease[] = []; private horizontalCreases: ConversationLayoutHorizontalCrease[] = []; private wideQueryListener?: mediaQuery.MediaQueryListener; @@ -242,6 +246,8 @@ export struct AppRootPresentation { filePreviewLayout: this.filePreviewLayout(), sessionDetailsPlacement: this.sessionDetailsPlacement, fullTextSheetPlacement: this.fullTextPlacement, + skillPickerSheetPlacement: this.skillPickerPlacement, + usageReportSheetPlacement: this.usageReportPlacement, wideMasterPaneWidth: this.wideMasterPaneWidth, wideMasterDetailGap: this.wideMasterDetailGap, wideDetailContentOffset: this.wideDetailContentOffset, @@ -284,6 +290,8 @@ export struct AppRootPresentation { showBackButton: false, hoverOperateMode: this.isHoverLayout(), fullTextSheetPlacement: this.fullTextPlacement, + skillPickerSheetPlacement: this.skillPickerPlacement, + usageReportSheetPlacement: this.usageReportPlacement, onRestoreSidebar: () => this.restoreWideMasterPane() }) } @@ -531,6 +539,8 @@ export struct AppRootPresentation { input, SettingsSheetKind.UserMessageFullText ); + this.skillPickerPlacement = SettingsPlacementPolicy.resolve(input, SettingsSheetKind.SkillPicker); + this.usageReportPlacement = SettingsPlacementPolicy.resolve(input, SettingsSheetKind.UsageReport); } private isRtlLayout(): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CommandChipRow.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CommandChipRow.ets new file mode 100644 index 0000000000..97aeb9f0b6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CommandChipRow.ets @@ -0,0 +1,318 @@ +import { MobileDesignGeometry, MobileDesignMotion, MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CommandPaletteChip, CommandPaletteChipKind, CommandPaletteChipState } from '../../services/CommandPalettePolicy'; +import { CommandPaletteNotice, CommandPalettePresentation } from '../state/CommandPaletteState'; +import { CommandPaletteAction, CommandPaletteRequest } from '../actions/ConversationIntent'; +import { ComposerPresentation } from './ComposerBar'; +import { + CONTENT_ON_INK, + INK, + LINE, + MUTED, + PAGE_BG, + SOFT, + STATUS_DANGER, + TRANSPARENT +} from './Theme'; + +// The row's own geometry. It is new to this component and is not part of the +// cross-platform token contract yet, so it lives here rather than being pushed +// into another platform's generated tokens. +const CHIP_HEIGHT: number = 32; +const CHIP_RADIUS: number = 16; +const CHIP_HORIZONTAL_PADDING: number = 9; +const CHIP_GAP: number = 4; +const CHIP_TOP_PADDING: number = 6; +const CHIP_END_GUTTER: number = 32; +const CHIP_ICON_SIZE: number = 13; +const CHIP_CHEVRON_SIZE: number = 9; +const CHIP_ROW_FADE_WIDTH: number = 18; +const CHIP_DISABLED_OPACITY: number = 0.38; +const CHIP_IN_FLIGHT_OPACITY: number = 0.52; +const CHIP_BADGE_HEIGHT: number = 15; +const HINT_ICON_SIZE: number = 14; +const CHIP_ROW_HEIGHT: number = CHIP_HEIGHT + CHIP_TOP_PADDING + 2; + +/** + * The composer's command chips: one horizontally scrollable row directly above + * the input, plus the single line the row owes the user underneath it. + * + * Commands execute on the tap; the skills chip opens the picker instead, which + * is what its up-chevron says. A chip that cannot run is drawn grey and stays + * tappable, because "nothing happened" is not an answer — the row explains + * itself in the hint line below. + */ +@ComponentV2 +export struct CommandChipRow { + @Param palette: CommandPalettePresentation = CommandPalettePresentation.hidden(); + @Param presentation: ComposerPresentation = ComposerPresentation.Compact; + @Event onRequest: (request: CommandPaletteRequest) => void = (_request: CommandPaletteRequest) => {}; + @Local showReason: boolean = false; + + build() { + Column({ space: 4 }) { + Stack({ alignContent: Alignment.End }) { + Scroll() { + Row({ space: CHIP_GAP }) { + ForEach(this.palette.chips, (chip: CommandPaletteChip) => { + this.Chip(chip) + }, (chip: CommandPaletteChip) => chip.kind) + } + .alignItems(VerticalAlign.Center) + .padding({ + left: this.horizontalInset(), + right: CHIP_END_GUTTER, + top: CHIP_TOP_PADDING, + bottom: 2 + }) + } + .scrollable(ScrollDirection.Horizontal) + .scrollBar(BarState.Off) + // Scroll centres content narrower than the viewport, which would park the + // first chip in the middle of the row instead of at the composer's edge. + .align(Alignment.Start) + .width('100%') + .height(CHIP_ROW_HEIGHT) + + this.RowEndFade() + } + .width('100%') + .height(CHIP_ROW_HEIGHT) + + if (this.hintKey().length > 0) { + this.HintLine() + } + } + .width('100%') + .constraintSize({ maxWidth: this.presentation === ComposerPresentation.Floating ? 760 : 10000 }) + .alignSelf(ItemAlign.Center) + } + + @Builder + Chip(chip: CommandPaletteChip) { + Stack({ alignContent: Alignment.TopEnd }) { + Row({ space: 4 }) { + if (chip.state === CommandPaletteChipState.InFlight) { + LoadingProgress() + .width(CHIP_ICON_SIZE) + .height(CHIP_ICON_SIZE) + .color(MUTED) + } else { + SymbolGlyph(this.chipGlyph(chip.kind)) + .fontSize(CHIP_ICON_SIZE) + .fontColor([this.chipGlyphColor(chip)]) + .opacity(chip.state === CommandPaletteChipState.Dimmed ? 1 : 0.82) + } + Text(RemoteI18n.t(chip.labelKey)) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(this.chipLabelColor(chip)) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (chip.showsChevron) { + SymbolGlyph($r('sys.symbol.chevron_up')) + .fontSize(CHIP_CHEVRON_SIZE) + .fontColor([this.chipChevronColor(chip)]) + .opacity(chip.state === CommandPaletteChipState.Dimmed ? 1 : 0.56) + } + } + .height(CHIP_HEIGHT) + .padding({ left: CHIP_HORIZONTAL_PADDING, right: CHIP_HORIZONTAL_PADDING }) + .backgroundColor(this.chipBackgroundColor(chip)) + .borderRadius(CHIP_RADIUS) + + // Absolutely offset rather than laid out inline: a badge that eats width + // pushes the row's last chip off the screen edge. + if (chip.badgeKey.length > 0) { + this.ChipBadge(chip.badgeKey) + } + } + .height(CHIP_HEIGHT) + .opacity(this.chipOpacity(chip)) + .accessibilityText(chip.badgeKey.length > 0 ? + `${RemoteI18n.t(chip.labelKey)} · ${RemoteI18n.t(chip.badgeKey)}` : + RemoteI18n.t(chip.labelKey)) + .animation({ duration: MobileDesignMotion.quick, curve: Curve.EaseOut }) + .onClick(() => { + this.tapChip(chip); + }) + } + + /** The corner badge an unsupported host's chips carry. */ + @Builder + ChipBadge(badgeKey: string) { + Text(RemoteI18n.t(badgeKey)) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(CHIP_BADGE_HEIGHT) + .fontColor(MUTED) + .padding({ left: 4, right: 4 }) + .backgroundColor(LINE) + .borderRadius(4) + .offset({ x: 2, y: -CHIP_TOP_PADDING }) + } + + @Builder + RowEndFade() { + Column() + .width(CHIP_ROW_FADE_WIDTH) + .height(CHIP_HEIGHT) + .margin({ top: CHIP_TOP_PADDING }) + .hitTestBehavior(HitTestMode.None) + .linearGradient({ + angle: 90, + colors: [[TRANSPARENT, 0.0], [PAGE_BG, 1.0]] + }) + } + + /** + * The row's explanation line. + * + * Three things can be said here and only one at a time: an unsupported host, a + * verb that just failed (with its retry), or the goal chip waiting for its + * argument. The goal prefill outranks the rest while it is lit, because that is + * the state the user has to act on before anything can be sent. + */ + @Builder + HintLine() { + Row({ space: 6 }) { + SymbolGlyph(this.hintGlyph()) + .fontSize(HINT_ICON_SIZE) + .fontColor([this.hintColor()]) + Text(RemoteI18n.t(this.hintKey())) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(this.hintColor()) + .layoutWeight(1) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.retryable()) { + Text(RemoteI18n.t('common.retry')) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .backgroundColor(SOFT) + .borderRadius(8) + .accessibilityText(RemoteI18n.t('common.retry')) + .onClick(() => { + this.onRequest(new CommandPaletteRequest( + CommandPaletteAction.RetryCommand, '', this.palette.notice.retryCommand)); + }) + } + } + .width('100%') + .padding({ left: this.horizontalInset(), right: MobileDesignGeometry.conversationOverlaySideInset }) + .alignItems(VerticalAlign.Top) + } + + private horizontalInset(): number { + return this.presentation === ComposerPresentation.Floating ? + 24 : MobileDesignGeometry.conversationOverlaySideInset; + } + + private tapChip(chip: CommandPaletteChip): void { + if (!chip.runsAction) { + // Degraded is a state to explain, not a dead control: the reason appears + // under the row instead of being swallowed by the tap. + this.showReason = chip.reasonKey.length > 0; + return; + } + this.showReason = false; + this.onRequest(this.requestFor(chip.kind)); + } + + private requestFor(kind: CommandPaletteChipKind): CommandPaletteRequest { + if (kind === CommandPaletteChipKind.Compact) { + return new CommandPaletteRequest(CommandPaletteAction.Compact); + } + if (kind === CommandPaletteChipKind.Init) { + return new CommandPaletteRequest(CommandPaletteAction.InitAgentsMd); + } + if (kind === CommandPaletteChipKind.Goal) { + return new CommandPaletteRequest(CommandPaletteAction.ToggleGoal); + } + if (kind === CommandPaletteChipKind.Usage) { + return new CommandPaletteRequest(CommandPaletteAction.OpenUsage); + } + return new CommandPaletteRequest(CommandPaletteAction.OpenSkills); + } + + private chipGlyph(kind: CommandPaletteChipKind): Resource { + if (kind === CommandPaletteChipKind.Compact) return $r('sys.symbol.arrow_up_left_and_arrow_down_right'); + if (kind === CommandPaletteChipKind.Init) return $r('sys.symbol.doc_text_badge_arrow_up'); + if (kind === CommandPaletteChipKind.Goal) return $r('sys.symbol.list_checkmark'); + if (kind === CommandPaletteChipKind.Usage) return $r('sys.symbol.square_grid_2x2'); + return $r('sys.symbol.wrench_and_screwdriver'); + } + + private chipBackgroundColor(chip: CommandPaletteChip): ResourceColor { + return chip.state === CommandPaletteChipState.Pending ? INK : SOFT; + } + + private chipLabelColor(chip: CommandPaletteChip): ResourceColor { + if (chip.state === CommandPaletteChipState.Pending) return CONTENT_ON_INK; + return chip.state === CommandPaletteChipState.Dimmed ? MUTED : INK; + } + + private chipGlyphColor(chip: CommandPaletteChip): ResourceColor { + if (chip.state === CommandPaletteChipState.Pending) return CONTENT_ON_INK; + return chip.state === CommandPaletteChipState.Dimmed ? MUTED : INK; + } + + private chipChevronColor(chip: CommandPaletteChip): ResourceColor { + return chip.state === CommandPaletteChipState.Dimmed ? MUTED : INK; + } + + private chipOpacity(chip: CommandPaletteChip): number { + if (chip.state === CommandPaletteChipState.InFlight) return CHIP_IN_FLIGHT_OPACITY; + return chip.state === CommandPaletteChipState.Dimmed ? CHIP_DISABLED_OPACITY : 1; + } + + private notice(): CommandPaletteNotice { + return this.palette.notice; + } + + private retryable(): boolean { + return this.palette.rowReasonKey.length === 0 && this.notice().retryable; + } + + private hintKey(): string { + if (this.palette.rowReasonKey.length > 0) { + return this.palette.rowReasonKey; + } + if (this.notice().visible) { + return this.notice().labelKey; + } + const goal = this.palette.chips.find((chip: CommandPaletteChip): boolean => + chip.kind === CommandPaletteChipKind.Goal); + if (goal && goal.state === CommandPaletteChipState.Pending) { + return 'commands.goalHint'; + } + if (this.showReason) { + const dimmed = this.palette.chips.find((chip: CommandPaletteChip): boolean => + chip.reasonKey.length > 0); + if (dimmed) { + return dimmed.reasonKey; + } + } + return ''; + } + + private hintGlyph(): Resource { + const notice = this.notice(); + if (this.palette.rowReasonKey.length === 0 && notice.visible && notice.retryable) { + return $r('sys.symbol.xmark'); + } + return $r('sys.symbol.info_circle'); + } + + private hintColor(): ResourceColor { + const notice = this.notice(); + if (this.palette.rowReasonKey.length === 0 && notice.visible && notice.retryable) { + return STATUS_DANGER; + } + return MUTED; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 0696cab2da..e7fc255f30 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -2,6 +2,7 @@ import { KeyboardReleaseService } from '../../services/KeyboardReleaseService'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { MobileDesignGeometry, MobileDesignMotion, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { ChatComposerPolicy, ComposerPrimaryAction } from '../../services/ChatComposerPolicy'; +import { CommandPalettePolicy } from '../../services/CommandPalettePolicy'; import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from '../state/ChatComposerCapabilities'; import { ConversationUiModel, @@ -49,6 +50,11 @@ const COMPOSER_IMAGE_STRIP_TOP_GAP: number = 6; /** Strip height plus its top gap and the parent Column's 2vp row spacing. */ const COMPOSER_IMAGE_STRIP_BLOCK: number = COMPOSER_IMAGE_CARD_SIZE + COMPOSER_IMAGE_STRIP_TOP_GAP + 2; +const COMPOSER_TOKEN_HEIGHT: number = 28; +const COMPOSER_TOKEN_STRIP_TOP_GAP: number = 6; +/** The command token's row, sized the same way as the attachment strip's. */ +const COMPOSER_TOKEN_STRIP_BLOCK: number = + COMPOSER_TOKEN_HEIGHT + COMPOSER_TOKEN_STRIP_TOP_GAP + 2; /** Gap the model popover keeps between its defaults section and the list. */ const COMPOSER_MODEL_SELECTOR_SECTION_SPACE: number = 10; const COMPOSER_MODEL_DEFAULTS_DIVIDER_STROKE: number = 0.5; @@ -66,6 +72,24 @@ export struct ComposerBar { @Local sendTransitionActive: boolean = false; private sendTransitionTimerId: number = -1; @Param selectedImages: ConversationUiSelectedImage[] = []; + /** + * The command palette's skill token, empty when the composer carries none. + * + * It is a chip beside the draft rather than text in it, so the field stays + * exactly what the user typed; the send path is what turns it into content. + * The key is the pick's identity and decides whether the strip is drawn; the + * label is the skill's name and is one half of the text the message will carry. + */ + @Param skillId: string = ''; + @Param skillLabel: string = ''; + /** + * Whether the composer sits under the command row. + * + * The row is the only thing that can produce a bare `/goal`, and that is a + * command still waiting for its argument rather than a message, so this is + * what lets the composer withhold Send without changing the local surface. + */ + @Param commandRowActive: boolean = false; @Param isBusy: boolean = false; @Param canStop: boolean = false; @Param connectionState: string = 'connected'; @@ -83,6 +107,7 @@ export struct ComposerBar { private restoreInputFocusTimerId: number = -1; @Event onPickImages: () => void = () => {}; @Event onRemoveImage: (imageId: string) => void = (_imageId: string) => {}; + @Event onRemoveSkillToken: () => void = () => {}; @Event onSend: () => void = () => {}; @Event onStop: () => void = () => {}; @Event onVoiceInput: () => void = () => {}; @@ -137,6 +162,12 @@ export struct ComposerBar { @Builder AdaptiveComposer() { Column({ space: 2 }) { + // The token is not a thumbnail: it sits above the field with the images + // because that is the only strip the composer has, and both are things the + // next send will carry rather than text the user is still writing. + if (this.hasSkillToken()) { + this.CommandTokenStrip() + } if (this.selectedImages.length > 0) { this.SelectedImageStrip() } @@ -659,6 +690,48 @@ export struct ComposerBar { .margin({ top: COMPOSER_IMAGE_STRIP_TOP_GAP }) } + /** + * The one skill token a message may carry. + * + * It is removable in place, and it shows the exact text the message will send + * — `[$plan]` — so the token and the wire form cannot drift apart. + */ + @Builder + CommandTokenStrip() { + Row() { + Row({ space: 5 }) { + SymbolGlyph($r('sys.symbol.wrench_and_screwdriver')) + .fontSize(12) + .fontColor([INK]) + .opacity(0.7) + Text(CommandPalettePolicy.skillTokenLabel(this.skillLabel)) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(11) + .fontColor([INK]) + .opacity(0.6) + } + .height(COMPOSER_TOKEN_HEIGHT) + .padding({ left: 9, right: 9 }) + .backgroundColor(SOFT) + .borderRadius(COMPOSER_TOKEN_HEIGHT / 2) + .accessibilityText(`${RemoteI18n.t('commands.skillToken')} · ` + + `${CommandPalettePolicy.skillTokenLabel(this.skillLabel)}`) + .onClick(() => { + this.onRemoveSkillToken(); + }) + Blank() + } + .width('100%') + .height(COMPOSER_TOKEN_HEIGHT) + .padding({ left: 4, right: 4 }) + .margin({ top: COMPOSER_TOKEN_STRIP_TOP_GAP }) + } + @Builder SelectedImageCard(image: ConversationUiSelectedImage) { Stack({ alignContent: Alignment.TopEnd }) { @@ -706,7 +779,7 @@ export struct ComposerBar { // Dictation runs inside this app rather than in a system dialog, so listening // outranks the draft here in a way a running turn does not. private primaryAction(): ComposerPrimaryAction { - return ChatComposerPolicy.primaryAction( + const action = ChatComposerPolicy.primaryAction( this.inputText, this.selectedImages.length, this.isBusy, @@ -717,6 +790,16 @@ export struct ComposerBar { this.connectionState, this.capabilities.showVoiceInput ); + // A bare `/goal` is a command whose argument has not been written yet, and + // the desktop parses the objective out of the message head, so there is + // nothing to send until the user supplies one. The palette's own face of + // this rule lives in `CommandPalettePolicy.blocksSend`; the send path checks + // it again, because the field can also be typed into directly. + if (action === ComposerPrimaryAction.Send && this.commandRowActive && + CommandPalettePolicy.blocksSend(this.inputText)) { + return ComposerPrimaryAction.SendBlocked; + } + return action; } private shouldShowAddButton(): boolean { @@ -726,10 +809,23 @@ export struct ComposerBar { ); } - // Attachments live inside the card, so the card has to grow to hold them. + /** + * Whether the composer is carrying a skill token. + * + * The label is the token's own text, so the two cannot disagree: a pick with + * no name to write would draw an empty chip rather than nothing at all. + */ + private hasSkillToken(): boolean { + return this.skillLabel.length > 0; + } + + // Attachments and the skill token live inside the card, so the card has to + // grow to hold them. private composerHeight(): number { const base = this.isComposerExpanded() ? COMPOSER_EXPANDED_HEIGHT : this.collapsedComposerHeight(); - return this.selectedImages.length > 0 ? base + COMPOSER_IMAGE_STRIP_BLOCK : base; + const attachmentBlock = this.selectedImages.length > 0 ? COMPOSER_IMAGE_STRIP_BLOCK : 0; + const tokenBlock = this.hasSkillToken() ? COMPOSER_TOKEN_STRIP_BLOCK : 0; + return base + attachmentBlock + tokenBlock; } private collapsedComposerHeight(): number { @@ -739,7 +835,7 @@ export struct ComposerBar { // The pill radius only reads as a pill while the card is one row tall. private composerRadius(): number { - if (this.isComposerExpanded() || this.selectedImages.length > 0) { + if (this.isComposerExpanded() || this.selectedImages.length > 0 || this.hasSkillToken()) { return MobileDesignGeometry.composerExpandedRadius; } return MobileDesignGeometry.composerCollapsedRadius; 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 448b0a0e75..a1190d4590 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 @@ -37,6 +37,10 @@ export struct ConversationRouteSurface { @Param hoverOperateMode: boolean = false; @Param fullTextSheetPlacement: SettingsPlacement = SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UserMessageFullText); + @Param skillPickerSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SkillPicker); + @Param usageReportSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UsageReport); @Event onRestoreSidebar: () => void = () => {}; build() { @@ -68,6 +72,8 @@ export struct ConversationRouteSurface { composerInputId: 'conversation-composer-input', hoverOperateMode: this.hoverOperateMode, fullTextSheetPlacement: this.fullTextSheetPlacement, + skillPickerSheetPlacement: this.skillPickerSheetPlacement, + usageReportSheetPlacement: this.usageReportSheetPlacement, onRestoreSidebar: this.onRestoreSidebar, onIntent: (intent: ConversationIntent) => this.dispatchConversationIntent(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 eff105257a..d8e2c80afa 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 @@ -9,11 +9,22 @@ import { RemoteLogger } from '../../services/RemoteLogger'; import { ChatComposerCapabilities } from '../state/ChatComposerCapabilities'; import { ChatSurface } from '../state/ChatSurface'; import { ConversationViewState } from '../state/ConversationViewState'; -import { ConversationIntent, ConversationIntents, ConversationIntentType, MessageCopyScope } from '../actions/ConversationIntent'; +import { + CommandPaletteAction, + CommandPaletteRequest, + ConversationIntent, + ConversationIntents, + ConversationIntentType, + MessageCopyScope +} from '../actions/ConversationIntent'; +import { CommandPalettePresentation } from '../state/CommandPaletteState'; import { SettingsPlacement } from '../policy/SettingsPlacementPolicy'; import { AdaptiveSheetOptions } from './AdaptiveSheetOptions'; import { ChatTimeline } from './ChatTimeline'; +import { CommandChipRow } from './CommandChipRow'; import { ConversationLoadingState } from './ConversationLoadingState'; +import { SkillPickerSheet } from './SkillPickerSheet'; +import { UsageReportSheet } from './UsageReportSheet'; import { UserMessageFullTextSheet } from './UserMessageFullTextSheet'; import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { @@ -44,6 +55,18 @@ import { TRANSPARENT, } from './Theme'; +/** + * The overlay sheets the conversation can present, one at a time. + * + * The root node carries a single sheet binding, so which sheet is up has to be + * named rather than counted: a second `bindSheet` on the same node is dropped, + * and with it every sheet that node was meant to open. + */ +const CONVERSATION_SHEET_NONE: string = ''; +const CONVERSATION_SHEET_FULL_TEXT: string = 'full_text'; +const CONVERSATION_SHEET_SKILLS: string = 'skills'; +const CONVERSATION_SHEET_USAGE: string = 'usage'; + @ComponentV2 export struct ConversationView { @Param state: ConversationViewState = new ConversationViewState(); @@ -52,15 +75,23 @@ export struct ConversationView { @Event onRestoreSidebar: () => void = () => {}; @Local showHeaderActions: boolean = false; /** - * The user message whose full text is open, as a panel over the conversation. + * Which overlay sheet this pane is presenting, and whether one is up. + * + * A node carries exactly one sheet binding, so the three sheets the + * conversation can open take turns on the root's single `bindSheet` rather + * than each adding a binding beside it: a second binding on the same node is + * dropped, which silently costs every sheet on it. The kind is what the + * content and the options switch on, and the flag is what the framework + * writes back when the user drags the sheet away. * - * One panel at a time is the whole reason this is state of the conversation - * rather than of the row that was long-pressed: the rows are virtualized and - * reused, and a sheet bound per row would fight the pool instead of the - * message it belongs to. It is view state, so it never becomes a projected - * field or an intent: nothing outside this pane reads it. + * This is state of the conversation rather than of the row that was + * long-pressed: the rows are virtualized and reused, and a sheet bound per row + * would fight the pool instead of the message it belongs to. It is view state, + * so it never becomes a projected field or an intent: nothing outside this pane + * reads it. */ - @Local showFullText: boolean = false; + @Local activeSheet: string = CONVERSATION_SHEET_NONE; + @Local sheetPresented: boolean = false; @Local fullTextMessage: string = ''; /** * How much of the pane the two floating layers cover, measured rather than @@ -117,17 +148,71 @@ export struct ConversationView { .width('100%') .height('100%') .backgroundColor(PAGE_BG) - .bindSheet($$this.showFullText, this.FullTextSheet(), this.fullTextSheetOptions()) + .bindSheet($$this.sheetPresented, this.ActiveSheet(), this.activeSheetOptions()) } + /** + * The sheet that is up, built from the same kind the flag was opened with. + * + * Every branch closes by clearing the flag, because the sheet is dismissed by + * the framework and by its own affordances both. + */ @Builder - private FullTextSheet() { - UserMessageFullTextSheet({ - text: this.fullTextMessage, - onClose: () => { - this.showFullText = false; - } - }) + private ActiveSheet() { + if (this.activeSheet === CONVERSATION_SHEET_FULL_TEXT) { + UserMessageFullTextSheet({ + text: this.fullTextMessage, + onClose: () => { + this.closeSheet(); + } + }) + } else if (this.activeSheet === CONVERSATION_SHEET_SKILLS) { + SkillPickerSheet({ + palette: this.commandPalette, + onSelect: (skillId: string, skillLabel: string) => { + this.closeSheet(); + this.onIntent(ConversationIntents.commandPalette( + new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId, '', skillLabel))); + }, + onRefresh: () => { + this.onIntent(ConversationIntents.commandPalette( + new CommandPaletteRequest(CommandPaletteAction.RefreshCatalog))); + }, + onClose: () => { + this.closeSheet(); + } + }) + } else if (this.activeSheet === CONVERSATION_SHEET_USAGE) { + UsageReportSheet({ + palette: this.commandPalette, + onRetry: () => { + this.onIntent(ConversationIntents.commandPalette( + new CommandPaletteRequest(CommandPaletteAction.RetryUsage))); + }, + onClose: () => { + this.closeSheet(); + } + }) + } + } + + private activeSheetOptions(): SheetOptions { + if (this.activeSheet === CONVERSATION_SHEET_FULL_TEXT) { + return this.adaptiveSheetOptions(this.fullTextSheetPlacement); + } + if (this.activeSheet === CONVERSATION_SHEET_SKILLS) { + return this.adaptiveSheetOptions(this.skillPickerSheetPlacement); + } + return this.adaptiveSheetOptions(this.usageReportSheetPlacement); + } + + private openSheet(kind: string): void { + this.activeSheet = kind; + this.sheetPresented = true; + } + + private closeSheet(): void { + this.sheetPresented = false; } /** @@ -479,12 +564,27 @@ export struct ConversationView { @Builder Composer() { Column() { + // The command row floats above the composer and is mounted here rather than + // in either layout, so the compact overlay, the half-folded pane and the + // wide detail pane all get the same row from the same subtree. + if (this.commandPalette.visible) { + CommandChipRow({ + palette: this.commandPalette, + presentation: this.composerPresentation, + onRequest: (request: CommandPaletteRequest) => { + this.onCommandPaletteRequest(request); + } + }) + } ComposerBar({ presentation: this.composerPresentation, capabilities: this.composerCapabilities, inputId: this.composerInputId, chatInput: this.chatInput, selectedImages: this.selectedImages, + skillId: this.commandPalette.skillId, + skillLabel: this.commandPalette.skillLabel, + commandRowActive: this.commandPalette.visible, isBusy: this.isBusy, canStop: this.canStop, connectionState: this.connectionState, @@ -497,6 +597,10 @@ export struct ConversationView { onRemoveImage: (imageId: string) => { this.onRemoveImage(imageId); }, + onRemoveSkillToken: () => { + this.onIntent(ConversationIntents.commandPalette( + new CommandPaletteRequest(CommandPaletteAction.ClearSkill))); + }, onSend: () => { this.onSend(); }, @@ -517,6 +621,30 @@ export struct ConversationView { .width('100%') } + /** + * Hands one command-row tap to the conversation. + * + * The row's own sheets are view state, the way the full-text panel is: nothing + * outside this pane reads them, so opening one is a local decision. Everything + * else — the verbs, the catalog read, the draft edits — leaves as the one + * typed intent. + */ + private onCommandPaletteRequest(request: CommandPaletteRequest): void { + if (request.action === CommandPaletteAction.OpenSkills) { + this.openSheet(CONVERSATION_SHEET_SKILLS); + } else if (request.action === CommandPaletteAction.OpenUsage) { + this.openSheet(CONVERSATION_SHEET_USAGE); + } + this.onIntent(ConversationIntents.commandPalette(request)); + } + + private adaptiveSheetOptions(placement: SettingsPlacement): SheetOptions { + // The same adaptive contract as the full-text panel: the content draws the + // surface, the sheet supplies transparency, the scrim, and a drag bar + // wherever the placement resolves to a bottom sheet. + return AdaptiveSheetOptions.fromPlacement(placement, true); + } + @Builder HeaderActionsPopover() { Column() { @@ -678,6 +806,7 @@ export struct ConversationView { private get selectedImages(): ConversationUiSelectedImage[] { return this.state.selectedImages; } private get isVoiceListening(): boolean { return this.state.isVoiceListening; } private get chatInput(): string { return this.state.chatInput; } + private get commandPalette(): CommandPalettePresentation { return this.state.commandPalette; } private get isSessionPinned(): boolean { return this.state.isSessionPinned; } private get activeFilePreviewPath(): string { return this.options.activeFilePreviewPath; } private get activeFilePreviewLoading(): boolean { return this.options.activeFilePreviewLoading; } @@ -688,6 +817,8 @@ export struct ConversationView { private get composerInputId(): string { return this.options.composerInputId; } private get hoverOperateMode(): boolean { return this.options.hoverOperateMode; } private get fullTextSheetPlacement(): SettingsPlacement { return this.options.fullTextSheetPlacement; } + private get skillPickerSheetPlacement(): SettingsPlacement { return this.options.skillPickerSheetPlacement; } + private get usageReportSheetPlacement(): SettingsPlacement { return this.options.usageReportSheetPlacement; } private onOpenSidebar(): void { this.dispatchSimple(ConversationIntentType.OpenSidebar); } private onBack(): void { this.dispatchSimple(ConversationIntentType.Back); } private onTogglePinSession(): void { this.dispatchSimple(ConversationIntentType.TogglePin); } @@ -721,13 +852,7 @@ export struct ConversationView { return; } this.fullTextMessage = text; - this.showFullText = true; - } - private fullTextSheetOptions(): SheetOptions { - // The same adaptive contract as the other overlay sheets: the content draws - // the surface, the sheet supplies transparency, the scrim, and a drag bar - // wherever the placement resolves to a bottom sheet. - return AdaptiveSheetOptions.fromPlacement(this.fullTextSheetPlacement, true); + this.openSheet(CONVERSATION_SHEET_FULL_TEXT); } private onOpenFilePreview(path: string, label: string): void { this.onIntent(ConversationIntents.openFilePreview(path, label)); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets index 904a3c74df..3b9113ddb8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets @@ -27,6 +27,15 @@ export class ConversationViewOptions { */ fullTextSheetPlacement: SettingsPlacement = SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UserMessageFullText); + /** + * Placement for the composer's command palette sheets. Same contract as the + * full-text panel: the page owns the geometry, so the conversation consumes it + * instead of re-deriving sheets from a width of its own. + */ + skillPickerSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SkillPicker); + usageReportSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UsageReport); } export class ConversationViewContract { 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 8094df5bc4..cc444c0a6e 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 @@ -22,6 +22,10 @@ export struct ConversationViewHost { @Param hoverOperateMode: boolean = false; @Param fullTextSheetPlacement: SettingsPlacement = SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UserMessageFullText); + @Param skillPickerSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SkillPicker); + @Param usageReportSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UsageReport); @Param onRestoreSidebar: () => void = () => {}; @Param onIntent: (intent: ConversationIntent) => void = (_intent: ConversationIntent) => {}; @@ -45,6 +49,8 @@ export struct ConversationViewHost { options.composerInputId = this.composerInputId; options.hoverOperateMode = this.hoverOperateMode; options.fullTextSheetPlacement = this.fullTextSheetPlacement; + options.skillPickerSheetPlacement = this.skillPickerSheetPlacement; + options.usageReportSheetPlacement = this.usageReportSheetPlacement; return options; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets index 06f3aff632..89f843bb37 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets @@ -3,7 +3,7 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { SavedRuntimeConnection, RecentWorkspaceEntry, RemoteWorkspaceIdentity } from '../../model/RemoteModels'; import { remoteWorkspaceIdentityMatches, remoteWorkspaceKey } from '../../services/RemoteSessionIdentity'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, CONTENT_ON_ACTION, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, SOFT, TRANSPARENT } from './Theme'; +import { CARD, CONTENT_ON_ACTION, CONTENT_ON_INK, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, SOFT, TRANSPARENT } from './Theme'; import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; @ComponentV2 @@ -245,7 +245,7 @@ export struct SidebarWorkspacePicker { this.onBrowse({ type: 'browse-workspace', path: current.slice(0, current.lastIndexOf('/')) || '/', text: this.connectionId }); }) Button(RemoteI18n.t('workspaceTools.selectFolder')) - .fontColor(CONTENT_ON_ACTION).fontSize(MobileDesignTypography.labelSmall.size).height(40).layoutWeight(1) + .fontColor(CONTENT_ON_INK).fontSize(MobileDesignTypography.labelSmall.size).height(40).layoutWeight(1) .backgroundColor(INK).borderRadius(12) .onClick(() => { this.targetPath = this.tools.browsePath; this.browsing = false; }) }.enabled(!this.tools.browseBusy && this.tools.browseError.length === 0 && this.tools.browsePath.length > 0) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SkillPickerSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SkillPickerSheet.ets new file mode 100644 index 0000000000..cfb8e1bbcf --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SkillPickerSheet.ets @@ -0,0 +1,464 @@ +import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { + CommandCatalogProjection, + COMMAND_CATALOG_FILTER_ALL, + SkillCatalogFilter, + SkillCatalogPresentation, + SkillCatalogRow, + SkillCatalogSection +} from '../../services/CommandCatalogProjection'; +import { + CommandPaletteCatalogStatus, + CommandPalettePolicy, + CommandPaletteProbeFailure +} from '../../services/CommandPalettePolicy'; +import { CommandPalettePresentation } from '../state/CommandPaletteState'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; +import { AppActionButton, AppActionStyle } from './AppActionButton'; +import { SheetCloseHeader } from './SheetCloseHeader'; +import { + SHEET_ACTION_HEIGHT, + SHEET_FOOTER_BOTTOM_PADDING, + SHEET_FOOTER_TOP_PADDING, + SHEET_HORIZONTAL_PADDING, + SHEET_TOP_RADIUS +} from './SheetLayout'; +import { CARD, CONTENT_ON_INK, INK, LINE, MUTED, PAGE_BG, SOFT, SUBTLE, TRANSPARENT } from './Theme'; + +// The picker's own geometry, matching the approved design's half-sheet. +const SKILL_SEARCH_HEIGHT: number = 42; +const SKILL_SEARCH_RADIUS: number = 14; +const FILTER_CHIP_HEIGHT: number = 28; +const SKILL_ICON_SURFACE: number = 36; +const SKILL_ICON_RADIUS: number = 10; +const SKILL_SECTION_HEIGHT: number = 28; +const SKILL_TAG_HEIGHT: number = 16; +const SKILL_UNAVAILABLE_OPACITY: number = 0.4; + +/** + * The skill picker, opened from the composer's 技能 chip. + * + * It is the row's other half: the chips run commands, and this chooses the one + * skill token a message may carry. Selecting inserts; it never sends. The sheet + * is also where an unsupported desktop is explained, because a dead chip would + * say less than an empty catalog with a reason and a way to re-check it. + */ +@ComponentV2 +export struct SkillPickerSheet { + @Param palette: CommandPalettePresentation = CommandPalettePresentation.hidden(); + @Event onSelect: (skillId: string, skillLabel: string) => void = (_skillId: string, _skillLabel: string) => {}; + @Event onRefresh: () => void = () => {}; + @Event onClose: () => void = () => {}; + @Local query: string = ''; + @Local filterId: string = COMMAND_CATALOG_FILTER_ALL; + // The sheet is a bottom sheet under the immersive shell, so it reads the + // navigation-bar strip itself and ends in a tail that rests above it. + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + // The next open starts from the whole catalog rather than from whatever the + // previous visit was filtering. + this.query = ''; + this.filterId = COMMAND_CATALOG_FILTER_ALL; + } + + build() { + Column() { + SheetCloseHeader({ + title: RemoteI18n.t('commands.skills.title'), + showDivider: true, + onClose: this.onClose + }) + this.SearchBox() + this.Filters() + if (this.isLoading()) { + this.LoadingState() + } else if (this.isUnavailable()) { + this.UnavailableState() + } else if (this.presentation().isEmpty) { + this.EmptyState() + } else if (this.presentation().hasNoMatches) { + this.NoMatchState() + } else { + this.SkillList() + } + this.Footer() + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + .borderRadius({ topLeft: SHEET_TOP_RADIUS, topRight: SHEET_TOP_RADIUS }) + } + + @Builder + SearchBox() { + Row() { + Row({ space: 8 }) { + SymbolGlyph($r('sys.symbol.magnifyingglass')) + .fontSize(16) + .fontColor([MUTED]) + TextInput({ placeholder: RemoteI18n.t('commands.skills.searchPlaceholder'), text: this.query }) + .layoutWeight(1) + .height(SKILL_SEARCH_HEIGHT) + .fontSize(MobileDesignTypography.bodySmall.size) + .fontColor(INK) + .placeholderColor(MUTED) + .backgroundColor(TRANSPARENT) + .padding(0) + .maxLines(1) + .enabled(!this.isUnavailable()) + .onChange((value: string) => { + this.query = value; + }) + } + .width('100%') + .height(SKILL_SEARCH_HEIGHT) + .padding({ left: 12, right: 12 }) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(SKILL_SEARCH_RADIUS) + .opacity(this.isUnavailable() ? SKILL_UNAVAILABLE_OPACITY : 1) + } + // A 100%-width child cannot inset itself with margin (margin lands outside + // the border and overflows the parent), so the wrapper owns the inset and + // the bordered box fills what remains. + .width('100%') + .padding({ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING, top: 12 }) + } + + @Builder + Filters() { + Scroll() { + Row({ space: 4 }) { + ForEach(this.presentation().filters, (filter: SkillCatalogFilter) => { + Text(`${RemoteI18n.t(filter.labelKey)} ${filter.count}`) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(this.filterId === filter.id ? CONTENT_ON_INK : MUTED) + .backgroundColor(this.filterId === filter.id ? INK : SOFT) + .height(FILTER_CHIP_HEIGHT) + .padding({ left: 12, right: 12 }) + .borderRadius(FILTER_CHIP_HEIGHT / 2) + .accessibilityText(RemoteI18n.t(filter.labelKey)) + .onClick(() => { + if (!this.isUnavailable()) { + this.filterId = filter.id; + } + }) + }, (filter: SkillCatalogFilter) => filter.id) + } + .padding({ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING }) + } + .scrollable(ScrollDirection.Horizontal) + .scrollBar(BarState.Off) + .align(Alignment.Start) + .width('100%') + .height(FILTER_CHIP_HEIGHT) + .margin({ top: 12 }) + .opacity(this.isUnavailable() ? SKILL_UNAVAILABLE_OPACITY : 1) + } + + @Builder + SkillList() { + Scroll() { + Column({ space: 2 }) { + ForEach(this.presentation().sections, (section: SkillCatalogSection) => { + this.SectionHeader(section) + ForEach(section.rows, (row: SkillCatalogRow) => { + this.SkillRow(row) + }, (row: SkillCatalogRow) => row.id) + }, (section: SkillCatalogSection) => section.id) + } + .width('100%') + .padding({ bottom: this.insets.tailSpacing(12) }) + } + .width('100%') + .layoutWeight(1) + .align(Alignment.TopStart) + .scrollBar(BarState.Off) + .margin({ top: 6 }) + } + + @Builder + SectionHeader(section: SkillCatalogSection) { + Row({ space: 6 }) { + Text(RemoteI18n.t(section.titleKey)) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(SUBTLE) + Text(`${section.count}`) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(SUBTLE) + Blank() + } + .width('100%') + .height(SKILL_SECTION_HEIGHT) + .padding({ left: SHEET_HORIZONTAL_PADDING + 2, right: SHEET_HORIZONTAL_PADDING + 2 }) + .alignItems(VerticalAlign.Center) + } + + @Builder + SkillRow(row: SkillCatalogRow) { + Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph(row.isBuiltin ? + $r('sys.symbol.wrench_and_screwdriver') : $r('sys.symbol.doc_text')) + .fontSize(20) + .fontColor([MUTED]) + } + .width(SKILL_ICON_SURFACE) + .height(SKILL_ICON_SURFACE) + .backgroundColor(this.isSelected(row) ? CARD : SOFT) + .borderRadius(SKILL_ICON_RADIUS) + + Column({ space: 1 }) { + Row({ space: 6 }) { + Text(row.name) + .fontSize(MobileDesignTypography.titleSmall.size) + .lineHeight(MobileDesignTypography.titleSmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.rowTag(row).length > 0) { + Text(this.rowTag(row)) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(SKILL_TAG_HEIGHT) + .fontColor(MUTED) + .padding({ left: 4, right: 4 }) + .backgroundColor(this.isSelected(row) ? CARD : SOFT) + .borderRadius(4) + } + Blank() + } + .width('100%') + .alignItems(VerticalAlign.Center) + + Text(row.description) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + Column({ space: 2 }) { + Text(row.insertHint) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(SUBTLE) + if (this.isSelected(row)) { + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(16) + .fontColor([INK]) + } + } + .alignItems(HorizontalAlign.End) + } + .width('100%') + .constraintSize({ minHeight: MobileDesignGeometry.selectionRowHeight }) + .padding({ left: SHEET_HORIZONTAL_PADDING + 4, right: SHEET_HORIZONTAL_PADDING + 4, top: 8, bottom: 8 }) + .backgroundColor(this.isSelected(row) ? SOFT : TRANSPARENT) + .borderRadius(12) + .accessibilityText(`${row.name} · ${row.insertHint}`) + .onClick(() => { + this.onSelect(row.id, row.name); + }) + } + + @Builder + LoadingState() { + Column() { + LoadingProgress() + .width(24) + .height(24) + .color(MUTED) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + } + + /** + * The read did not answer, so the sheet explains and offers the re-check. + * + * What it claims follows from how the read failed: a host that reported the + * version gap is an upgrade, a host that refused for its own reasons is quoted, + * and a read that never reached a host is a connection to look at. Claiming a + * version gap for a timeout would send the user to update a desktop that is + * already current. + */ + @Builder + UnavailableState() { + Column({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.info_circle')) + .fontSize(26) + .fontColor([MUTED]) + } + .width(64) + .height(64) + .backgroundColor(SOFT) + .borderRadius(32) + + Text(RemoteI18n.t(this.unavailableTitleKey())) + .fontSize(MobileDesignTypography.bodyLarge.size) + .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .textAlign(TextAlign.Center) + + Text(RemoteI18n.t(this.unavailableBodyKey())) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .constraintSize({ maxWidth: 260 }) + + // The desktop's own words for the refusal. A version gap reads as one, and + // so does the other common cause — a workspace or session the host cannot + // find — which this app would otherwise have to guess at. + if (this.palette.catalogErrorText.length > 0) { + Text(this.palette.catalogErrorText) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(SUBTLE) + .textAlign(TextAlign.Center) + .constraintSize({ maxWidth: 280 }) + .maxLines(4) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + + AppActionButton({ + label: RemoteI18n.t('commands.skills.recheck'), + style: AppActionStyle.Secondary, + actionHeight: SHEET_ACTION_HEIGHT, + elevated: false, + onAction: this.onRefresh + }) + .width(176) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .padding({ left: 10, right: 10, bottom: 12 }) + } + + @Builder + NoMatchState() { + Column() { + Text(RemoteI18n.t('commands.skills.noMatch')) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .padding({ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING }) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + } + + /** + * The host answered, and this mode has nothing to invoke. + * + * Distinct from a failed probe: the read worked, so there is nothing to + * re-check, and the sentence says which of the two happened. + */ + @Builder + EmptyState() { + Column() { + Text(RemoteI18n.t('commands.skills.empty')) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .constraintSize({ maxWidth: 260 }) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .padding({ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING }) + } + + @Builder + Footer() { + Text(RemoteI18n.t(this.isUnavailable() ? + 'commands.skills.footerUnavailable' : 'commands.skills.footer')) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .width('100%') + .padding({ + left: SHEET_HORIZONTAL_PADDING, + right: SHEET_HORIZONTAL_PADDING, + top: SHEET_FOOTER_TOP_PADDING, + bottom: SHEET_FOOTER_BOTTOM_PADDING + }) + } + + private presentation(): SkillCatalogPresentation { + return CommandCatalogProjection.present(this.palette.catalog, this.query, this.filterId); + } + + private isLoading(): boolean { + return this.palette.catalogStatus === CommandPaletteCatalogStatus.Loading; + } + + /** + * The read did not answer, so there is no list to show. + * + * The row's own explanation counts too, because a chip can reach this sheet + * while the read that would fill it is still outstanding. + */ + private isUnavailable(): boolean { + if (this.palette.catalogStatus === CommandPaletteCatalogStatus.Failed) { + return true; + } + return !this.isLoading() && this.palette.rowReasonKey.length > 0; + } + + private unavailableTitleKey(): string { + return CommandPalettePolicy.probeTitleKey(this.failure()); + } + + private unavailableBodyKey(): string { + return CommandPalettePolicy.probeBodyKey(this.failure()); + } + + /** + * How the read failed, as this sheet tells it. + * + * A read that never failed — the row is explaining something else — is not a + * version claim either, so it takes the same wording as an unreachable host. + */ + private failure(): string { + if (this.palette.catalogStatus !== CommandPaletteCatalogStatus.Failed) { + return CommandPaletteProbeFailure.Unreachable; + } + return this.palette.catalogFailure; + } + + private rowTag(row: SkillCatalogRow): string { + return row.group.length > 0 ? row.group : row.source; + } + + private isSelected(row: SkillCatalogRow): boolean { + return this.palette.skillId.length > 0 && this.palette.skillId === row.id; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets index 5b356d8209..14376ca7d3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets @@ -21,6 +21,12 @@ export const ACCENT: ResourceColor = $r('app.color.accent'); export const FILE_LINK: ResourceColor = $r('app.color.file_link'); export const PRIMARY_ACTION: ResourceColor = $r('app.color.primary_action'); export const CONTENT_ON_ACTION: ResourceColor = $r('app.color.content_on_action'); +/** + * Content that sits on an INK-filled surface. INK inverts per theme while + * content_on_action is pinned to the action color, so an ink chip must not + * borrow CONTENT_ON_ACTION — page_bg is the theme-correct inverse of INK. + */ +export const CONTENT_ON_INK: ResourceColor = $r('app.color.page_bg'); export const CONNECT_HERO_BG: ResourceColor = $r('app.color.connect_hero_bg'); export const CONNECT_HERO_ACCENT: ResourceColor = $r('app.color.connect_hero_accent'); export const CONNECT_HERO_SECONDARY: ResourceColor = $r('app.color.connect_hero_secondary'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/UsageReportSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/UsageReportSheet.ets new file mode 100644 index 0000000000..e76baa2cae --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/UsageReportSheet.ets @@ -0,0 +1,206 @@ +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CommandPaletteUsageStatus, CommandPalettePresentation } from '../state/CommandPaletteState'; +import { UsageReportRow, UsageReportSection } from '../policy/UsageReportPresentationPolicy'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; +import { AppActionButton, AppActionStyle } from './AppActionButton'; +import { SheetCloseHeader } from './SheetCloseHeader'; +import { SHEET_ACTION_HEIGHT, SHEET_FOOTER_BOTTOM_PADDING, SHEET_FOOTER_TOP_PADDING, SHEET_HORIZONTAL_PADDING, SHEET_TOP_RADIUS } from './SheetLayout'; +import { INK, MUTED, PAGE_BG, SUBTLE } from './Theme'; + +const USAGE_SECTION_HEIGHT: number = 30; +const USAGE_ROW_MIN_HEIGHT: number = 30; + +/** + * The session's usage, opened from the 用量 chip. + * + * Every row comes from the report the host actually sent: the desktop report is + * mirrored field by field and all of it is optional, so this sheet renders what + * is present and never fills a missing metric with a zero. A report that + * arrived with gaps says so at the end instead of pretending to be complete. + */ +@ComponentV2 +export struct UsageReportSheet { + @Param palette: CommandPalettePresentation = CommandPalettePresentation.hidden(); + @Event onRetry: () => void = () => {}; + @Event onClose: () => void = () => {}; + // The sheet is a bottom sheet under the immersive shell, so it reads the + // navigation-bar strip itself and ends in a tail that rests above it. + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + + build() { + Column() { + SheetCloseHeader({ + title: RemoteI18n.t('usage.title'), + showDivider: true, + onClose: this.onClose + }) + if (this.isLoading()) { + this.LoadingState() + } else if (this.isFailed()) { + this.FailedState() + } else if (this.palette.usageSections.length === 0) { + this.EmptyState() + } else { + this.Report() + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + .borderRadius({ topLeft: SHEET_TOP_RADIUS, topRight: SHEET_TOP_RADIUS }) + } + + @Builder + Report() { + Scroll() { + Column({ space: 4 }) { + ForEach(this.palette.usageSections, (section: UsageReportSection) => { + this.SectionHeader(section) + ForEach(section.rows, (row: UsageReportRow) => { + this.ReportRow(row) + }, (row: UsageReportRow, index: number) => `${section.id}-${index}-${row.labelKey}`) + }, (section: UsageReportSection) => section.id) + if (this.palette.usagePartial) { + Text(RemoteI18n.t('usage.partialNote')) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(SUBTLE) + .width('100%') + .padding({ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING, top: 10 }) + } + } + .width('100%') + .padding({ top: 8, bottom: this.insets.tailSpacing(SHEET_FOOTER_BOTTOM_PADDING) }) + } + .width('100%') + .layoutWeight(1) + .align(Alignment.TopStart) + .scrollBar(BarState.Off) + } + + @Builder + SectionHeader(section: UsageReportSection) { + Row() { + Text(RemoteI18n.t(section.titleKey)) + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(SUBTLE) + Blank() + } + .width('100%') + .height(USAGE_SECTION_HEIGHT) + .padding({ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING }) + .alignItems(VerticalAlign.Bottom) + } + + @Builder + ReportRow(row: UsageReportRow) { + Row() { + Text(RemoteI18n.t(row.labelKey)) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Blank() + Text(row.valueKey.length > 0 ? RemoteI18n.t(row.valueKey) : row.value) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.End) + } + .width('100%') + .constraintSize({ minHeight: USAGE_ROW_MIN_HEIGHT }) + .padding({ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING, top: 4, bottom: 4 }) + .alignItems(VerticalAlign.Top) + } + + @Builder + LoadingState() { + Column() { + LoadingProgress() + .width(24) + .height(24) + .color(MUTED) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + } + + /** + * The read failed. The host's own text is shown when it sent one — a usage + * failure is usually about the target session, not about this app. + */ + @Builder + FailedState() { + Column({ space: 10 }) { + Text(RemoteI18n.t('usage.loadFailed')) + .fontSize(MobileDesignTypography.bodyLarge.size) + .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) + .fontColor(INK) + .textAlign(TextAlign.Center) + if (this.palette.usageErrorText.length > 0) { + Text(this.palette.usageErrorText) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .constraintSize({ maxWidth: 280 }) + } + AppActionButton({ + label: RemoteI18n.t('common.retry'), + style: AppActionStyle.Secondary, + actionHeight: SHEET_ACTION_HEIGHT, + elevated: false, + onAction: this.onRetry + }) + .width(176) + .margin({ top: 4 }) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .padding({ left: 10, right: 10, bottom: 12 }) + } + + @Builder + EmptyState() { + Column() { + Text(RemoteI18n.t('usage.empty')) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .padding({ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING }) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .padding({ bottom: SHEET_FOOTER_TOP_PADDING }) + } + + private isLoading(): boolean { + return this.palette.usageStatus === CommandPaletteUsageStatus.Loading; + } + + private isFailed(): boolean { + return this.palette.usageStatus === CommandPaletteUsageStatus.Failed; + } +} 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 43f5168eab..23666cd23e 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 @@ -52,6 +52,10 @@ export struct WideConversationHost { SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SessionDetails); @Param fullTextSheetPlacement: SettingsPlacement = SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UserMessageFullText); + @Param skillPickerSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SkillPicker); + @Param usageReportSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UsageReport); @Param wideMasterPaneWidth: number = 0; @Param wideMasterDetailGap: number = 0; @Param wideDetailContentOffset: number = 0; @@ -367,6 +371,8 @@ export struct WideConversationHost { ), useWidePresentation: true, fullTextSheetPlacement: this.fullTextSheetPlacement, + skillPickerSheetPlacement: this.skillPickerSheetPlacement, + usageReportSheetPlacement: this.usageReportSheetPlacement, onRestoreSidebar: this.onRestoreMasterPane }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets index f3b76098ec..534434ed4b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets @@ -4,7 +4,7 @@ import { WorkspaceTerminalView } from './WorkspaceTerminalView'; import { WorkspaceToolsState, WorkspaceToolsIntent, WorkspaceFileEntry, WorkspaceTerminalEntry, WorkspaceToolLocation } from '../state/WorkspaceToolsState'; import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, CONTENT_ON_ACTION, INK, MEDIA_BACKGROUND, MUTED, PAGE_BG, LINE, SOFT, TRANSPARENT } from './Theme'; +import { CARD, CONTENT_ON_INK, INK, MEDIA_BACKGROUND, MUTED, PAGE_BG, LINE, SOFT, TRANSPARENT } from './Theme'; import { SheetCloseButton } from './SheetCloseButton'; import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; @@ -92,7 +92,7 @@ export struct WorkspaceToolsPanel { .onClick(() => this.onIntent({ type: 'file-action-cancel' })) Text(this.fileActionTitle()).fontColor(INK).fontSize(MobileDesignTypography.titleMedium.size).fontWeight(FontWeight.Medium).layoutWeight(1).maxLines(2) Button(this.fileActionTitle()) - .fontColor(CONTENT_ON_ACTION).backgroundColor(INK).height(40).borderRadius(8) + .fontColor(CONTENT_ON_INK).backgroundColor(INK).height(40).borderRadius(8) .enabled(this.state.fileAction === 'delete' || this.state.fileActionName.trim().length > 0) .onClick(() => this.onIntent({ type: 'file-action-submit' })) }.enabled(!this.state.busy).width('100%') @@ -233,7 +233,7 @@ export struct WorkspaceToolsPanel { Button(RemoteI18n.t('workspaceTools.lineNumbers')).fontColor(MUTED).fontSize(MobileDesignTypography.labelSmall.size) .height(36).backgroundColor(SOFT).borderRadius(8).onClick(() => this.onIntent({ type: 'line-numbers' })) if (this.state.editing) { - Button(RemoteI18n.t('workspaceTools.save')).fontColor(CONTENT_ON_ACTION).backgroundColor(INK).height(36).borderRadius(8) + Button(RemoteI18n.t('workspaceTools.save')).fontColor(CONTENT_ON_INK).backgroundColor(INK).height(36).borderRadius(8) .enabled(!this.state.busy).onClick(() => this.onIntent({ type: 'save' })) } else { Button(RemoteI18n.t('workspaceTools.edit')).fontColor(INK).backgroundColor(SOFT).height(36).borderRadius(8) @@ -263,7 +263,7 @@ export struct WorkspaceToolsPanel { SymbolGlyph($r('sys.symbol.rectangle_stack')).fontColor([MUTED]).fontSize(32) }.width(72).height(72).backgroundColor(SOFT).borderRadius(20) Button(RemoteI18n.t('workspaceTools.createTerminal')) - .fontColor(CONTENT_ON_ACTION).backgroundColor(INK) + .fontColor(CONTENT_ON_INK).backgroundColor(INK) .borderRadius(8).height(44).padding({ left: 18, right: 18 }) .enabled(!this.state.busy) .onClick(() => this.onIntent({ type: 'terminal-create' })) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SettingsPlacementPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SettingsPlacementPolicy.ets index fc4efb4035..c6bb42db65 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SettingsPlacementPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SettingsPlacementPolicy.ets @@ -22,7 +22,16 @@ export enum SettingsSheetKind { * it is placed by the same contract: an overlay sheet that must land inside a * foldable's leaf, and inside the operate half of a half-folded screen. */ - UserMessageFullText = 'user_message_full_text' + UserMessageFullText = 'user_message_full_text', + /** + * The composer's skill picker. Same reasoning as the full-text panel: it is a + * large multi-step surface opened from the conversation, not an anchored menu, + * so a bottom sheet is its compact form and the placement contract decides + * where it lands on a folded or wide screen. + */ + SkillPicker = 'skill_picker', + /** The composer's session usage report: a read-only metrics list. */ + UsageReport = 'usage_report' } export class SettingsPlacement { @@ -59,6 +68,9 @@ export class SettingsPlacementPolicy { static readonly SIDE_VERTICAL_MARGIN: number = 80; static readonly SESSION_DETAILS_HEIGHT: number = 560; static readonly REMOTE_VIEW_SETTINGS_HEIGHT: number = 520; + /** The skill picker's share of a phone screen, per its approved design. */ + static readonly SKILL_PICKER_HEIGHT: number = 540; + static readonly USAGE_REPORT_HEIGHT: number = 560; static compactBottom(kind: SettingsSheetKind = SettingsSheetKind.Settings): SettingsPlacement { return SettingsPlacementPolicy.bottomPlacement(kind); @@ -222,9 +234,20 @@ export class SettingsPlacementPolicy { } private static bottomPlacement(kind: SettingsSheetKind): SettingsPlacement { - const height = kind === SettingsSheetKind.RemoteViewSettings ? - SettingsPlacementPolicy.REMOTE_VIEW_SETTINGS_HEIGHT : 0; - return new SettingsPlacement(SettingsPlacementMode.Bottom, 0, height, 0); + return new SettingsPlacement(SettingsPlacementMode.Bottom, 0, SettingsPlacementPolicy.bottomHeight(kind), 0); + } + + private static bottomHeight(kind: SettingsSheetKind): number { + if (kind === SettingsSheetKind.RemoteViewSettings) { + return SettingsPlacementPolicy.REMOTE_VIEW_SETTINGS_HEIGHT; + } + if (kind === SettingsSheetKind.SkillPicker) { + return SettingsPlacementPolicy.SKILL_PICKER_HEIGHT; + } + if (kind === SettingsSheetKind.UsageReport) { + return SettingsPlacementPolicy.USAGE_REPORT_HEIGHT; + } + return 0; } private static kindSideHeight(viewportHeight: number, kind: SettingsSheetKind): number { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UsageReportPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UsageReportPresentationPolicy.ets new file mode 100644 index 0000000000..e0770f0181 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UsageReportPresentationPolicy.ets @@ -0,0 +1,305 @@ +import { + RemoteSessionUsageReport, + RemoteUsageModelRow, + RemoteUsageToolRow +} from '../../model/RemoteModels'; + +/** + * Projects the session usage report into the sheet's rows. + * + * The report is mirrored from the desktop contract and every field of it is + * optional, so this policy renders exactly the metrics the payload carries and + * omits the rest: an unmeasured number must not be drawn as zero, and a row the + * host never sent has nothing to explain. Both spellings of each metric are + * accepted, because the payload travels between two independently built versions. + */ + +export class UsageReportRow { + readonly labelKey: string; + readonly value: string; + /** Set when the value is a catalog word rather than a formatted number. */ + readonly valueKey: string; + + constructor(labelKey: string, value: string, valueKey: string = '') { + this.labelKey = labelKey; + this.value = value; + this.valueKey = valueKey; + } +} + +export class UsageReportSection { + readonly id: string; + readonly titleKey: string; + readonly rows: UsageReportRow[]; + + constructor(id: string, titleKey: string, rows: UsageReportRow[]) { + this.id = id; + this.titleKey = titleKey; + this.rows = rows; + } +} + +export class UsageReportPresentation { + readonly sections: UsageReportSection[]; + /** Coverage below `complete`: the sheet says so instead of implying totals. */ + readonly partial: boolean; + readonly isEmpty: boolean; + + constructor(sections: UsageReportSection[], partial: boolean) { + this.sections = sections; + this.partial = partial; + this.isEmpty = sections.length === 0; + } +} + +export class UsageReportPresentationPolicy { + static readonly partialNoteKey: string = 'usage.partialNote'; + static readonly yesKey: string = 'usage.yes'; + static readonly noKey: string = 'usage.no'; + + static present(report?: RemoteSessionUsageReport): UsageReportPresentation { + if (!report) { + return new UsageReportPresentation([], false); + } + const sections: UsageReportSection[] = []; + const overview = UsageReportPresentationPolicy.overviewRows(report); + if (overview.length > 0) { + sections.push(new UsageReportSection('overview', 'usage.sectionOverview', overview)); + } + const time = UsageReportPresentationPolicy.timeRows(report); + if (time.length > 0) { + sections.push(new UsageReportSection('time', 'usage.sectionTime', time)); + } + const tokens = UsageReportPresentationPolicy.tokenRows(report); + if (tokens.length > 0) { + sections.push(new UsageReportSection('tokens', 'usage.sectionTokens', tokens)); + } + const models = UsageReportPresentationPolicy.modelRows(report); + if (models.length > 0) { + sections.push(new UsageReportSection('models', 'usage.sectionModels', models)); + } + const tools = UsageReportPresentationPolicy.toolRows(report); + if (tools.length > 0) { + sections.push(new UsageReportSection('tools', 'usage.sectionTools', tools)); + } + const files = UsageReportPresentationPolicy.fileRows(report); + if (files.length > 0) { + sections.push(new UsageReportSection('files', 'usage.sectionFiles', files)); + } + const compression = UsageReportPresentationPolicy.compressionRows(report); + if (compression.length > 0) { + sections.push(new UsageReportSection('compression', 'usage.sectionCompression', compression)); + } + const errors = UsageReportPresentationPolicy.errorRows(report); + if (errors.length > 0) { + sections.push(new UsageReportSection('errors', 'usage.sectionErrors', errors)); + } + return new UsageReportPresentation(sections, UsageReportPresentationPolicy.isPartial(report)); + } + + static isPartial(report: RemoteSessionUsageReport): boolean { + const level = UsageReportPresentationPolicy.text(report.coverage?.level).toLowerCase(); + return level.length > 0 && level !== 'complete'; + } + + private static overviewRows(report: RemoteSessionUsageReport): UsageReportRow[] { + const rows: UsageReportRow[] = []; + UsageReportPresentationPolicy.pushNumber( + rows, 'usage.scopeTurns', UsageReportPresentationPolicy.number(report.scope?.turn_count, report.scope?.turnCount)); + const includesSubagents = report.scope?.includes_subagents ?? report.scope?.includesSubagents; + if (includesSubagents !== undefined) { + rows.push(new UsageReportRow( + 'usage.scopeSubagents', + '', + includesSubagents ? UsageReportPresentationPolicy.yesKey : UsageReportPresentationPolicy.noKey)); + } + UsageReportPresentationPolicy.pushText(rows, 'usage.coverageLevel', report.coverage?.level); + const missing = report.coverage?.missing; + if (missing && missing.length > 0) { + rows.push(new UsageReportRow('usage.coverageMissing', missing.join(', '))); + } + UsageReportPresentationPolicy.pushText(rows, 'usage.workspace', report.workspace?.path_label ?? report.workspace?.pathLabel); + return rows; + } + + private static timeRows(report: RemoteSessionUsageReport): UsageReportRow[] { + const rows: UsageReportRow[] = []; + UsageReportPresentationPolicy.pushDuration(rows, 'usage.timeSpan', report.time?.wall_time_ms, report.time?.wallTimeMs); + UsageReportPresentationPolicy.pushDuration( + rows, 'usage.timeTurns', report.time?.active_turn_ms, report.time?.activeTurnMs); + UsageReportPresentationPolicy.pushDuration(rows, 'usage.timeModel', report.time?.model_ms, report.time?.modelMs); + UsageReportPresentationPolicy.pushDuration(rows, 'usage.timeTools', report.time?.tool_ms, report.time?.toolMs); + return rows; + } + + private static tokenRows(report: RemoteSessionUsageReport): UsageReportRow[] { + const rows: UsageReportRow[] = []; + UsageReportPresentationPolicy.pushTokens( + rows, 'usage.tokensInput', report.tokens?.input_tokens, report.tokens?.inputTokens); + UsageReportPresentationPolicy.pushTokens( + rows, 'usage.tokensOutput', report.tokens?.output_tokens, report.tokens?.outputTokens); + UsageReportPresentationPolicy.pushTokens( + rows, 'usage.tokensTotal', report.tokens?.total_tokens, report.tokens?.totalTokens); + UsageReportPresentationPolicy.pushTokens( + rows, 'usage.tokensCached', report.tokens?.cached_tokens, report.tokens?.cachedTokens); + return rows; + } + + private static modelRows(report: RemoteSessionUsageReport): UsageReportRow[] { + const models = report.models; + if (!models || models.length === 0) { + return []; + } + return models.map((model: RemoteUsageModelRow): UsageReportRow => { + const name = UsageReportPresentationPolicy.text(model.model_id ?? model.modelId); + const calls = UsageReportPresentationPolicy.number(model.call_count, model.callCount); + const tokens = UsageReportPresentationPolicy.number(model.total_tokens, model.totalTokens); + const parts: string[] = []; + if (calls !== undefined) { + parts.push(`${calls}`); + } + if (tokens !== undefined) { + parts.push(UsageReportPresentationPolicy.tokenText(tokens)); + } + return new UsageReportRow('usage.modelRow', `${name} · ${parts.join(' · ')}`); + }); + } + + private static toolRows(report: RemoteSessionUsageReport): UsageReportRow[] { + const tools = report.tools; + if (!tools || tools.length === 0) { + return []; + } + return tools.map((tool: RemoteUsageToolRow): UsageReportRow => { + const name = UsageReportPresentationPolicy.text(tool.tool_name ?? tool.toolName); + const calls = UsageReportPresentationPolicy.number(tool.call_count, tool.callCount); + const errors = UsageReportPresentationPolicy.number(tool.error_count, tool.errorCount); + const parts: string[] = []; + if (calls !== undefined) { + parts.push(`${calls}`); + } + if (errors !== undefined) { + parts.push(`${errors}`); + } + return new UsageReportRow('usage.toolRow', `${name} · ${parts.join(' · ')}`); + }); + } + + private static fileRows(report: RemoteSessionUsageReport): UsageReportRow[] { + const rows: UsageReportRow[] = []; + UsageReportPresentationPolicy.pushNumber( + rows, 'usage.filesChanged', UsageReportPresentationPolicy.number(report.files?.changed_files, report.files?.changedFiles)); + UsageReportPresentationPolicy.pushNumber( + rows, 'usage.filesAdded', UsageReportPresentationPolicy.number(report.files?.added_lines, report.files?.addedLines)); + UsageReportPresentationPolicy.pushNumber( + rows, 'usage.filesDeleted', UsageReportPresentationPolicy.number(report.files?.deleted_lines, report.files?.deletedLines)); + return rows; + } + + private static compressionRows(report: RemoteSessionUsageReport): UsageReportRow[] { + const rows: UsageReportRow[] = []; + UsageReportPresentationPolicy.pushNumber( + rows, 'usage.compactionCount', + UsageReportPresentationPolicy.number(report.compression?.compaction_count, report.compression?.compactionCount)); + UsageReportPresentationPolicy.pushTokens( + rows, 'usage.compactionSaved', report.compression?.saved_tokens, report.compression?.savedTokens); + return rows; + } + + private static errorRows(report: RemoteSessionUsageReport): UsageReportRow[] { + const rows: UsageReportRow[] = []; + UsageReportPresentationPolicy.pushNumber( + rows, 'usage.errorsTotal', UsageReportPresentationPolicy.number(report.errors?.total_errors, report.errors?.totalErrors)); + UsageReportPresentationPolicy.pushNumber( + rows, 'usage.errorsTools', UsageReportPresentationPolicy.number(report.errors?.tool_errors, report.errors?.toolErrors)); + UsageReportPresentationPolicy.pushNumber( + rows, 'usage.errorsModels', UsageReportPresentationPolicy.number(report.errors?.model_errors, report.errors?.modelErrors)); + return rows; + } + + private static pushText(rows: UsageReportRow[], labelKey: string, value?: string): void { + const text = UsageReportPresentationPolicy.text(value); + if (text.length > 0) { + rows.push(new UsageReportRow(labelKey, text)); + } + } + + private static pushNumber(rows: UsageReportRow[], labelKey: string, value?: number): void { + if (value === undefined) { + return; + } + rows.push(new UsageReportRow(labelKey, `${value}`)); + } + + private static pushTokens(rows: UsageReportRow[], labelKey: string, camel?: number, snake?: number): void { + const value = UsageReportPresentationPolicy.number(camel, snake); + if (value === undefined) { + return; + } + rows.push(new UsageReportRow(labelKey, UsageReportPresentationPolicy.tokenText(value))); + } + + private static pushDuration(rows: UsageReportRow[], labelKey: string, camel?: number, snake?: number): void { + const value = UsageReportPresentationPolicy.number(camel, snake); + if (value === undefined) { + return; + } + rows.push(new UsageReportRow(labelKey, UsageReportPresentationPolicy.durationText(value))); + } + + private static number(first?: number, second?: number): number | undefined { + if (first !== undefined) { + return first; + } + return second; + } + + private static text(value?: string): string { + return value === undefined ? '' : value; + } + + /** Token counts read in the same compact units the desktop report uses. */ + static tokenText(value: number): string { + if (value < 1000) { + return `${value}`; + } + if (value < 1000000) { + return `${UsageReportPresentationPolicy.rounded(value / 1000)}K`; + } + if (value < 1000000000) { + return `${UsageReportPresentationPolicy.rounded(value / 1000000)}M`; + } + return `${UsageReportPresentationPolicy.rounded(value / 1000000000)}B`; + } + + static durationText(value: number): string { + if (value < 1000) { + return `${value}ms`; + } + const seconds = Math.floor(value / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remaining = seconds % 60; + if (minutes < 60) { + return remaining === 0 ? `${minutes}m` : `${minutes}m ${remaining}s`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes === 0 ? `${hours}h` : `${hours}h ${remainingMinutes}m`; + } + + private static rounded(value: number): string { + const scaled = Math.round(value * 100) / 100; + const whole = Math.floor(scaled); + const fraction = Math.round((scaled - whole) * 100); + if (fraction === 0) { + return `${whole}`; + } + if (fraction % 10 === 0) { + return `${whole}.${fraction / 10}`; + } + return `${whole}.${fraction < 10 ? '0' : ''}${fraction}`; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CommandPalettePreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CommandPalettePreview.ets new file mode 100644 index 0000000000..b5d21bd5f8 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CommandPalettePreview.ets @@ -0,0 +1,500 @@ +import { + RemoteCommandPaletteClient, + RemoteCommandPaletteController, + RemoteCommandPaletteScope, + RemoteSkillCatalogQuery +} from '../../services/RemoteCommandPaletteController'; +import { CommandCatalogProjection, RemoteModeSkillRow } from '../../services/CommandCatalogProjection'; +import { + CommandPaletteCatalogStatus, + CommandPalettePolicy, + CommandPaletteProbeFailure, + commandPaletteProbeReady +} from '../../services/CommandPalettePolicy'; +import { HostInvokeRefusal } from '../../services/HostInvokeRefusal'; +import { CommandPalettePresentation, CommandPaletteState } from '../state/CommandPaletteState'; +import { CommandPaletteAction, CommandPaletteRequest } from '../actions/ConversationIntent'; +import { RemoteCommandCatalog, RemoteSessionUsageReport } from '../../model/RemoteModels'; +import { ComposerBar, ComposerPresentation } from '../components/ComposerBar'; +import { CommandChipRow } from '../components/CommandChipRow'; +import { SkillPickerSheet } from '../components/SkillPickerSheet'; +import { UsageReportSheet } from '../components/UsageReportSheet'; +import { AdaptiveSheetOptions } from '../components/AdaptiveSheetOptions'; +import { SettingsPlacementPolicy, SettingsSheetKind } from '../policy/SettingsPlacementPolicy'; +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { INK, MUTED, PAGE_BG } from '../components/Theme'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; + +/** + * The host rows this fixture answers a catalog read with. + * + * Shaped like the real `ModeSkillInfo` payload: built-in skills are the only ones + * that carry a group key, and the custom rows stand for a user skill that reuses + * a built-in name plus one of their own. The last row is one the host will not + * let a user invoke, so the picker must drop it. + */ +const PREVIEW_SKILL_ROWS: RemoteModeSkillRow[] = [ + { + key: 'plan', name: 'Plan', description: 'Break the request into an ordered plan', + level: 'user', isBuiltin: true, groupKey: 'workflow', isShadowed: false, + allowUserInvocation: true, selectedForRuntime: true, effectiveEnabled: true, globallyEnabled: true + }, + { + key: 'review', name: 'Review', description: 'Review the change for real defects', + level: 'user', isBuiltin: true, groupKey: 'workflow', isShadowed: false, + allowUserInvocation: true, selectedForRuntime: true, effectiveEnabled: true, globallyEnabled: true + }, + { + key: 'summarize', name: 'Summarize', description: 'Condense the current thread', + level: 'user', isBuiltin: true, groupKey: 'writing', isShadowed: false, + allowUserInvocation: true, selectedForRuntime: true, effectiveEnabled: true, globallyEnabled: true + }, + { + key: 'changelog', name: 'Changelog', description: 'Draft release notes from the diff', + level: 'project', isBuiltin: false, isShadowed: false, + allowUserInvocation: true, selectedForRuntime: true, effectiveEnabled: true, globallyEnabled: true + }, + { + key: 'plan.team', name: 'Plan', description: 'Plan the work the way this project does', + level: 'user', isBuiltin: false, isShadowed: false, + allowUserInvocation: true, selectedForRuntime: true, effectiveEnabled: true, globallyEnabled: true + }, + { + key: 'implicit', name: 'Implicit only', description: 'Never offered for a manual pick', + level: 'user', isBuiltin: false, isShadowed: false, + allowUserInvocation: false, selectedForRuntime: true, effectiveEnabled: true, globallyEnabled: true + } +]; + +const PREVIEW_USAGE: RemoteSessionUsageReport = { + schemaVersion: 1, + sessionId: 'preview-session', + scope: { kind: 'session', turnCount: 7, includesSubagents: false }, + coverage: { level: 'partial', missing: ['files'] }, + time: { wallTimeMs: 1_842_000, activeTurnMs: 611_000, modelMs: 402_000, toolMs: 121_000 }, + tokens: { inputTokens: 128_400, outputTokens: 22_950, totalTokens: 151_350, cachedTokens: 61_200 }, + models: [{ modelId: 'claude-sonnet-4', callCount: 14, totalTokens: 151_350 }], + tools: [{ toolName: 'read_file', callCount: 21, errorCount: 1 }], + compression: { compactionCount: 1, savedTokens: 48_000 }, + errors: { totalErrors: 1, toolErrors: 1, modelErrors: 0 } +}; + +/** + * The host, minus the network. + * + * Only the four `host_invoke` verbs are answered here, and the catalog read can + * fail in each of the three ways the app distinguishes: a host that names the + * version gap, a host that refuses for its own reason, and a wire that answers + * nothing at all. The held compaction never settles until the fixture releases + * it, which is how the running state is held still for a screenshot. + */ +@ObservedV2 +class CommandPalettePreviewClient implements RemoteCommandPaletteClient { + /** '' | 'version_gap' | 'refused' | 'unreachable' | 'pending'. */ + catalogOutcome: string = ''; + failCompact: boolean = false; + holdCompact: boolean = false; + private release: () => void = () => {}; + + async skillCatalog(_query: RemoteSkillCatalogQuery): Promise { + if (this.catalogOutcome === 'pending') { + // A read that has not answered yet: the row waits rather than degrading. + return new Promise((): void => {}); + } + if (this.catalogOutcome === 'version_gap') { + throw new HostInvokeRefusal('get_mode_skill_configs', + "command 'get_mode_skill_configs' is unknown to this OpenBitFun desktop peer host version; " + + 'upgrade the peer host or check the command name'); + } + if (this.catalogOutcome === 'refused') { + throw new HostInvokeRefusal('get_mode_skill_configs', 'workspace not found for this session'); + } + if (this.catalogOutcome === 'unreachable') { + // The bridge answering without reaching a command handler: `ok: false` on + // the wire, but no command was refused, so the row must not say one was. + throw new HostInvokeRefusal('get_mode_skill_configs', + "peer host invoke timed out after 120s for 'get_mode_skill_configs'"); + } + return CommandCatalogProjection.parseHostCatalog(PREVIEW_SKILL_ROWS); + } + + async compactSession(_query: RemoteCommandPaletteScope): Promise { + if (this.holdCompact) { + await new Promise((resolve: () => void) => { + this.release = resolve; + }); + } + if (this.failCompact) { + throw new Error('The target device did not start the compaction. Try again later.'); + } + return 'Context compacted'; + } + + /** Lets a held compaction finish, so the completion line can be seen too. */ + releaseHeld(): void { + this.release(); + } + + async runInitAgentsMd(_query: RemoteCommandPaletteScope): Promise { + return 'AGENTS.md generated'; + } + + async sessionUsageReport(_query: RemoteCommandPaletteScope): Promise { + return PREVIEW_USAGE; + } +} + +/** + * The real command row, picker and usage sheet over a network-free host. + * + * The controller, its probe and the projection are production code; only the + * verbs are answered here, so what these screenshots show is what the row does + * with a catalog, a refusal, a running verb and a settled failure rather than + * what a mock of the row would draw. + */ +@ComponentV2 +export struct CommandPalettePreview { + @Local draft: string = ''; + @Local showSkillPicker: boolean = false; + @Local showUsageReport: boolean = false; + @Local sheetPresented: boolean = false; + @Local wide: boolean = false; + /** + * Bumped by every state transition. + * + * It is read in `build()` so the fixture re-renders on the controller's + * callbacks, and it doubles as the state name a screenshot can be read + * against. + */ + @Local revision: number = 0; + private palette: CommandPaletteState = new CommandPaletteState(); + private client: CommandPalettePreviewClient = new CommandPalettePreviewClient(); + private controller: RemoteCommandPaletteController = new RemoteCommandPaletteController( + this.client, + { + onCatalogLoading: (): void => { + this.palette.setCatalogLoading(); + this.stateChanged(); + }, + onCatalogReady: (_sessionId: string, catalog: RemoteCommandCatalog): void => { + this.palette.setCatalogReady(catalog); + this.stateChanged(); + }, + onCatalogFailed: (_sessionId: string, errorText: string, failure: string): void => { + this.palette.setCatalogFailed(errorText, failure); + this.stateChanged(); + }, + onCommandStarted: (_sessionId: string, wireAction: string): void => { + this.palette.beginCommand(wireAction); + this.stateChanged(); + }, + onCommandSettled: (_sessionId: string, wireAction: string, errorText: string, _message: string): void => { + this.palette.settleCommand(wireAction, errorText); + this.stateChanged(); + }, + onCommandNoticeExpired: (_sessionId: string, wireAction: string): void => { + this.palette.expireNotice(wireAction); + this.stateChanged(); + }, + onUsageLoading: (): void => { + this.palette.setUsageLoading(); + this.stateChanged(); + }, + onUsageReady: (_sessionId: string, report: RemoteSessionUsageReport | undefined): void => { + this.palette.setUsageReady(report); + this.stateChanged(); + }, + onUsageFailed: (_sessionId: string, errorText: string): void => { + this.palette.setUsageFailed(errorText); + this.stateChanged(); + } + } + ); + + aboutToAppear(): void { + const variant = this.variant(); + this.client.catalogOutcome = CommandPalettePreview.catalogOutcomeFor(variant); + this.client.failCompact = variant === 'failed'; + this.client.holdCompact = variant === 'busy'; + this.draft = variant === 'token' || variant === 'goal' ? '' : 'Check the redirect handler'; + if (variant === 'token' || variant === 'busy') { + this.palette.setSkill('plan', 'Plan'); + } + // The two halves of the goal chip's ownership rule: lit with its prefill in + // the draft, and the state the commit that consumes that draft leaves behind. + // The rule itself lives in `RemotePageState.prepareComposerSubmission` and is + // pinned by `command-palette-composer.test.cjs`; this fixture only frames it. + if (variant === 'goal') { + const toggled = CommandPalettePolicy.toggleGoal(this.draft, false); + this.draft = toggled.draft; + this.palette.setGoalPending(toggled.pending); + } + if (variant === 'goal-sent') { + this.draft = ''; + this.palette.setGoalPending(false); + } + // Both sheets open before the first frame, the way the settings preview + // opens its own: the scenario id is only known once the page is appearing. + if (variant === 'sheet' || CommandPalettePreview.isSkillsSheetVariant(variant)) { + this.openSheet(true); + } + if (variant === 'usage') { + this.openSheet(false); + } + // The catalog read is also the probe, so this is the same first move a + // session entry makes: one read decides whether the picker may list anything. + void this.controller.loadCatalog('preview-session', 'agentic', false, this.canApply()); + if (variant === 'busy' || variant === 'failed') { + void this.controller.runCommand('preview-session', CommandPalettePolicy.compactWireAction, + this.scope, this.canApply()); + } + if (variant === 'usage') { + void this.controller.loadUsageReport('preview-session', this.scope, this.canApply()); + } + } + + /** + * The catalog read each variant stands for. + * + * The three failure kinds are the three the app tells apart, so a screenshot + * can be read against the words a person would actually get: the host naming + * the version gap, the host refusing a command for a reason of its own, and + * the bridge answering without ever reaching a command handler. + */ + private static catalogOutcomeFor(variant: string): string { + if (variant === 'degraded' || variant === 'gap') { + return 'version_gap'; + } + if (variant === 'refused') { + return 'refused'; + } + if (variant === 'unreachable') { + return 'unreachable'; + } + return variant === 'loading' ? 'pending' : ''; + } + + /** + * The variants that are showing the picker rather than the row alone. + * + * The waiting variant keeps its sheet shut on purpose: what it is there to + * show is the row's own waiting chip, which a sheet would cover. + */ + private static isSkillsSheetVariant(variant: string): boolean { + return variant === 'degraded' || variant === 'gap' || + variant === 'refused' || variant === 'unreachable'; + } + + build() { + this.Body() + } + + /** + * The fixture's one screen, in a builder rather than in `build()`. + * + * A sheet binds to the node it is attached to, and the root of `build()` is + * the node a parent can re-address; the app's own sheet hosts attach theirs + * inside a builder for the same reason. + */ + @Builder + private Body() { + Column({ space: 8 }) { + Text(`command-palette · ${this.variant()} · r${this.revision}`) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED) + .id('command-palette-state') + Text(this.stateLine()) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(INK) + .maxLines(3) + .id('command-palette-status') + Row({ space: 6 }) { + Button('Probe') + .id('palette-probe') + .onClick(() => { + void this.controller.loadCatalog('preview-session', 'agentic', true, this.canApply()); + }) + Button('Sheet') + .id('palette-sheet') + .onClick(() => { + this.dispatch(new CommandPaletteRequest(CommandPaletteAction.OpenSkills)); + }) + Button('Usage') + .id('palette-usage') + .onClick(() => { + this.dispatch(new CommandPaletteRequest(CommandPaletteAction.OpenUsage)); + }) + Button('Token') + .id('palette-token') + .onClick(() => { + this.dispatch(new CommandPaletteRequest(CommandPaletteAction.SelectSkill, 'plan', '', 'Plan')); + }) + Button('Release') + .id('palette-release') + .onClick(() => { + this.client.holdCompact = false; + this.client.releaseHeld(); + }) + } + Blank() + CommandChipRow({ + palette: this.presentation(), + presentation: this.wide ? ComposerPresentation.Floating : ComposerPresentation.Compact, + onRequest: (request: CommandPaletteRequest) => { + this.dispatch(request); + } + }) + ComposerBar({ + presentation: this.wide ? ComposerPresentation.Floating : ComposerPresentation.Compact, + chatInput: this.draft, + skillId: this.palette.skillId, + skillLabel: this.palette.skillLabel, + commandRowActive: true, + onChatInputChange: (value: string) => { + this.draft = value; + }, + onRemoveSkillToken: () => { + this.dispatch(new CommandPaletteRequest(CommandPaletteAction.ClearSkill)); + } + }) + } + .width('100%') + .height('100%') + .padding({ left: 12, right: 12, top: 12 }) + .backgroundColor(PAGE_BG) + // One node carries one sheet binding, so the picker and the report take + // turns on it exactly as they do in the conversation pane. + .bindSheet($$this.sheetPresented, this.ActiveSheet(), this.activeSheetOptions()) + .onAreaChange((_old: Area, area: Area) => { + this.wide = Number(area.width) >= 600; + }) + } + + @Builder + private ActiveSheet() { + if (this.showSkillPicker) { + SkillPickerSheet({ + palette: this.presentation(), + onSelect: (skillId: string, skillLabel: string) => { + this.closeSheet(); + this.dispatch(new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId, '', skillLabel)); + }, + onRefresh: () => { + void this.controller.loadCatalog('preview-session', 'agentic', true, this.canApply()); + }, + onClose: () => { + this.closeSheet(); + } + }) + } else { + UsageReportSheet({ + palette: this.presentation(), + onRetry: () => { + void this.controller.loadUsageReport('preview-session', this.scope, this.canApply()); + }, + onClose: () => { + this.closeSheet(); + } + }) + } + } + + private activeSheetOptions(): SheetOptions { + return AdaptiveSheetOptions.fromPlacement(SettingsPlacementPolicy.compactBottom( + this.showSkillPicker ? SettingsSheetKind.SkillPicker : SettingsSheetKind.UsageReport), true); + } + + private openSheet(skills: boolean): void { + this.showSkillPicker = skills; + this.showUsageReport = !skills; + this.sheetPresented = true; + } + + private closeSheet(): void { + this.sheetPresented = false; + } + + /** + * The row's one funnel, mirroring the conversation's dispatch. + * + * Opening or closing a sheet is this page's own view state; everything else + * goes through the same controller the conversation uses, so the probe, the + * single-flight guard and the notices behave here exactly as they do in the + * chat page. + */ + private dispatch(request: CommandPaletteRequest): void { + if (request.action === CommandPaletteAction.ToggleGoal) { + const toggled = CommandPalettePolicy.toggleGoal(this.draft, this.palette.goalPending); + this.draft = toggled.draft; + this.palette.setGoalPending(toggled.pending); + this.stateChanged(); + return; + } + if (request.action === CommandPaletteAction.SelectSkill) { + this.palette.setSkill(request.skillId, request.skillLabel); + this.stateChanged(); + return; + } + if (request.action === CommandPaletteAction.ClearSkill) { + this.palette.clearSkill(); + this.stateChanged(); + return; + } + if (request.action === CommandPaletteAction.OpenUsage || + request.action === CommandPaletteAction.RetryUsage) { + this.openSheet(false); + void this.controller.loadUsageReport('preview-session', this.scope, this.canApply()); + return; + } + if (request.action === CommandPaletteAction.OpenSkills || + request.action === CommandPaletteAction.RefreshCatalog) { + this.openSheet(true); + void this.controller.loadCatalog('preview-session', 'agentic', + request.action === CommandPaletteAction.RefreshCatalog, this.canApply()); + return; + } + const wireAction = request.action === CommandPaletteAction.RetryCommand ? + request.wireAction : request.action; + if (CommandPalettePolicy.isRunnable(wireAction)) { + void this.controller.runCommand('preview-session', wireAction, this.scope, this.canApply()); + } + } + + private get scope(): RemoteCommandPaletteScope { + const value: RemoteCommandPaletteScope = { sessionId: 'preview-session' }; + value.workspacePath = '/Users/user/projects/openbitfun'; + return value; + } + + /** Every read here is pinned to the one session this fixture stands for. */ + private canApply(): (sessionId: string) => boolean { + return (sessionId: string): boolean => sessionId === 'preview-session'; + } + + private presentation(): CommandPalettePresentation { + return CommandPalettePresentation.project(this.palette, true, false); + } + + private stateChanged(): void { + this.revision += 1; + } + + /** What the fixture is showing: the read's verdict and the row's own line. */ + private stateLine(): string { + const probe = commandPaletteProbeReady(this.palette.catalogStatus) ? 'probe ok' : 'probe refused'; + const failure = this.palette.catalogFailure.length > 0 ? + ` · ${this.palette.catalogFailure}` : ''; + const goal = this.palette.goalPending ? ' · goal pending' : ''; + const notice = this.palette.notice; + const running = this.palette.runningCommand.length > 0 ? + ` · running ${this.palette.runningCommand}` : ''; + return notice.visible ? + `${probe}${failure}${goal}${running} · ${RemoteI18n.t(notice.labelKey)}` : + `${probe}${failure}${goal}${running}`; + } + + private variant(): string { + const id = AppStorage.get('scenarioId') ?? 'command-palette'; + return id.replace('command-palette', '').replace('-dark', '').replace(/^-/, ''); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets index 8da4cd02c6..0303c84793 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -9,6 +9,7 @@ import { ModelRoleSelectorPreview } from './ModelRoleSelectorPreview'; import { SubagentProcessPreview } from './SubagentProcessPreview'; import { FileVisibilityPreview } from './FileVisibilityPreview'; import { ComposerOverflowPreview } from './ComposerOverflowPreview'; +import { CommandPalettePreview } from './CommandPalettePreview'; import { ImmersiveBottomPreview } from './ImmersiveBottomPreview'; import { WelcomeHome } from '../components/WelcomeHome'; import { InteractionMailboxPreview } from './InteractionMailboxPreview'; @@ -63,6 +64,8 @@ struct MobileDesignGallery { FileVisibilityPreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('composer-overflow')) { ComposerOverflowPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('command-palette')) { + CommandPalettePreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('immersive-bottom')) { ImmersiveBottomPreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('device-selector')) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 6d13f2fd08..d068438ada 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -8,10 +8,12 @@ import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionAction import { accountDeviceLink } from '../../services/AccountDeviceLink'; import { ChatMessage, + RemoteCommandCatalog, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, + RemoteSessionUsageReport, RecentWorkspaceEntry, SelectedImageAttachment, SessionSummary, @@ -41,6 +43,7 @@ import { LocaleController } from '../viewmodel/LocaleController'; import { ConversationController } from '../viewmodel/ConversationController'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemoteCommandPaletteController } from '../../services/RemoteCommandPaletteController'; import { RemoteSessionController } from '../../services/RemoteSessionController'; import { RemoteSessionListCache } from '../../services/RemoteSessionListCache'; import { RemoteSessionListRdbStore } from '../../services/RemoteSessionListRdbStore'; @@ -53,7 +56,7 @@ import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; import { RemoteUiState } from '../../services/RemoteUiState'; import { VoiceInputLifecycleController } from '../../services/VoiceInputLifecycleController'; import { VoiceInputService } from '../../services/VoiceInputService'; -import { ConversationIntent, MessageCopyScope } from '../actions/ConversationIntent'; +import { ConversationIntent, CommandPaletteRequest, MessageCopyScope } from '../actions/ConversationIntent'; import { AppRootHostPort } from '../host/AppRootHostAdapter'; import { AppRootPresentationActions } from '../actions/AppRootPresentationActions'; import { @@ -407,7 +410,7 @@ export abstract class AppRootRuntimeComposition { this.remoteSessionViewModel.settleTranscriptLoading(); }, onComposerPrepared: (sessionId: string, text: string, images: SelectedImageAttachment[]) => { - return this.remotePageState.conversation.prepareComposerSubmission(sessionId, text, images); + return this.remotePageState.prepareComposerSubmission(sessionId, text, images); }, onSendSucceeded: (turnId: string, pendingActiveId: string, localMessageId: string) => { this.chatTimelineStore.acknowledgeOptimisticTurn(localMessageId, turnId); @@ -584,6 +587,68 @@ export abstract class AppRootRuntimeComposition { this.remotePageState.setBusy(isBusy); } ); + /** + * The composer command palette's host traffic. + * + * Every callback is fenced to the session that asked for it: a catalog read or + * a usage report that lands after the user moved to another conversation would + * otherwise paint one session's data over another's. + */ + readonly remoteCommandPaletteController: RemoteCommandPaletteController = + new RemoteCommandPaletteController( + this.sessionManager, + { + onCatalogLoading: (sessionId: string): void => { + if (!this.isCommandPaletteSession(sessionId)) return; + this.remotePageState.commandPalette.setCatalogLoading(); + }, + onCatalogReady: (sessionId: string, catalog: RemoteCommandCatalog): void => { + if (!this.isCommandPaletteSession(sessionId)) return; + this.remotePageState.commandPalette.setCatalogReady(catalog); + }, + onCatalogFailed: (sessionId: string, errorText: string, failure: string): void => { + if (!this.isCommandPaletteSession(sessionId)) return; + this.remotePageState.commandPalette.setCatalogFailed(errorText, failure); + }, + onCommandStarted: (sessionId: string, wireAction: string): void => { + if (!this.isCommandPaletteSession(sessionId)) return; + this.remotePageState.commandPalette.beginCommand(wireAction); + }, + onCommandSettled: (sessionId: string, wireAction: string, errorText: string, message: string): void => { + if (!this.isCommandPaletteSession(sessionId)) return; + this.remotePageState.commandPalette.settleCommand(wireAction, errorText); + if (errorText.length > 0) { + // The host's own words, kept out of the notice's one line so a long + // refusal does not push the composer's hint out of shape. + this.remotePageState.setStatusText(errorText); + } else if (message.length > 0) { + RemoteLogger.info( + `command palette completed session=${sessionId} action=${wireAction}`); + } + }, + onCommandNoticeExpired: (sessionId: string, wireAction: string): void => { + if (!this.isCommandPaletteSession(sessionId)) return; + this.remotePageState.commandPalette.expireNotice(wireAction); + }, + onUsageLoading: (sessionId: string): void => { + if (!this.isCommandPaletteSession(sessionId)) return; + this.remotePageState.commandPalette.setUsageLoading(); + }, + onUsageReady: (sessionId: string, report: RemoteSessionUsageReport | undefined): void => { + if (!this.isCommandPaletteSession(sessionId)) return; + this.remotePageState.commandPalette.setUsageReady(report); + }, + onUsageFailed: (sessionId: string, errorText: string): void => { + if (!this.isCommandPaletteSession(sessionId)) return; + this.remotePageState.commandPalette.setUsageFailed(errorText); + } + } + ); + + private isCommandPaletteSession(sessionId: string): boolean { + return sessionId.length > 0 && this.remotePageState.activeSession.sessionId === sessionId; + } + readonly remoteSessionViewModel: RemoteSessionViewModel = new RemoteSessionViewModel( this.remotePageState, @@ -737,6 +802,7 @@ export abstract class AppRootRuntimeComposition { chatCache: this.remoteChatCache, polling: this.remoteChatPollingLifecycleController, models: this.remoteModelController, + commandPalette: this.remoteCommandPaletteController, files: this.remoteFileDownloadController, tools: this.remoteToolActionController, connection: this.remoteConnectionController, @@ -792,6 +858,9 @@ export abstract class AppRootRuntimeComposition { buildPlan: async (path: string, name: string): Promise => { await this.conversationController.buildRemotePlan(path, name); }, + commandPalette: async (request: CommandPaletteRequest): Promise => { + await this.conversationController.dispatchCommandPalette(request); + }, send: async (): Promise => { await this.conversationController.sendVisibleMessage(); }, voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, inputChanged: (route: AppRoute, value: string): void => diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/CommandPaletteState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/CommandPaletteState.ets new file mode 100644 index 0000000000..680676b196 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/CommandPaletteState.ets @@ -0,0 +1,317 @@ +import { RemoteCommandCatalog, RemoteSessionUsageReport } from '../../model/RemoteModels'; +import { + CommandPaletteAvailability, + CommandPaletteCatalogStatus, + CommandPaletteChip, + CommandPaletteChipKind, + CommandPaletteChipState, + CommandPaletteNoticeKind, + CommandPalettePolicy, + CommandPaletteProbeFailure +} from '../../services/CommandPalettePolicy'; +import { + UsageReportPresentationPolicy, + UsageReportSection +} from '../policy/UsageReportPresentationPolicy'; + +export enum CommandPaletteUsageStatus { + Idle = 'idle', + Loading = 'loading', + Ready = 'ready', + Failed = 'failed' +} + +/** + * What the chip row says under itself after a verb ran. + * + * One line at a time: a second verb replaces the first notice rather than + * stacking a second line above the composer. + */ +export class CommandPaletteNotice { + readonly kind: CommandPaletteNoticeKind; + readonly labelKey: string; + /** Host error text for a failure, empty otherwise. */ + readonly detail: string; + /** The verb a retry would repeat, empty when there is nothing to repeat. */ + readonly retryCommand: string; + + constructor(kind: CommandPaletteNoticeKind, labelKey: string, detail: string, retryCommand: string) { + this.kind = kind; + this.labelKey = labelKey; + this.detail = detail; + this.retryCommand = retryCommand; + } + + static none(): CommandPaletteNotice { + return new CommandPaletteNotice(CommandPaletteNoticeKind.Done, '', '', ''); + } + + get visible(): boolean { + return this.labelKey.length > 0; + } + + get retryable(): boolean { + return CommandPalettePolicy.canRetry(this.kind, this.retryCommand); + } +} + +/** + * Observable command-palette state for the remote conversation. + * + * Owned by `RemotePageState`, written by `RemoteCommandPaletteController` through + * the composition, and read only through `CommandPalettePresentation` so the + * view never has to know which half of this is host truth and which half is a + * local draft decision. + */ +@ObservedV2 +export class CommandPaletteState { + @Trace catalogStatus: string = CommandPaletteCatalogStatus.Idle; + @Trace catalog: RemoteCommandCatalog | undefined = undefined; + /** + * Why the skill read failed, when it did. + * + * Kept beside the host's text because the two answer different questions: the + * kind decides whether this app may tell the user their desktop is out of + * date, and the text is the host's own account of what went wrong. + */ + @Trace catalogFailure: string = CommandPaletteProbeFailure.None; + /** + * The host's own text for a failed read. + * + * A verb can fail for a reason the user can act on — a locked workspace, a + * session the desktop cannot find — and the host already said which. Restating + * that in this app's words would lose it. + */ + @Trace catalogErrorText: string = ''; + @Trace runningCommand: string = ''; + @Trace notice: CommandPaletteNotice = CommandPaletteNotice.none(); + @Trace usageStatus: string = CommandPaletteUsageStatus.Idle; + @Trace usageReport: RemoteSessionUsageReport | undefined = undefined; + @Trace usageErrorText: string = ''; + /** The key of the single selected skill, or '' when the composer carries none. */ + @Trace skillId: string = ''; + /** The selected skill's name, which is the text its token carries. */ + @Trace skillLabel: string = ''; + /** The goal chip is lit and its `/goal ` prefill is in the composer. */ + @Trace goalPending: boolean = false; + + /** + * The row's inputs, read from this state rather than passed in. + * + * The skill read's verdict belongs to whoever holds the answer, so the + * projection no longer takes a pre-computed `probeReady` that could disagree + * with the status sitting right beside it. + */ + availability(hasSession: boolean, isBusy: boolean): CommandPaletteAvailability { + return new CommandPaletteAvailability( + this.catalogStatus, this.catalogFailure, hasSession, isBusy, + this.runningCommand, this.goalPending); + } + + setCatalogLoading(): void { + this.catalogStatus = CommandPaletteCatalogStatus.Loading; + this.catalogFailure = CommandPaletteProbeFailure.None; + this.catalogErrorText = ''; + } + + setCatalogReady(catalog: RemoteCommandCatalog): void { + this.catalog = catalog; + this.catalogStatus = CommandPaletteCatalogStatus.Ready; + this.catalogFailure = CommandPaletteProbeFailure.None; + this.catalogErrorText = ''; + } + + setCatalogFailed(errorText: string, failure: string): void { + this.catalog = undefined; + this.catalogErrorText = errorText; + this.catalogFailure = failure; + this.catalogStatus = CommandPaletteCatalogStatus.Failed; + } + + beginCommand(wireAction: string): void { + this.runningCommand = wireAction; + this.notice = CommandPaletteNotice.none(); + } + + settleCommand(wireAction: string, errorText: string): void { + this.runningCommand = ''; + if (errorText.length === 0) { + this.notice = new CommandPaletteNotice( + CommandPaletteNoticeKind.Done, + CommandPalettePolicy.doneNoticeKey(wireAction), + '', + ''); + return; + } + this.notice = new CommandPaletteNotice( + CommandPaletteNoticeKind.Failed, + CommandPalettePolicy.failedNoticeKey(wireAction), + errorText, + wireAction); + } + + /** The completion line's dwell time ran out. */ + expireNotice(wireAction: string): void { + if (this.notice.visible && this.notice.retryCommand.length === 0 && + CommandPalettePolicy.doneNoticeKey(wireAction) === this.notice.labelKey) { + this.notice = CommandPaletteNotice.none(); + } + } + + clearNotice(): void { + this.notice = CommandPaletteNotice.none(); + } + + setUsageLoading(): void { + this.usageStatus = CommandPaletteUsageStatus.Loading; + this.usageErrorText = ''; + } + + setUsageReady(report: RemoteSessionUsageReport | undefined): void { + this.usageReport = report; + this.usageStatus = CommandPaletteUsageStatus.Ready; + } + + setUsageFailed(errorText: string): void { + this.usageReport = undefined; + this.usageErrorText = errorText; + this.usageStatus = CommandPaletteUsageStatus.Failed; + } + + /** Picks a skill. The key decides whether it is a new pick; the label is its token. */ + setSkill(skillId: string, skillLabel: string): void { + const next = CommandPalettePolicy.applySkillSelection(this.skillId, skillId); + if (next === this.skillId && next.length > 0) { + // Re-picking the row that is already in the composer: the token stays the + // one already drawn rather than being rewritten under the user. + return; + } + this.skillId = next; + this.skillLabel = next.length > 0 ? skillLabel.trim() : ''; + } + + clearSkill(): void { + this.skillId = ''; + this.skillLabel = ''; + } + + setGoalPending(pending: boolean): void { + this.goalPending = pending; + } + + /** Everything the previous conversation owned, dropped with it. */ + reset(): void { + this.catalogStatus = CommandPaletteCatalogStatus.Idle; + this.catalog = undefined; + this.catalogFailure = CommandPaletteProbeFailure.None; + this.catalogErrorText = ''; + this.runningCommand = ''; + this.notice = CommandPaletteNotice.none(); + this.usageStatus = CommandPaletteUsageStatus.Idle; + this.usageReport = undefined; + this.usageErrorText = ''; + this.skillId = ''; + this.skillLabel = ''; + this.goalPending = false; + } +} + +/** + * The command palette as the conversation view reads it. + * + * Immutable, computed once per projection: the chips a tap may act on, the + * explanation the row owes the user, the notice line, and the two sheets' data. + */ +export class CommandPalettePresentation { + readonly visible: boolean; + readonly chips: CommandPaletteChip[]; + /** Persistent explanation under the row: a session-less surface. */ + readonly rowReasonKey: string; + readonly notice: CommandPaletteNotice; + readonly catalogStatus: string; + readonly catalog?: RemoteCommandCatalog; + /** Why the read failed, which is what the picker's copy follows. */ + readonly catalogFailure: string; + /** The host's own text for a failed read, empty otherwise. */ + readonly catalogErrorText: string; + readonly usageStatus: string; + readonly usageSections: UsageReportSection[]; + readonly usagePartial: boolean; + readonly usageErrorText: string; + /** The selected skill's key, which is what its row's check mark compares. */ + readonly skillId: string; + /** The selected skill's name, which is the text its token carries. */ + readonly skillLabel: string; + + constructor( + visible: boolean, + chips: CommandPaletteChip[], + rowReasonKey: string, + notice: CommandPaletteNotice, + catalogStatus: string, + catalog: RemoteCommandCatalog | undefined, + catalogFailure: string, + catalogErrorText: string, + usageStatus: string, + usageSections: UsageReportSection[], + usagePartial: boolean, + usageErrorText: string, + skillId: string, + skillLabel: string + ) { + this.visible = visible; + this.chips = chips; + this.rowReasonKey = rowReasonKey; + this.notice = notice; + this.catalogStatus = catalogStatus; + this.catalog = catalog; + this.catalogFailure = catalogFailure; + this.catalogErrorText = catalogErrorText; + this.usageStatus = usageStatus; + this.usageSections = usageSections; + this.usagePartial = usagePartial; + this.usageErrorText = usageErrorText; + this.skillId = skillId; + this.skillLabel = skillLabel; + } + + /** No row at all: the general surface, or a conversation with no session. */ + static hidden(): CommandPalettePresentation { + return new CommandPalettePresentation( + false, [], '', CommandPaletteNotice.none(), + CommandPaletteCatalogStatus.Idle, undefined, CommandPaletteProbeFailure.None, '', + CommandPaletteUsageStatus.Idle, [], false, '', '', ''); + } + + static project( + state: CommandPaletteState, + hasSession: boolean, + isBusy: boolean + ): CommandPalettePresentation { + if (!CommandPalettePolicy.visible(hasSession)) { + return CommandPalettePresentation.hidden(); + } + const availability = state.availability(hasSession, isBusy); + const usage = UsageReportPresentationPolicy.present(state.usageReport); + return new CommandPalettePresentation( + true, + CommandPalettePolicy.chips(availability), + CommandPalettePolicy.rowReasonKey(availability), + state.notice, + state.catalogStatus, + state.catalog, + state.catalogFailure, + state.catalogErrorText, + state.usageStatus, + usage.sections, + usage.partial, + state.usageErrorText, + state.skillId, + state.skillLabel); + } + + chipState(kind: CommandPaletteChipKind): CommandPaletteChipState { + const match = this.chips.find((chip: CommandPaletteChip): boolean => chip.kind === kind); + return match ? match.state : CommandPaletteChipState.Dimmed; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets index 7452089d09..f3bbf57c10 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets @@ -22,6 +22,7 @@ import { AppRoute } from '../navigation/AppRouteContract'; import { ConversationCoreState } from './ConversationCoreState'; import { GeneralChatPageState } from './GeneralChatPageState'; import { RemotePageState } from './RemotePageState'; +import { CommandPalettePresentation } from './CommandPaletteState'; /** Immutable presentation state consumed by ConversationView. */ export class ConversationViewState { @@ -53,6 +54,11 @@ export class ConversationViewState { selectedImages: ConversationUiSelectedImage[] = []; isVoiceListening: boolean = false; chatInput: string = ''; + /** + * The composer's command row. Hidden on the general surface and whenever the + * conversation has no session, so the row can never act on a stale target. + */ + commandPalette: CommandPalettePresentation = CommandPalettePresentation.hidden(); static project( route: AppRoute, @@ -86,6 +92,10 @@ export class ConversationViewState { state.downloadedFilePath = remote.downloadedFilePath; state.fileDownloadStatus = remote.fileDownloadStatus; state.interactionMailbox = remote.interactionMailbox; + state.commandPalette = CommandPalettePresentation.project( + remote.commandPalette, + state.activeSession.sessionId.length > 0, + state.isBusy); return state; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index b66b7cda5c..22d07c2f57 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets @@ -13,8 +13,10 @@ import { SessionSummary } from '../../model/RemoteModels'; import { ChatTimelineItem, ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; +import { ComposerSubmission } from '../../model/ComposerSubmission'; import { RemoteUiState } from '../../services/RemoteUiState'; import { ConversationCoreState } from './ConversationCoreState'; +import { CommandPaletteState } from './CommandPaletteState'; /** * Observable projection for the Remote home page. @@ -24,6 +26,11 @@ import { ConversationCoreState } from './ConversationCoreState'; export class RemotePageState { @Trace interactionMailbox: InteractionMailboxState = new InteractionMailboxState(); readonly workspaceTools: WorkspaceToolsState = new WorkspaceToolsState(); + /** + * The composer command palette. Session-scoped, so it is dropped with the + * conversation it belongs to rather than outliving it. + */ + readonly commandPalette: CommandPaletteState = new CommandPaletteState(); @Trace conversation: ConversationCoreState = new ConversationCoreState('code'); @Trace desktopName: string = ''; @Trace desktopId: string = ''; @@ -254,12 +261,19 @@ export class RemotePageState { } setActiveSession(session: SessionSummary): void { - if (this.conversation.activeSession.sessionId !== session.sessionId) this.interactionMailbox = new InteractionMailboxState(); + if (this.conversation.activeSession.sessionId !== session.sessionId) { + this.interactionMailbox = new InteractionMailboxState(); + // A skill catalog, a usage report and a lit goal chip all belong to the + // session that produced them. Carrying them into another conversation + // would show one session's numbers over another's messages. + this.commandPalette.reset(); + } this.conversation.setActiveSession(session); } clearActiveSession(): void { this.interactionMailbox = new InteractionMailboxState(); + this.commandPalette.reset(); this.conversation.clearActiveSession(); this.isLoadingConversation = false; this.isLoadingTranscript = false; @@ -337,6 +351,47 @@ export class RemotePageState { this.conversation.clearComposer(); } + /** + * Draft ownership for a send, including the palette's two local picks. + * + * The skill token and the lit goal chip are the composer's local state rather + * than text in the field, so each travels with the draft's own ownership rules: + * consumed by the commit the host accepted, and put back by the rollback that + * it did not. A goal chip left lit over a sent `/goal` would say the objective + * is still missing after it just went out. + */ + prepareComposerSubmission( + sessionId: string, + text: string, + images: SelectedImageAttachment[] + ): ComposerSubmission { + const inner = this.conversation.prepareComposerSubmission(sessionId, text, images); + const skillId = this.commandPalette.skillId; + const skillLabel = this.commandPalette.skillLabel; + const goalPending = this.commandPalette.goalPending; + return { + commit: (): void => { + const hadSkill = this.commandPalette.skillId.length > 0; + inner.commit(); + if (hadSkill) { + this.commandPalette.clearSkill(); + } + if (goalPending) { + this.commandPalette.setGoalPending(false); + } + }, + rollback: (): void => { + inner.rollback(); + if (skillId.length > 0 && this.commandPalette.skillId.length === 0) { + this.commandPalette.setSkill(skillId, skillLabel); + } + if (goalPending && !this.commandPalette.goalPending) { + this.commandPalette.setGoalPending(true); + } + } + }; + } + setVoiceListening(isVoiceListening: boolean): void { this.conversation.setVoiceListening(isVoiceListening); } @@ -382,6 +437,7 @@ export class RemotePageState { /** Clears every conversation-scoped value when its remote identity ends. */ resetConversationContext(): void { this.interactionMailbox = new InteractionMailboxState(); + this.commandPalette.reset(); this.conversation.reset(); this.hasMoreSessions = false; this.isLoadingSessions = false; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets index fdec3a1adf..b9486d0a4b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -8,7 +8,7 @@ import { ChatTimelineItem } from '../../model/ChatTimelineModels'; import { VoiceInputRouteSnapshot } from '../../services/VoiceInputLifecycleController'; import { AppRootRouteState } from '../navigation/AppRootRouteState'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; -import { MessageCopyScope } from '../actions/ConversationIntent'; +import { MessageCopyScope, CommandPaletteRequest } from '../actions/ConversationIntent'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; @@ -203,6 +203,9 @@ export class ConversationController { async retryVisibleMessage(text: string): Promise { await this.visible.retryVisibleMessage(text); } downloadVisibleFile(path: string): void { this.visible.downloadVisibleFile(path); } async selectVisibleModel(modelId: string): Promise { await this.visible.selectVisibleModel(modelId); } + async dispatchCommandPalette(request: CommandPaletteRequest): Promise { + await this.visible.dispatchCommandPalette(request); + } onVisibleChatInputChange(route: AppRoute, value: string): void { this.visible.onVisibleChatInputChange(route, value); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets index fbbb5bb7cd..3aa96977cf 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets @@ -5,6 +5,7 @@ import { RemoteChatCommandController } from '../../services/RemoteChatCommandCon import { RemoteChatPollingLifecycleController } from '../../services/RemoteChatPollingLifecycleController'; import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemoteCommandPaletteController } from '../../services/RemoteCommandPaletteController'; import { RemoteToolActionController } from '../../services/RemoteToolActionController'; import { RemoteSessionManager } from '../../services/RemoteSessionManager'; import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; @@ -35,6 +36,7 @@ export interface RemoteConversationDependencies { readonly chatCache: RemoteChatCache; readonly polling: RemoteChatPollingLifecycleController; readonly models: RemoteModelController; + readonly commandPalette: RemoteCommandPaletteController; readonly files: RemoteFileDownloadController; readonly tools: RemoteToolActionController; readonly connection: RemoteConnectionController; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets index df2a3d79df..12b84bb31f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets @@ -6,6 +6,8 @@ import { } from '../../model/RemoteModels'; import { ChatTimelineItem } from '../../model/ChatTimelineModels'; import { ChatTimelineState, ChatTimelineStore } from '../../services/ChatTimelineStore'; +import { CommandPalettePolicy } from '../../services/CommandPalettePolicy'; +import { RemoteCommandPaletteScope } from '../../services/RemoteCommandPaletteController'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { Encoding } from '../../services/Encoding'; import { MarkdownHtmlSerializer } from '../../services/MarkdownHtmlSerializer'; @@ -13,7 +15,7 @@ import { RemoteChatPollingCursor, RemoteChatPollingSnapshot } from '../../servic import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteUiState } from '../../services/RemoteUiState'; -import { MessageCopyScope } from '../actions/ConversationIntent'; +import { MessageCopyScope, CommandPaletteAction, CommandPaletteRequest } from '../actions/ConversationIntent'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteConversationDependencies, @@ -75,10 +77,114 @@ export class RemoteTranscriptController { async loadRemoteMessages(): Promise { const runtime = requireRemoteRuntime(this.remoteRuntime); + const sessionId = this.remote.activeSession.sessionId || ''; await runtime.chat.loadMessages( - this.remote.activeSession.sessionId || '', + sessionId, runtime.hooks.isConversationContext ); + // Read the skill catalog behind the transcript. This is also the palette's + // behavior probe — a host that does not serve the read is what greys the row + // — and it is cached per session, so opening a session pays for it once. + void runtime.commandPalette.loadCatalog( + sessionId, + this.commandPaletteModeId(), + false, + this.commandPaletteScope() + ); + } + + /** + * One tap in the composer's command row. + * + * Everything the palette can do lands here, because this controller owns what + * "the active conversation" means: the session id, the draft, the workspace + * that owns the session, and whether the probe has answered yet. + */ + async dispatchCommandPalette(request: CommandPaletteRequest): Promise { + const runtime = requireRemoteRuntime(this.remoteRuntime); + const sessionId = this.remote.activeSession.sessionId || ''; + const palette = this.remote.commandPalette; + if (request.action === CommandPaletteAction.ToggleGoal) { + // Goals are plain message text, so this works on any host that can send. + const toggled = CommandPalettePolicy.toggleGoal(this.remote.chatInput, palette.goalPending); + this.remote.setChatInput(toggled.draft); + palette.setGoalPending(toggled.pending); + return; + } + if (request.action === CommandPaletteAction.SelectSkill) { + palette.setSkill(request.skillId, request.skillLabel); + return; + } + if (request.action === CommandPaletteAction.ClearSkill) { + palette.clearSkill(); + return; + } + const canApply = this.commandPaletteScope(); + if (sessionId.length === 0 || !canApply(sessionId)) { + RemoteLogger.info( + `command palette ignored action=${request.action} session=${shortSessionId(sessionId)}`); + return; + } + const scope = this.commandPaletteCommandScope(sessionId); + if (request.action === CommandPaletteAction.Compact || + request.action === CommandPaletteAction.InitAgentsMd || + request.action === CommandPaletteAction.RetryCommand) { + const wireAction = request.action === CommandPaletteAction.RetryCommand ? request.wireAction : request.action; + if (!CommandPalettePolicy.isRunnable(wireAction)) { + return; + } + await runtime.commandPalette.runCommand(sessionId, wireAction, scope, canApply); + return; + } + if (request.action === CommandPaletteAction.OpenUsage || + request.action === CommandPaletteAction.RetryUsage) { + await runtime.commandPalette.loadUsageReport(sessionId, scope, canApply); + return; + } + if (request.action === CommandPaletteAction.OpenSkills || + request.action === CommandPaletteAction.RefreshCatalog) { + await runtime.commandPalette.loadCatalog( + sessionId, + this.commandPaletteModeId(), + request.action === CommandPaletteAction.RefreshCatalog, + canApply); + } + } + + /** + * The host's mode id for the palette's catalog read. + * + * It is the session's own agent type: the picker must show the skills that + * mode would actually run, and a session that has not named one yet falls back + * to the desktop's default harness rather than an empty mode. + */ + private commandPaletteModeId(): string { + const agentType = (this.remote.activeSession.agentType || '').trim(); + return agentType.length > 0 ? agentType : 'agentic'; + } + + /** The workspace that owns the active session, as the verbs expect it. */ + private commandPaletteCommandScope(sessionId: string): RemoteCommandPaletteScope { + const scope: RemoteCommandPaletteScope = { sessionId }; + if (this.remote.workspaceId.length > 0) { + scope.workspaceId = this.remote.workspaceId; + } + if (this.remote.workspacePath.length > 0) { + scope.workspacePath = this.remote.workspacePath; + } + if (this.remote.workspaceConnectionId.length > 0) { + scope.remoteConnectionId = this.remote.workspaceConnectionId; + } + if (this.remote.workspaceSshHost.length > 0) { + scope.remoteSshHost = this.remote.workspaceSshHost; + } + return scope; + } + + /** Reads stay pinned to the session they were asked for. */ + private commandPaletteScope(): (sessionId: string) => boolean { + return (sessionId: string): boolean => + sessionId.length > 0 && this.remote.activeSession.sessionId === sessionId; } async selectRemoteModel(modelId: string): Promise { @@ -108,7 +214,17 @@ export class RemoteTranscriptController { } const rawText = this.remote.chatInput.trim(); const images = this.remote.selectedImages.slice(); - const text = rawText.length > 0 ? rawText : + // A bare `/goal` is a command with its argument still missing, so it is not + // a message yet: the composer already drew it as SendBlocked, and anything + // that reaches the send path another way stops here too. + if (CommandPalettePolicy.blocksSend(rawText)) { + return; + } + // The skill token is a chip beside the field, so this is where it becomes + // message content — once, and only for the draft being sent. It is built from + // the picked skill's name, which is the form the desktop's own picker writes. + const skillLabel = this.remote.commandPalette.skillLabel; + const text = rawText.length > 0 ? CommandPalettePolicy.composeSubmissionText(rawText, skillLabel) : (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); const sessionId = this.remote.activeSession.sessionId || ''; if ((!text && images.length === 0) || !sessionId || this.remote.isBusy || this.remote.connectionState !== 'connected' || diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/VisibleConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/VisibleConversationController.ets index 92c345813e..9464fecd4c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/VisibleConversationController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/VisibleConversationController.ets @@ -1,6 +1,7 @@ import { RemoteSession } from '../../model/RemoteModels'; import { AppRoute } from '../navigation/AppRouteContract'; import { RemotePageState } from '../state/RemotePageState'; +import { CommandPaletteRequest } from '../actions/ConversationIntent'; import { RemoteConversationDependencies, requireRemoteRuntime } from './ConversationRuntime'; import { RemoteCreateFlowController } from './RemoteCreateFlowController'; import { RemoteTranscriptController } from './RemoteTranscriptController'; @@ -51,6 +52,10 @@ export class VisibleConversationController { downloadVisibleFile(path: string): void { void this.transcript.downloadRemoteFile(path); } async selectVisibleModel(modelId: string): Promise { await this.transcript.selectRemoteModel(modelId); } + async dispatchCommandPalette(request: CommandPaletteRequest): Promise { + await this.transcript.dispatchCommandPalette(request); + } + closeActiveChat(): void { const runtime = requireRemoteRuntime(this.remoteRuntime); runtime.filePreview.close(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandCatalogProjection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandCatalogProjection.ets new file mode 100644 index 0000000000..b9ea144e39 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandCatalogProjection.ets @@ -0,0 +1,394 @@ +import { + RemoteCommandCatalog, + RemoteSkillCatalogEntry, + REMOTE_SKILL_SOURCE_BUILTIN, + REMOTE_SKILL_SOURCE_PROJECT, + REMOTE_SKILL_SOURCE_USER +} from '../model/RemoteModels'; +import { CommandPalettePolicy } from './CommandPalettePolicy'; + +/** + * Turns the host's skill catalog into the rows the picker sheet renders. + * + * Two shapes meet here. The host sends `get_mode_skill_configs` rows — each a + * `SkillInfo` flattened beside its mode availability flags — and the palette + * wants one entry per invocable skill, with the host's own group taxonomy kept + * for ordering and its availability flags kept as filtering truth. + * + * Pure and host-tolerant: a row is dropped only when it has no id, and the two + * sides of the sheet — the built-in catalog and the skills the user authored — + * never merge, so a user skill that reuses a built-in name stays its own row in + * the custom group instead of shadowing the built-in one. + */ + +export const COMMAND_CATALOG_FILTER_ALL: string = 'all'; +export const COMMAND_CATALOG_FILTER_BUILTIN: string = 'builtin'; +export const COMMAND_CATALOG_FILTER_CUSTOM: string = 'custom'; + +/** + * One raw host row. + * + * Everything but the id is optional so a partially instrumented or older host + * still yields entries instead of failing the whole read. Absent flags read as + * `false` — with `allow_user_invocation` the sole exception, whose contract + * default is `true` — so a host that stops serializing the availability flags + * hides nothing it meant to offer, and offers nothing it meant to hold back. + */ +export interface RemoteModeSkillRow { + key?: string; + name?: string; + description?: string; + groupKey?: string; + group_key?: string; + isBuiltin?: boolean; + is_builtin?: boolean; + level?: string; + isShadowed?: boolean; + is_shadowed?: boolean; + allowUserInvocation?: boolean; + allow_user_invocation?: boolean; + selectedForRuntime?: boolean; + selected_for_runtime?: boolean; + effectiveEnabled?: boolean; + effective_enabled?: boolean; + globallyEnabled?: boolean; + globally_enabled?: boolean; +} + +export class SkillCatalogRow { + readonly id: string; + readonly name: string; + readonly description: string; + readonly group: string; + readonly source: string; + readonly isBuiltin: boolean; + /** + * The exact token the message will carry, e.g. `[$Plan]`. + * + * Built from the skill's name rather than its key, because that is the token + * the desktop's own picker inserts; the key stays the row's identity, which is + * what the picker keys on and what the composer compares against. + */ + readonly insertHint: string; + + constructor(entry: RemoteSkillCatalogEntry, isBuiltin: boolean) { + this.id = entry.id; + this.name = entry.name; + this.description = entry.description; + this.group = entry.group; + this.source = entry.source; + this.isBuiltin = isBuiltin; + this.insertHint = CommandPalettePolicy.skillTokenLabel(entry.name); + } +} + +export class SkillCatalogSection { + readonly id: string; + readonly titleKey: string; + readonly count: number; + readonly rows: SkillCatalogRow[]; + + constructor(id: string, titleKey: string, rows: SkillCatalogRow[]) { + this.id = id; + this.titleKey = titleKey; + this.count = rows.length; + this.rows = rows; + } +} + +export class SkillCatalogFilter { + readonly id: string; + readonly labelKey: string; + readonly count: number; + + constructor(id: string, labelKey: string, count: number) { + this.id = id; + this.labelKey = labelKey; + this.count = count; + } +} + +export class SkillCatalogPresentation { + readonly sections: SkillCatalogSection[]; + readonly filters: SkillCatalogFilter[]; + readonly totalCount: number; + readonly builtinCount: number; + readonly customCount: number; + /** Nothing to show at all: the catalog is empty or the host never sent one. */ + readonly isEmpty: boolean; + readonly hasNoMatches: boolean; + + constructor( + sections: SkillCatalogSection[], + filters: SkillCatalogFilter[], + totalCount: number, + builtinCount: number, + customCount: number, + isEmpty: boolean, + hasNoMatches: boolean + ) { + this.sections = sections; + this.filters = filters; + this.totalCount = totalCount; + this.builtinCount = builtinCount; + this.customCount = customCount; + this.isEmpty = isEmpty; + this.hasNoMatches = hasNoMatches; + } +} + +export class CommandCatalogProjection { + static readonly builtinSectionKey: string = 'commands.skills.sectionBuiltin'; + static readonly customSectionKey: string = 'commands.skills.sectionCustom'; + + /** + * The host's raw rows, as palette entries. + * + * This is the whole of the host-shape translation: the id is the skill key — + * the same string the `[$key]` message token carries — and the availability + * flags are copied through so nothing downstream re-reads the payload. Rows + * without an id are dropped; rows repeating an id collapse onto one entry, + * because a row's key is what the picker lists and keys on and two rows with + * the same key would be the same choice offered twice. + */ + static parseHostCatalog(value?: RemoteModeSkillRow[]): RemoteCommandCatalog { + const parsed: RemoteSkillCatalogEntry[] = []; + if (!value || value.length === 0) { + return { skills: parsed }; + } + for (const row of value) { + const id = CommandCatalogProjection.trimmed(row.key); + if (id.length === 0) { + continue; + } + const builtin = CommandCatalogProjection.flag(row.isBuiltin, row.is_builtin); + parsed.push({ + id, + name: CommandCatalogProjection.rowName(row.name, id), + description: CommandCatalogProjection.trimmed(row.description), + // A user-authored skill has no host group of its own; the picker then + // groups it by source, which is the only taxonomy it actually has. + group: CommandCatalogProjection.trimmed(row.groupKey ?? row.group_key), + source: CommandCatalogProjection.source(builtin, row.level), + shadowed: CommandCatalogProjection.flag(row.isShadowed, row.is_shadowed), + selectedForRuntime: CommandCatalogProjection.flag(row.selectedForRuntime, row.selected_for_runtime), + effectiveEnabled: CommandCatalogProjection.flag(row.effectiveEnabled, row.effective_enabled), + globallyEnabled: CommandCatalogProjection.flag(row.globallyEnabled, row.globally_enabled), + allowUserInvocation: CommandCatalogProjection.defaultTrue(row.allowUserInvocation, row.allow_user_invocation) + }); + } + return { skills: CommandCatalogProjection.collapseById(parsed) }; + } + + /** + * One entry per skill key, in the host's own order. + * + * Which duplicate wins is the part worth being careful about: the host lists + * both the winner and the skill it shadows under the same key, so keeping the + * first row could keep the shadowed one and hide the very skill the user can + * actually invoke. A later row therefore replaces an earlier one when it is + * the invocable of the two, and the position stays the first row's. + */ + private static collapseById(entries: RemoteSkillCatalogEntry[]): RemoteSkillCatalogEntry[] { + const order: string[] = []; + const byId: Map = new Map(); + for (const entry of entries) { + const kept = byId.get(entry.id); + if (kept === undefined) { + order.push(entry.id); + byId.set(entry.id, entry); + continue; + } + if (!CommandCatalogProjection.invocable(kept) && CommandCatalogProjection.invocable(entry)) { + byId.set(entry.id, entry); + } + } + const collapsed: RemoteSkillCatalogEntry[] = []; + for (const id of order) { + const entry = byId.get(id); + if (entry !== undefined) { + collapsed.push(entry); + } + } + return collapsed; + } + + /** + * Whether the host offered this skill to the user in the active mode. + * + * Mirrors the desktop composer's own rule: the host's winner for the name, + * enabled for this mode, enabled globally, and allowed to be invoked by hand. + * A shadowed row is a duplicate of the winner and never a second choice. + */ + static invocable(entry: RemoteSkillCatalogEntry): boolean { + return !entry.shadowed && entry.selectedForRuntime && entry.effectiveEnabled && + entry.globallyEnabled && entry.allowUserInvocation; + } + + /** The invocable catalog as rows, in the host's own order. */ + static rows(catalog?: RemoteCommandCatalog): SkillCatalogRow[] { + const skills = catalog?.skills; + if (!skills || skills.length === 0) { + return []; + } + const rows: SkillCatalogRow[] = []; + for (const skill of skills) { + if (!CommandCatalogProjection.invocable(skill)) { + continue; + } + rows.push(new SkillCatalogRow(skill, CommandCatalogProjection.isBuiltin(skill.source))); + } + return rows; + } + + static builtinCount(rows: SkillCatalogRow[]): number { + return rows.filter((row: SkillCatalogRow): boolean => row.isBuiltin).length; + } + + static customCount(rows: SkillCatalogRow[]): number { + return rows.filter((row: SkillCatalogRow): boolean => !row.isBuiltin).length; + } + + static filters(rows: SkillCatalogRow[]): SkillCatalogFilter[] { + return [ + new SkillCatalogFilter(COMMAND_CATALOG_FILTER_ALL, 'commands.skills.filterAll', rows.length), + new SkillCatalogFilter( + COMMAND_CATALOG_FILTER_BUILTIN, 'commands.skills.filterBuiltin', + CommandCatalogProjection.builtinCount(rows)), + new SkillCatalogFilter( + COMMAND_CATALOG_FILTER_CUSTOM, 'commands.skills.filterCustom', + CommandCatalogProjection.customCount(rows)) + ]; + } + + /** + * The sheet's sections for one query and filter. + * + * Built-in skills are grouped and sorted by their group key, which is the + * catalog's own taxonomy; the custom section always comes last so a user skill + * can never be mistaken for a built-in one. + */ + static present(catalog: RemoteCommandCatalog | undefined, query: string, filterId: string): SkillCatalogPresentation { + const rows = CommandCatalogProjection.rows(catalog); + const filters = CommandCatalogProjection.filters(rows); + if (rows.length === 0) { + return new SkillCatalogPresentation([], filters, 0, 0, 0, true, false); + } + const normalizedQuery = query.trim().toLowerCase(); + const matched = rows.filter((row: SkillCatalogRow): boolean => + CommandCatalogProjection.matches(row, normalizedQuery)); + const sections: SkillCatalogSection[] = []; + if (filterId !== COMMAND_CATALOG_FILTER_CUSTOM) { + const builtin = CommandCatalogProjection.sortBuiltin( + matched.filter((row: SkillCatalogRow): boolean => row.isBuiltin)); + if (builtin.length > 0) { + sections.push(new SkillCatalogSection( + COMMAND_CATALOG_FILTER_BUILTIN, + CommandCatalogProjection.builtinSectionKey, + builtin)); + } + } + if (filterId !== COMMAND_CATALOG_FILTER_BUILTIN) { + const custom = CommandCatalogProjection.sortCustom( + matched.filter((row: SkillCatalogRow): boolean => !row.isBuiltin)); + if (custom.length > 0) { + sections.push(new SkillCatalogSection( + COMMAND_CATALOG_FILTER_CUSTOM, + CommandCatalogProjection.customSectionKey, + custom)); + } + } + return new SkillCatalogPresentation( + sections, + filters, + rows.length, + CommandCatalogProjection.builtinCount(rows), + CommandCatalogProjection.customCount(rows), + false, + sections.length === 0); + } + + static matches(row: SkillCatalogRow, normalizedQuery: string): boolean { + if (normalizedQuery.length === 0) { + return true; + } + return row.name.toLowerCase().indexOf(normalizedQuery) >= 0 || + row.id.toLowerCase().indexOf(normalizedQuery) >= 0 || + row.group.toLowerCase().indexOf(normalizedQuery) >= 0 || + row.description.toLowerCase().indexOf(normalizedQuery) >= 0; + } + + static isBuiltin(source?: string): boolean { + return CommandCatalogProjection.trimmed(source).toLowerCase() === REMOTE_SKILL_SOURCE_BUILTIN; + } + + /** + * Which side of the sheet a row belongs to. + * + * The host says whether the row is a built-in; anything else is user-authored, + * and `level` only decides whether it was written for this project or for the + * user's own directory. + */ + private static source(builtin: boolean, level?: string): string { + if (builtin) { + return REMOTE_SKILL_SOURCE_BUILTIN; + } + return CommandCatalogProjection.trimmed(level).toLowerCase() === REMOTE_SKILL_SOURCE_PROJECT ? + REMOTE_SKILL_SOURCE_PROJECT : REMOTE_SKILL_SOURCE_USER; + } + + /** A flag an older host may omit; absent reads as `false`. */ + private static flag(camel?: boolean, snake?: boolean): boolean { + const value = camel ?? snake; + return value === undefined ? false : value; + } + + /** A flag whose host default is `true`: absent must not read as a refusal. */ + private static defaultTrue(camel?: boolean, snake?: boolean): boolean { + const value = camel ?? snake; + return value === undefined ? true : value; + } + + private static sortBuiltin(rows: SkillCatalogRow[]): SkillCatalogRow[] { + return CommandCatalogProjection.sortRows(rows); + } + + /** The custom section sorts on the same keys; only its place in the sheet differs. */ + private static sortCustom(rows: SkillCatalogRow[]): SkillCatalogRow[] { + return CommandCatalogProjection.sortRows(rows); + } + + private static sortRows(rows: SkillCatalogRow[]): SkillCatalogRow[] { + return rows.slice().sort((left: SkillCatalogRow, right: SkillCatalogRow): number => { + const byGroup = CommandCatalogProjection.compare(left.group, right.group); + if (byGroup !== 0) { + return byGroup; + } + const byName = CommandCatalogProjection.compare(left.name, right.name); + return byName !== 0 ? byName : CommandCatalogProjection.compare(left.id, right.id); + }); + } + + private static compare(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; + } + + /** + * The row's display name, falling back to its key. + * + * The fallback is the same string the token would carry, so a host row that + * never named its skill still yields a pick that can be sent rather than an + * empty `[$]`. + */ + private static rowName(name: string | undefined, fallback: string): string { + const resolved = CommandCatalogProjection.trimmed(name); + return resolved.length > 0 ? resolved : fallback; + } + + private static trimmed(value?: string): string { + return value === undefined ? '' : value.trim(); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandPalettePolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandPalettePolicy.ets new file mode 100644 index 0000000000..dd61a92904 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandPalettePolicy.ets @@ -0,0 +1,565 @@ +/** + * The composer command palette's rules, kept pure so they can be exercised + * without ArkUI. + * + * Everything here answers one of three questions: which chip may be tapped + * right now, what a tapped chip does to the draft, and what the draft becomes + * when it is sent. The palette never guesses a session, never opens the picker + * before the host has answered the skill read, and never rewrites a draft the + * user owns. + */ + +/** Lifecycle of the catalog read that doubles as the palette's behavior probe. */ +export enum CommandPaletteCatalogStatus { + /** No read yet: the picker's chip waits rather than acts. */ + Idle = 'idle', + /** The read is in flight. */ + Loading = 'loading', + /** The host answered; the picker has rows to offer. */ + Ready = 'ready', + /** The read failed. The chip still opens the picker, which explains and re-checks. */ + Failed = 'failed' +} + +/** + * Why the skill read failed. + * + * The three cases are not interchangeable to the person holding the phone, and + * only one of them justifies telling them their desktop is out of date. A read + * that reached nothing is a connection to fix; a read the host refused without + * naming a version gap is the host saying no; only a host that reports the gap + * itself is an upgrade. + */ +export enum CommandPaletteProbeFailure { + None = '', + /** The host answered, and its own words name the version gap. */ + VersionGap = 'version_gap', + /** The host answered and refused, for a reason it stated itself. */ + Refused = 'refused', + /** Nothing answered: a transport, timeout or unusable-payload failure. */ + Unreachable = 'unreachable' +} + +/** Whether the probe's answer lets the picker offer rows. */ +export function commandPaletteProbeReady(status: string): boolean { + return status === CommandPaletteCatalogStatus.Ready; +} + +/** + * The host's own wording when it does not have a command in this version. + * + * `PeerRefusal::UnknownToHost` is the peer-host registry's sentence, and it is + * the only thing that names a version. The desktop's command registry answers + * `Command not found: ` for a name it does not carry, which is a refusal + * about the command rather than a statement about the build: it keeps the + * refusal copy and leaves the upgrade claim to the sentence that makes it. + */ +const VERSION_GAP_MARKERS: string[] = [ + 'unknown to this openbitfun', + 'peer host version' +]; + +/** + * The bridge's own wording when it never got to a command handler. + * + * `host_invoke` answers `ok: false` for two unrelated events: a command the host + * declined, and the bridge between the relay and the desktop failing to deliver + * it at all (`peer_host_invoke.rs`: the app handle is not up yet, the request + * could not be emitted, the channel closed, or the whole call timed out). The + * second kind is a connection that did not reach a command, so it must not be + * described as the desktop having answered — the refusal copy asserts an answer, + * and the version claim would be pure invention. + */ +const NON_ANSWER_MARKERS: string[] = [ + 'peer host invoke', + 'peer app handle not ready', + 'failed to emit', + 'channel closed', + 'lock poisoned' +]; + +/** The five chips of the composer command row, in their on-screen order. */ +export enum CommandPaletteChipKind { + Compact = 'compact', + Init = 'init', + Goal = 'goal', + Usage = 'usage', + Skills = 'skills' +} + +export enum CommandPaletteChipState { + Ready = 'ready', + /** The goal chip waiting for its argument; drawn filled. */ + Pending = 'pending', + /** The verb this chip starts is in flight; a spinner replaces its icon. */ + InFlight = 'inflight', + /** Greyed. Still tappable, so the row can explain itself. */ + Dimmed = 'dimmed' +} + +/** + * Why a chip is not ready, and therefore what the row says about it. + * + * A chip's own business is the only thing that holds it back. The skill read is + * the picker's business alone, so a failed read no longer greys the row: the + * verbs exist on every host this app can reach, and a host that refuses one says + * so when it is run. + */ +export enum CommandPaletteDegradeReason { + /** The skill read has not answered, or answered with a failure. */ + Probe = 'probe', + /** Another palette verb is still running. */ + Busy = 'busy', + /** No session is open, so there is nothing to act on. */ + Session = 'session', + None = 'none' +} + +export enum CommandPaletteNoticeKind { + Busy = 'busy', + Done = 'done', + Failed = 'failed' +} + +/** What the row is allowed to do, as a snapshot of the live surface. */ +export class CommandPaletteAvailability { + /** The skill read's lifecycle, which gates the picker's chip and nothing else. */ + readonly probeStatus: string; + /** Why the read failed, when it did; the badge and the copy follow from this. */ + readonly probeFailure: string; + readonly hasSession: boolean; + readonly isBusy: boolean; + /** Wire id of the palette verb currently in flight, or '' when idle. */ + readonly runningCommand: string; + /** The goal chip is prefilled and waiting for its argument. */ + readonly goalPending: boolean; + + constructor( + probeStatus: string, + probeFailure: string, + hasSession: boolean, + isBusy: boolean, + runningCommand: string = '', + goalPending: boolean = false + ) { + this.probeStatus = probeStatus; + this.probeFailure = probeFailure; + this.hasSession = hasSession; + this.isBusy = isBusy; + this.runningCommand = runningCommand; + this.goalPending = goalPending; + } + + get probeReady(): boolean { + return commandPaletteProbeReady(this.probeStatus); + } +} + +/** One rendered chip: its label, its state, and what a tap does. */ +export class CommandPaletteChip { + readonly kind: CommandPaletteChipKind; + readonly state: CommandPaletteChipState; + readonly labelKey: string; + /** Badge shown on the chip's corner, empty when the chip has none. */ + readonly badgeKey: string; + readonly showsChevron: boolean; + /** Empty unless the chip has something to explain. */ + readonly reasonKey: string; + /** False while a tap must not reach the host; the reason line still shows. */ + readonly runsAction: boolean; + + constructor( + kind: CommandPaletteChipKind, + state: CommandPaletteChipState, + labelKey: string, + badgeKey: string, + showsChevron: boolean, + reasonKey: string, + runsAction: boolean + ) { + this.kind = kind; + this.state = state; + this.labelKey = labelKey; + this.badgeKey = badgeKey; + this.showsChevron = showsChevron; + this.reasonKey = reasonKey; + this.runsAction = runsAction; + } +} + +/** The goal chip's two-way toggle result. */ +export class GoalPrefillToggle { + readonly draft: string; + readonly pending: boolean; + + constructor(draft: string, pending: boolean) { + this.draft = draft; + this.pending = pending; + } +} + +const GOAL_COMMAND: string = '/goal'; +const GOAL_PREFILL: string = '/goal '; + +export class CommandPalettePolicy { + static readonly compactLabelKey: string = 'commands.compact'; + static readonly initLabelKey: string = 'commands.init'; + static readonly goalLabelKey: string = 'commands.goal'; + static readonly usageLabelKey: string = 'commands.usage'; + static readonly skillsLabelKey: string = 'commands.skills'; + static readonly upgradeBadgeKey: string = 'commands.upgradeBadge'; + static readonly degradedReasonKey: string = 'commands.degradedReason'; + static readonly busyReasonKey: string = 'commands.busyReason'; + static readonly probeWaitReasonKey: string = 'commands.probeWaitReason'; + static readonly probeRefusedReasonKey: string = 'commands.probeRefusedReason'; + static readonly probeUnreachableReasonKey: string = 'commands.probeUnreachableReason'; + + /** + * The internal ids of the two verbs that run on tap. + * + * They are not wire values: each maps to a desktop command through the + * palette's `host_invoke` client, and they are what the row, the notices and + * the single-flight guard compare against. + */ + static readonly compactWireAction: string = 'compact'; + static readonly initWireAction: string = 'init_agents_md'; + + /** The verb a chip starts, or '' for the chips that only shape the draft. */ + static wireAction(kind: CommandPaletteChipKind): string { + if (kind === CommandPaletteChipKind.Compact) return CommandPalettePolicy.compactWireAction; + if (kind === CommandPaletteChipKind.Init) return CommandPalettePolicy.initWireAction; + return ''; + } + + /** Whether this verb is one the row may start. */ + static isRunnable(wireAction: string): boolean { + return wireAction === CommandPalettePolicy.compactWireAction || + wireAction === CommandPalettePolicy.initWireAction; + } + + /** + * The row, in order. A chip is degraded rather than hidden the moment the host + * cannot serve it: a missing answer has to be visible and explainable, not + * silently absent, and every chip stays tappable so the row can say why. + */ + static chips(availability: CommandPaletteAvailability): CommandPaletteChip[] { + return [ + CommandPalettePolicy.chip(availability, CommandPaletteChipKind.Compact), + CommandPalettePolicy.chip(availability, CommandPaletteChipKind.Init), + CommandPalettePolicy.chip(availability, CommandPaletteChipKind.Goal), + CommandPalettePolicy.chip(availability, CommandPaletteChipKind.Usage), + CommandPalettePolicy.chip(availability, CommandPaletteChipKind.Skills) + ]; + } + + static chip( + availability: CommandPaletteAvailability, + kind: CommandPaletteChipKind + ): CommandPaletteChip { + const labelKey = CommandPalettePolicy.labelKey(kind); + const chevron = kind === CommandPaletteChipKind.Skills; + if (!availability.hasSession) { + return new CommandPaletteChip( + kind, CommandPaletteChipState.Dimmed, labelKey, + '', chevron, CommandPalettePolicy.sessionReasonKey(), false); + } + // Goals are plain message text: the host parses `/goal ` on the + // send path it already has, so this chip is honest about working even when + // the palette's own verbs do not. + if (kind === CommandPaletteChipKind.Goal) { + return new CommandPaletteChip( + kind, + availability.goalPending ? CommandPaletteChipState.Pending : CommandPaletteChipState.Ready, + labelKey, '', false, '', true); + } + // The picker is the one surface whose reach depends on the host's answer, so + // it is the one chip the skill read gates. + if (kind === CommandPaletteChipKind.Skills) { + return CommandPalettePolicy.skillsChip(availability, labelKey); + } + // Compacting, initializing and the usage report exist on every host this app + // can reach, and each one answers for itself when it is run — refusing it is + // the host's message to give, not a verdict this row can reach in advance. + const wireAction = CommandPalettePolicy.wireAction(kind); + if (availability.runningCommand.length > 0 && availability.runningCommand === wireAction) { + return new CommandPaletteChip( + kind, CommandPaletteChipState.InFlight, labelKey, '', false, '', false); + } + if (availability.isBusy || availability.runningCommand.length > 0) { + return new CommandPaletteChip( + kind, CommandPaletteChipState.Dimmed, labelKey, + '', false, CommandPalettePolicy.busyReasonKey, false); + } + return new CommandPaletteChip( + kind, CommandPaletteChipState.Ready, labelKey, '', false, '', true); + } + + /** + * The 技能 chip, which is where the skill read's answer shows. + * + * A read still in flight is neither a refusal nor a promise, so the chip waits + * and says so — no badge, and no sentence about the desktop's version. Only a + * host that reported the version gap itself gets the upgrade badge. + */ + private static skillsChip( + availability: CommandPaletteAvailability, + labelKey: string + ): CommandPaletteChip { + const kind = CommandPaletteChipKind.Skills; + if (availability.probeStatus === CommandPaletteCatalogStatus.Failed) { + // The picker keeps opening: it is where the reason, the host's own text and + // the re-check live, so a dead chip would say strictly less. + return new CommandPaletteChip( + kind, + CommandPaletteChipState.Dimmed, + labelKey, + CommandPalettePolicy.probeBadgeKey(availability.probeFailure), + true, + CommandPalettePolicy.probeReasonKey(availability.probeFailure), + true); + } + if (availability.probeReady) { + return new CommandPaletteChip(kind, CommandPaletteChipState.Ready, labelKey, '', true, '', true); + } + return new CommandPaletteChip( + kind, CommandPaletteChipState.Dimmed, labelKey, '', true, + CommandPalettePolicy.probeWaitReasonKey, false); + } + + /** The corner badge for a failed read: only a version gap is an upgrade. */ + static probeBadgeKey(failure: string): string { + return failure === CommandPaletteProbeFailure.VersionGap ? + CommandPalettePolicy.upgradeBadgeKey : ''; + } + + /** The chip's own sentence for a failed read. */ + static probeReasonKey(failure: string): string { + if (failure === CommandPaletteProbeFailure.VersionGap) { + return CommandPalettePolicy.degradedReasonKey; + } + return failure === CommandPaletteProbeFailure.Refused ? + CommandPalettePolicy.probeRefusedReasonKey : CommandPalettePolicy.probeUnreachableReasonKey; + } + + /** The picker's heading for a failed read. */ + static probeTitleKey(failure: string): string { + if (failure === CommandPaletteProbeFailure.VersionGap) return 'commands.skills.degradedTitle'; + return failure === CommandPaletteProbeFailure.Refused ? + 'commands.skills.refusedTitle' : 'commands.skills.unavailableTitle'; + } + + /** The picker's explanation for a failed read. */ + static probeBodyKey(failure: string): string { + if (failure === CommandPaletteProbeFailure.VersionGap) return 'commands.skills.degradedBody'; + return failure === CommandPaletteProbeFailure.Refused ? + 'commands.skills.refusedBody' : 'commands.skills.unavailableBody'; + } + + /** + * Classifies a failed skill read. + * + * `answered` is what the wire says: the host's bridge replied to this very + * command with a refusal rather than the request never landing. That is still + * not enough to tell the user their desktop is out of date, so the text + * decides — the bridge failing to deliver is a connection problem whatever + * `answered` says, the host's version-gap sentence is the upgrade, and the + * rest is the host declining for a reason of its own. + */ + static probeFailure(answered: boolean, hostText: string): string { + if (!answered) { + return CommandPaletteProbeFailure.Unreachable; + } + const text = hostText.toLowerCase(); + // Checked before the version markers: a bridge that never reached a command + // handler has no opinion about the command set either. + for (const marker of NON_ANSWER_MARKERS) { + if (text.indexOf(marker) >= 0) { + return CommandPaletteProbeFailure.Unreachable; + } + } + for (const marker of VERSION_GAP_MARKERS) { + if (text.indexOf(marker) >= 0) { + return CommandPaletteProbeFailure.VersionGap; + } + } + return CommandPaletteProbeFailure.Refused; + } + + static labelKey(kind: CommandPaletteChipKind): string { + if (kind === CommandPaletteChipKind.Compact) return CommandPalettePolicy.compactLabelKey; + if (kind === CommandPaletteChipKind.Init) return CommandPalettePolicy.initLabelKey; + if (kind === CommandPaletteChipKind.Goal) return CommandPalettePolicy.goalLabelKey; + if (kind === CommandPaletteChipKind.Usage) return CommandPalettePolicy.usageLabelKey; + return CommandPalettePolicy.skillsLabelKey; + } + + static sessionReasonKey(): string { + return 'commands.sessionReason'; + } + + /** + * The row's own line, which is now only ever about the session. + * + * A failed skill read is one chip's business and is explained there and in the + * picker; repeating it under a row of working chips would say the row is broken + * when it is not. + */ + static rowReasonKey(availability: CommandPaletteAvailability): string { + return availability.hasSession ? '' : CommandPalettePolicy.sessionReasonKey(); + } + + /** Whether the palette renders at all: one chip row needs a session to act on. */ + static visible(hasSession: boolean): boolean { + return hasSession; + } + + static canRun(chip: CommandPaletteChip): boolean { + return chip.runsAction; + } + + /** A settled failure is the only notice worth a retry affordance. */ + static canRetry(kind: CommandPaletteNoticeKind, wireAction: string): boolean { + return kind === CommandPaletteNoticeKind.Failed && + CommandPalettePolicy.isRunnable(wireAction); + } + + static busyNoticeKey(wireAction: string): string { + return wireAction === CommandPalettePolicy.initWireAction ? 'commands.initRunning' : 'commands.compactRunning'; + } + + static doneNoticeKey(wireAction: string): string { + return wireAction === CommandPalettePolicy.initWireAction ? 'commands.initDone' : 'commands.compactDone'; + } + + static failedNoticeKey(wireAction: string): string { + return wireAction === CommandPalettePolicy.initWireAction ? 'commands.initFailed' : 'commands.compactFailed'; + } + + /** The prefill the goal chip writes into the draft. */ + static goalPrefillText(): string { + return GOAL_PREFILL; + } + + /** + * Whether `/goal ` may replace what is in the composer. + * + * Only an empty draft may be filled: a prefill that overwrote typed text would + * turn one tap into data loss. + */ + static canPrefillGoal(draft: string): boolean { + return draft.trim().length === 0; + } + + /** Whether the draft is the goal prefill and nothing else. */ + static isGoalPrefill(draft: string): boolean { + return draft === GOAL_PREFILL; + } + + /** + * `true` for a bare `/goal`, which carries no argument and is therefore not a + * message yet. A trailing space is the prefill, not an argument. + */ + static isBareGoalCommand(text: string): boolean { + return text.trim() === GOAL_COMMAND; + } + + /** Whether this draft must not be sent as it stands. */ + static blocksSend(text: string): boolean { + return CommandPalettePolicy.isBareGoalCommand(text); + } + + /** + * Tapping the goal chip. + * + * First tap prefills an empty draft and lights the chip; tapping again takes + * the prefill back off, but only while the draft is still just the prefill — + * once an argument is typed the chip only stops being pending. + */ + static toggleGoal(draft: string, pending: boolean): GoalPrefillToggle { + if (pending) { + if (CommandPalettePolicy.isGoalPrefill(draft)) { + return new GoalPrefillToggle('', false); + } + return new GoalPrefillToggle(draft, false); + } + if (!CommandPalettePolicy.canPrefillGoal(draft)) { + return new GoalPrefillToggle(draft, false); + } + return new GoalPrefillToggle(GOAL_PREFILL, true); + } + + /** + * Picking a skill. + * + * One token at a time: the newest pick replaces the previous one, and picking + * the token that is already there is a no-op rather than a duplicate. The + * comparison is on the skill's key, so re-picking a row whose display name has + * changed is still the same pick. + */ + static applySkillSelection(currentSkillId: string, pickedSkillId: string): string { + const picked = pickedSkillId.trim(); + if (picked.length === 0) { + return currentSkillId; + } + if (picked === currentSkillId) { + return currentSkillId; + } + return picked; + } + + /** + * The token as the composer draws it: the exact text the message will carry. + * + * The label is the skill's own name, which is what the desktop puts in the + * token it inserts (`createSkillPromptReferenceToken(skillName)`), and the + * picker's row hint renders this same string so the two cannot describe + * different things. + */ + static skillTokenLabel(skillLabel: string): string { + const label = skillLabel.trim(); + return label.length === 0 ? '' : `[$${label}]`; + } + + /** Whether the draft opens with a host command rather than free text. */ + static startsWithHostCommand(text: string): boolean { + const trimmed = text.trim(); + return trimmed === GOAL_COMMAND || trimmed.indexOf(`${GOAL_COMMAND} `) === 0; + } + + /** + * The message a skill token sends. + * + * The token is a chip in the composer rather than text in the field, so the + * send path is where it becomes content, built from the same label the chip + * drew. It goes in front of free text, which is the position a reader expects + * and the one the host's skill reference parser accepts anywhere. + * + * A draft that opens with a host command keeps the command first: `/goal` is + * parsed by the desktop from the head of the message (`thread_goal.rs`: + * `goal_objective_from_prompt` refuses anything that does not start with + * `/goal `), so prefixing a token there would silently turn a goal into an + * ordinary message. The token is appended for those drafts instead. + * + * The token is written exactly once: a draft that already carries it keeps it, + * which is what stops a repeated pick from stacking `[$Plan] [$Plan]`. + */ + static composeSubmissionText(draft: string, skillLabel: string): string { + const token = CommandPalettePolicy.skillTokenLabel(skillLabel); + const text = draft.trim(); + if (token.length === 0) { + return draft; + } + if (text.indexOf(token) >= 0) { + return text; + } + if (text.length === 0) { + return token; + } + return CommandPalettePolicy.startsWithHostCommand(text) ? `${text} ${token}` : `${token} ${text}`; + } + + /** Whether a notice is still worth showing after this long, in milliseconds. */ + static noticeDwellMs(kind: CommandPaletteNoticeKind): number { + return kind === CommandPaletteNoticeKind.Done ? 4000 : 0; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostInvokeRefusal.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostInvokeRefusal.ets new file mode 100644 index 0000000000..80f955fbb6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/HostInvokeRefusal.ets @@ -0,0 +1,44 @@ +/** + * The one failure mode `host_invoke` distinguishes: the host answered. + * + * A bridge answers every invoke with `{ok, value, error}`, and `ok: false` means + * the host received the command and declined it — an unknown name, a retired + * command, a mode it does not have. Everything else that can go wrong on this + * wire (no connection, an expired room, a timeout, a payload that decodes to + * nothing) never reached a host at all, and the two must not be described to the + * user as the same event. + * + * This module owns only that distinction, so the palette can ask whether the host + * spoke without knowing which transport carried the question. + */ + +export class HostInvokeRefusal extends Error { + /** The host's own words for the refusal, exactly as it sent them. */ + readonly hostText: string; + /** The command the host declined. */ + readonly command: string; + + constructor(command: string, hostText: string) { + super(hostText.length > 0 ? hostText : `Remote host refused ${command}`); + this.name = 'HostInvokeRefusal'; + this.command = command; + this.hostText = hostText; + } +} + +/** Whether this failure is the host declining a command rather than the wire breaking. */ +export function isHostInvokeRefusal(err: Object): boolean { + return err instanceof HostInvokeRefusal; +} + +/** + * The host's own account of a refusal. + * + * Kept separate from the shared connection-error wording on purpose: a refused + * command is about the command, and the shared policy rewrites text that looks + * like a URL or a permission problem — which is exactly the kind of sentence a + * refusal needs to keep. + */ +export function hostInvokeRefusalText(err: Object): string { + return isHostInvokeRefusal(err) ? (err as HostInvokeRefusal).hostText : ''; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandPaletteController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandPaletteController.ets new file mode 100644 index 0000000000..231fe000cb --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandPaletteController.ets @@ -0,0 +1,329 @@ +import { + RemoteCommandCatalog, + RemoteSessionUsageReport +} from '../model/RemoteModels'; +import { CommandPaletteNoticeKind, CommandPalettePolicy, CommandPaletteProbeFailure } from './CommandPalettePolicy'; +import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; +import { hostInvokeRefusalText, isHostInvokeRefusal } from './HostInvokeRefusal'; +import { RemoteLogger } from './RemoteLogger'; + +/** + * The wire side of the composer command palette. + * + * Four desktop verbs — one read (the skill catalog) and three actions (compact, + * init, usage) — travel as `host_invoke` calls over the connection the phone + * already has. Nothing here needs a capability handshake: the first catalog read + * *is* the probe, and its answer decides whether the row may run anything. A + * host that predates one of these commands refuses it with its own error text, + * which the row shows instead of pretending the tap did nothing. + * + * The catalog is cached per session; it is read on entry, never on render. + */ +export interface RemoteCommandPaletteScope { + sessionId: string; + /** Owning workspace record ID; the paths below are its IO projection. */ + workspaceId?: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +export interface RemoteSkillCatalogQuery extends RemoteCommandPaletteScope { + /** The active session's agent type, which is the host's mode id. */ + modeId: string; + forceRefresh: boolean; +} + +export interface RemoteCommandPaletteClient { + /** Resolves with the host's answer; rejects when the host cannot serve it. */ + skillCatalog(query: RemoteSkillCatalogQuery): Promise; + compactSession(query: RemoteCommandPaletteScope): Promise; + runInitAgentsMd(query: RemoteCommandPaletteScope): Promise; + sessionUsageReport(query: RemoteCommandPaletteScope): Promise; +} + +export interface RemoteCommandPaletteCallbacks { + onCatalogLoading: (sessionId: string) => void; + onCatalogReady: (sessionId: string, catalog: RemoteCommandCatalog) => void; + /** + * The read failed. `failure` is how it failed — a host that refused, or a + * connection that never reached one — and it is what decides whether the row + * may tell the user their desktop is out of date. + */ + onCatalogFailed: (sessionId: string, errorText: string, failure: string) => void; + onCommandStarted: (sessionId: string, wireAction: string) => void; + /** A settled verb. `errorText` is empty on success, and the host's message is. */ + onCommandSettled: (sessionId: string, wireAction: string, errorText: string, message: string) => void; + /** The completion line has been up for its dwell time and may fade out. */ + onCommandNoticeExpired: (sessionId: string, wireAction: string) => void; + onUsageLoading: (sessionId: string) => void; + onUsageReady: (sessionId: string, report: RemoteSessionUsageReport | undefined) => void; + onUsageFailed: (sessionId: string, errorText: string) => void; +} + +/** + * How far the palette's behavior probe has got for the active session. + * + * There is no capability handshake to consult, so the picker reads this instead: + * `Probed` is the state whose rows may be listed, `Unprobed` is where every + * session starts (and reads like a waiting chip for one round trip), and + * `Failed` is a read that did not answer — which is a reason to re-check, and + * only sometimes a reason to upgrade. + */ +export enum CommandPaletteProbe { + Unprobed = 'unprobed', + Probed = 'probed', + Failed = 'failed' +} + +export class RemoteCommandPaletteController { + private readonly client: RemoteCommandPaletteClient; + private readonly callbacks: RemoteCommandPaletteCallbacks; + private catalogSessionId: string = ''; + private catalog?: RemoteCommandCatalog; + /** The probe's answer for `catalogSessionId`. */ + private catalogProbe: string = CommandPaletteProbe.Unprobed; + /** How the read failed, for `catalogSessionId`. */ + private catalogFailure: string = CommandPaletteProbeFailure.None; + /** The host's own text for a failed probe, shown where the row explains itself. */ + private catalogErrorText: string = ''; + /** Wire id of the palette verb in flight, or '' when the row is idle. */ + private runningAction: string = ''; + private noticeTimerId: number = -1; + + constructor(client: RemoteCommandPaletteClient, callbacks: RemoteCommandPaletteCallbacks) { + this.client = client; + this.callbacks = callbacks; + } + + runningCommand(): string { + return this.runningAction; + } + + isRunning(): boolean { + return this.runningAction.length > 0; + } + + catalogFor(sessionId: string): RemoteCommandCatalog | undefined { + return this.catalogSessionId === sessionId ? this.catalog : undefined; + } + + /** Whether a read for this session has already answered for this session. */ + catalogProbeFor(sessionId: string): string { + return this.catalogSessionId === sessionId ? this.catalogProbe : CommandPaletteProbe.Unprobed; + } + + /** How this session's failed read failed, or `None` when it did not. */ + catalogFailureFor(sessionId: string): string { + return this.catalogSessionId === sessionId ? this.catalogFailure : CommandPaletteProbeFailure.None; + } + + /** The host's refusal text for this session's failed probe, or ''. */ + catalogErrorFor(sessionId: string): string { + return this.catalogSessionId === sessionId ? this.catalogErrorText : ''; + } + + /** Drops the cached catalog and pending notice when the conversation ends. */ + reset(): void { + this.cancelNoticeTimer(); + this.catalogSessionId = ''; + this.catalog = undefined; + this.catalogProbe = CommandPaletteProbe.Unprobed; + this.catalogFailure = CommandPaletteProbeFailure.None; + this.catalogErrorText = ''; + this.runningAction = ''; + } + + private static shortSessionId(sessionId: string): string { + return sessionId.length <= 8 ? sessionId : + `${sessionId.slice(0, 4)}...${sessionId.slice(sessionId.length - 4)}`; + } + + /** + * Reads the skill catalog for one session — the palette's behavior probe. + * + * `force` is the user's 「重新检查」: it replays a read that failed, so a + * desktop upgraded since the last attempt is picked up without restarting the + * app. A non-forced call with an answer already in hand does not read again — + * that is what keeps this off the render path — but it does hand the answer + * back, because the cache outlives the state it was painted into. Re-entering a + * session drops the page's palette state while this controller keeps the + * answer, so a silent early return would draw a row that waits for an answer + * this controller already has. + */ + async loadCatalog( + sessionId: string, + modeId: string, + force: boolean, + canApply: (sessionId: string) => boolean + ): Promise { + if (sessionId.length === 0 || !canApply(sessionId)) { + return; + } + if (!force && this.catalogSessionId === sessionId && + this.catalogProbe !== CommandPaletteProbe.Unprobed) { + this.republishProbeAnswer(sessionId); + return; + } + this.catalogSessionId = sessionId; + this.catalogProbe = CommandPaletteProbe.Unprobed; + this.catalogFailure = CommandPaletteProbeFailure.None; + this.catalogErrorText = ''; + if (force) { + this.catalog = undefined; + } + this.callbacks.onCatalogLoading(sessionId); + try { + const catalog = await this.client.skillCatalog({ + sessionId, + modeId, + forceRefresh: force + }); + if (!canApply(sessionId)) { + return; + } + this.catalog = catalog; + this.catalogProbe = CommandPaletteProbe.Probed; + this.callbacks.onCatalogReady(sessionId, catalog); + RemoteLogger.info( + `command catalog loaded session=${RemoteCommandPaletteController.shortSessionId(sessionId)} ` + + `mode=${modeId} skills=${catalog.skills.length} refreshed=${force ? '1' : '0'}`); + } catch (err) { + if (!canApply(sessionId)) { + return; + } + // A read that did not answer leaves the picker's chip waiting for a + // re-check, and only a host that named the version gap gets the upgrade + // badge: "your desktop is too old" is not a thing to say about a timeout. + const answered = isHostInvokeRefusal(err); + const errorText = answered ? hostInvokeRefusalText(err) : ConnectionErrorPolicy.errorText(err); + this.catalog = undefined; + this.catalogProbe = CommandPaletteProbe.Failed; + this.catalogErrorText = errorText; + this.catalogFailure = CommandPalettePolicy.probeFailure(answered, errorText); + RemoteLogger.warn( + `command catalog failed session=${RemoteCommandPaletteController.shortSessionId(sessionId)} ` + + `failure=${this.catalogFailure}: ${String(err)}`); + this.callbacks.onCatalogFailed(sessionId, this.catalogErrorText, this.catalogFailure); + } + } + + /** + * Hands the cached answer back to whoever is painting the row now. + * + * The controller owns the probe's answer and the page owns its presentation; + * re-publishing keeps one owner of the truth instead of letting the two drift + * apart across a session that is left and re-entered. + */ + private republishProbeAnswer(sessionId: string): void { + if (this.catalogProbe === CommandPaletteProbe.Probed && this.catalog !== undefined) { + this.callbacks.onCatalogReady(sessionId, this.catalog); + return; + } + if (this.catalogProbe === CommandPaletteProbe.Failed) { + this.callbacks.onCatalogFailed(sessionId, this.catalogErrorText, this.catalogFailure); + } + } + + /** + * Runs one of the two verbs that execute immediately. + * + * Single-shot: a second tap while the first is still out is dropped rather + * than queued, because the host would either refuse it or act on a context the + * user has not seen the result of yet. + */ + async runCommand( + sessionId: string, + wireAction: string, + query: RemoteCommandPaletteScope, + canApply: (sessionId: string) => boolean + ): Promise { + if (sessionId.length === 0 || wireAction.length === 0 || this.isRunning() || !canApply(sessionId)) { + return; + } + this.cancelNoticeTimer(); + this.runningAction = wireAction; + this.callbacks.onCommandStarted(sessionId, wireAction); + const startedAt = Date.now(); + try { + const message = wireAction === CommandPalettePolicy.initWireAction ? + await this.client.runInitAgentsMd(query) : + await this.client.compactSession(query); + if (!canApply(sessionId)) { + this.runningAction = ''; + return; + } + this.runningAction = ''; + RemoteLogger.info( + `command palette accepted session=${RemoteCommandPaletteController.shortSessionId(sessionId)} ` + + `action=${wireAction} ms=${Date.now() - startedAt}`); + this.callbacks.onCommandSettled(sessionId, wireAction, '', message); + this.scheduleNoticeExpiry(sessionId, wireAction); + } catch (err) { + this.runningAction = ''; + if (!canApply(sessionId)) { + return; + } + const errorText = ConnectionErrorPolicy.errorText(err); + RemoteLogger.warn( + `command palette failed session=${RemoteCommandPaletteController.shortSessionId(sessionId)} ` + + `action=${wireAction}: ${String(err)}`); + this.callbacks.onCommandSettled(sessionId, wireAction, errorText, ''); + } + } + + /** + * The session usage report, read on every open. + * + * Usage is a snapshot by nature, so unlike the catalog it is never served from + * a cache: the numbers the user asked for are the ones measured when they + * asked. + */ + async loadUsageReport( + sessionId: string, + query: RemoteCommandPaletteScope, + canApply: (sessionId: string) => boolean + ): Promise { + if (sessionId.length === 0 || !canApply(sessionId)) { + return; + } + this.callbacks.onUsageLoading(sessionId); + const startedAt = Date.now(); + try { + const report = await this.client.sessionUsageReport(query); + if (!canApply(sessionId)) { + return; + } + RemoteLogger.info( + `session usage report loaded session=${RemoteCommandPaletteController.shortSessionId(sessionId)} ` + + `present=${report ? '1' : '0'} ms=${Date.now() - startedAt}`); + this.callbacks.onUsageReady(sessionId, report); + } catch (err) { + if (!canApply(sessionId)) { + return; + } + RemoteLogger.warn( + `session usage report failed session=${RemoteCommandPaletteController.shortSessionId(sessionId)}: ${String(err)}`); + this.callbacks.onUsageFailed(sessionId, ConnectionErrorPolicy.errorText(err)); + } + } + + private scheduleNoticeExpiry(sessionId: string, wireAction: string): void { + const dwell = CommandPalettePolicy.noticeDwellMs(CommandPaletteNoticeKind.Done); + if (dwell <= 0) { + return; + } + this.cancelNoticeTimer(); + this.noticeTimerId = setTimeout((): void => { + this.noticeTimerId = -1; + this.callbacks.onCommandNoticeExpired(sessionId, wireAction); + }, dwell); + } + + private cancelNoticeTimer(): void { + if (this.noticeTimerId !== -1) { + clearTimeout(this.noticeTimerId); + this.noticeTimerId = -1; + } + } +} 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 2b11f882e1..862f69c2e7 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 @@ -2,7 +2,7 @@ import { WorkspaceCatalog, projectWorkspaceCatalog } from './WorkspaceCatalog'; import { RuntimeFileLocation } from '../model/RemoteModels'; import { HostSessionCallbacks, HostSessionStream, REMOTE_CAPABILITY_HOST_STREAM_V1 } from './HostSessionStream'; import { SavedRuntimeConnection, SavedRuntimeConnectionsResponse } from '../model/RemoteModels'; -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, RemoteWorkspaceIdentity, REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteCommandCatalog, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, RemoteSessionUsageReport, RemoteTurnAcceptedValue, RemoteWorkspaceIdentity, REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { CloudAccountClient, CloudAccountRequestError, CloudAccountSession } from './CloudAccountClient'; import { RemoteI18n } from '../i18n/RemoteI18n'; @@ -16,6 +16,9 @@ import { RemoteChatCommandClient } from './RemoteChatCommandController'; import { RemoteFileDownloadClient } from './RemoteFileDownloadController'; import { RemoteLogger } from './RemoteLogger'; import { RemoteModelClient } from './RemoteModelController'; +import { RemoteCommandPaletteClient, RemoteCommandPaletteScope, RemoteSkillCatalogQuery } from './RemoteCommandPaletteController'; +import { HostInvokeRefusal } from './HostInvokeRefusal'; +import { CommandCatalogProjection, RemoteModeSkillRow } from './CommandCatalogProjection'; import { RemoteResponseMapper } from './RemoteResponseMapper'; import { stampRemoteSessionsWorkspace } from './RemoteSessionIdentity'; import { TranscriptIntegrityPolicy } from './TranscriptIntegrityPolicy'; @@ -38,7 +41,42 @@ const ACCOUNT_HANDSHAKE_TIMEOUT_MS: number = 15000; interface PermissionReplyArgs { request: PermissionReplyRequest; } interface PermissionReplyRequest { requestId: string; reply: string; feedback?: string; updatedInput?: Object; } -export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient { +/** + * HostInvoke arguments for the composer palette's verbs. + * + * Shapes are per-command, not a shared envelope: `get_mode_skill_configs` takes + * flat arguments, and the three session verbs take a `request` object whose + * field spelling differs between their two Rust structs. + */ +interface ModeSkillConfigArgs { + modeId: string; + forceRefresh?: boolean; + workspaceId?: string; + workspacePath?: string; +} + +interface SessionRequest { + sessionId: string; + workspaceId?: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +interface SessionRequestArgs { request: SessionRequest; } + +/** `GetSessionUsageReportRequest` is declared without `rename_all`. */ +interface SessionUsageRequest { + session_id: string; + workspace_id?: string; + workspace_path?: string; + remote_connection_id?: string; + remote_ssh_host?: string; +} + +interface SessionUsageArgs { request: SessionUsageRequest; } + +export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient, RemoteCommandPaletteClient { private transport?: RemoteCommandTransport; private transportGeneration: number = 0; private workspace?: WorkspaceInfo; @@ -64,9 +102,16 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile return (this.hostCapabilities || []).indexOf(REMOTE_CAPABILITY_HOST_STREAM_V1) >= 0; } + /** + * Sends one product command through the host bridge. + * + * A refusal is thrown as `HostInvokeRefusal` rather than a bare `Error` so the + * caller can tell a host that answered from a connection that did not: only the + * first may be described to the user as the host's own decision. + */ async hostInvoke(command: string, args: Object): Promise { const response = await this.send>({ cmd: 'host_invoke', command: command, args: args }); - if (!response.ok) throw new Error(response.error || 'Remote host operation failed'); + if (!response.ok) throw new HostInvokeRefusal(command, response.error || ''); return response.value as T; } @@ -447,6 +492,93 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile return response.model_id || modelId; } + /** + * The skill catalog the palette's picker shows. + * + * `get_mode_skill_configs` takes flat arguments and answers with the mode's + * skill rows; without `includeDiagnostics` the value is that rows array, which + * the projection turns into palette entries. A host that does not serve the + * command rejects the invoke, and that rejection is the palette's probe. + */ + async skillCatalog(query: RemoteSkillCatalogQuery): Promise { + const args: ModeSkillConfigArgs = { + modeId: query.modeId, + forceRefresh: query.forceRefresh + }; + if (query.workspaceId) { + args.workspaceId = query.workspaceId; + } + if (query.workspacePath) { + args.workspacePath = query.workspacePath; + } + const rows = await this.hostInvoke('get_mode_skill_configs', args); + return CommandCatalogProjection.parseHostCatalog(rows); + } + + /** + * `compact_session`, whose handler answers with the turn it started. + * + * The workspace locator is an upgrade projection: a host that still has the + * session in memory ignores it, and one that does not needs it to restore the + * session before compacting. + */ + async compactSession(query: RemoteCommandPaletteScope): Promise { + const args: SessionRequestArgs = { request: RemoteSessionManager.sessionRequest(query) }; + const value = await this.hostInvoke('compact_session', args); + RemoteLogger.info( + `compact_session accepted session=${RemoteSessionManager.shortId(query.sessionId)}`); + return value?.message || ''; + } + + async runInitAgentsMd(query: RemoteCommandPaletteScope): Promise { + const args: SessionRequestArgs = { request: RemoteSessionManager.sessionRequest(query) }; + const value = await this.hostInvoke('run_init_agents_md', args); + RemoteLogger.info( + `run_init_agents_md accepted session=${RemoteSessionManager.shortId(query.sessionId)}`); + return value?.message || ''; + } + + /** + * `get_session_usage_report`, whose request keeps its snake_case spelling. + * + * Unlike the two turn verbs this struct carries no `rename_all`, so its fields + * travel exactly as the desktop declares them. + */ + async sessionUsageReport(query: RemoteCommandPaletteScope): Promise { + const request: SessionUsageRequest = { session_id: query.sessionId }; + if (query.workspaceId) { + request.workspace_id = query.workspaceId; + } + if (query.workspacePath) { + request.workspace_path = query.workspacePath; + } + if (query.remoteConnectionId) { + request.remote_connection_id = query.remoteConnectionId; + } + if (query.remoteSshHost) { + request.remote_ssh_host = query.remoteSshHost; + } + const args: SessionUsageArgs = { request }; + return await this.hostInvoke('get_session_usage_report', args); + } + + private static sessionRequest(query: RemoteCommandPaletteScope): SessionRequest { + const request: SessionRequest = { sessionId: query.sessionId }; + if (query.workspaceId) { + request.workspaceId = query.workspaceId; + } + if (query.workspacePath) { + request.workspacePath = query.workspacePath; + } + if (query.remoteConnectionId) { + request.remoteConnectionId = query.remoteConnectionId; + } + if (query.remoteSshHost) { + request.remoteSshHost = query.remoteSshHost; + } + return request; + } + async pollSession( sessionId: string, sinceVersion: number, diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json index c82f5680c0..4af040b86c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json @@ -2735,6 +2735,306 @@ { "name": "time_daysAgo", "value": "%1$s days ago" + }, + { + "name": "commands_compact", + "value": "Compact session" + }, + { + "name": "commands_init", + "value": "Generate AGENTS.md" + }, + { + "name": "commands_goal", + "value": "Session goal" + }, + { + "name": "commands_usage", + "value": "Session usage" + }, + { + "name": "commands_skills", + "value": "Skills" + }, + { + "name": "commands_upgradeBadge", + "value": "Upgrade" + }, + { + "name": "commands_degradedReason", + "value": "The connected desktop is too old to list skills. Upgrade OpenBitFun on that device; messages still send in this session." + }, + { + "name": "commands_busyReason", + "value": "Wait for the session to be idle before using this command." + }, + { + "name": "commands_sessionReason", + "value": "Open a session to use the command row." + }, + { + "name": "commands_goalHint", + "value": "Add the objective after /goal, then send." + }, + { + "name": "commands_skillToken", + "value": "Skill" + }, + { + "name": "commands_compactRunning", + "value": "Compacting the context…" + }, + { + "name": "commands_compactDone", + "value": "Session compacted" + }, + { + "name": "commands_compactFailed", + "value": "Session compaction failed" + }, + { + "name": "commands_initRunning", + "value": "Generating AGENTS.md…" + }, + { + "name": "commands_initDone", + "value": "AGENTS.md generated" + }, + { + "name": "commands_initFailed", + "value": "Could not generate AGENTS.md" + }, + { + "name": "commands_skills_title", + "value": "Skills" + }, + { + "name": "commands_skills_searchPlaceholder", + "value": "Search skills" + }, + { + "name": "commands_skills_sectionBuiltin", + "value": "Built-in" + }, + { + "name": "commands_skills_sectionCustom", + "value": "Custom" + }, + { + "name": "commands_skills_filterAll", + "value": "All" + }, + { + "name": "commands_skills_filterBuiltin", + "value": "Built-in" + }, + { + "name": "commands_skills_filterCustom", + "value": "Custom" + }, + { + "name": "commands_skills_noMatch", + "value": "No skill matches this search." + }, + { + "name": "commands_skills_empty", + "value": "This session has no skill you can invoke." + }, + { + "name": "commands_skills_recheck", + "value": "Check again" + }, + { + "name": "commands_skills_unavailableTitle", + "value": "Skills are unavailable" + }, + { + "name": "commands_skills_unavailableBody", + "value": "The connected desktop did not answer the skill list. Check the connection to that device, then check again." + }, + { + "name": "commands_skills_degradedTitle", + "value": "Skills need a newer desktop" + }, + { + "name": "commands_skills_degradedBody", + "value": "The connected desktop is too old for the skill list. Upgrade OpenBitFun on that device, then check again." + }, + { + "name": "commands_skills_footer", + "value": "Picking a skill writes the draft; it never sends." + }, + { + "name": "commands_skills_footerUnavailable", + "value": "The draft is unchanged while skills are unavailable." + }, + { + "name": "usage_title", + "value": "Session Usage" + }, + { + "name": "usage_sectionOverview", + "value": "Overview" + }, + { + "name": "usage_sectionTime", + "value": "Time" + }, + { + "name": "usage_sectionTokens", + "value": "Tokens" + }, + { + "name": "usage_sectionModels", + "value": "Models" + }, + { + "name": "usage_sectionTools", + "value": "Tools" + }, + { + "name": "usage_sectionFiles", + "value": "Files" + }, + { + "name": "usage_sectionCompression", + "value": "Compaction" + }, + { + "name": "usage_sectionErrors", + "value": "Errors" + }, + { + "name": "usage_scopeTurns", + "value": "Turns" + }, + { + "name": "usage_scopeSubagents", + "value": "Includes subagents" + }, + { + "name": "usage_coverageLevel", + "value": "Data coverage" + }, + { + "name": "usage_coverageMissing", + "value": "Not reported" + }, + { + "name": "usage_workspace", + "value": "Workspace" + }, + { + "name": "usage_timeSpan", + "value": "Session span" + }, + { + "name": "usage_timeTurns", + "value": "Recorded turn time" + }, + { + "name": "usage_timeModel", + "value": "Model time" + }, + { + "name": "usage_timeTools", + "value": "Tool time" + }, + { + "name": "usage_tokensInput", + "value": "Input" + }, + { + "name": "usage_tokensOutput", + "value": "Output" + }, + { + "name": "usage_tokensTotal", + "value": "Total" + }, + { + "name": "usage_tokensCached", + "value": "Cached" + }, + { + "name": "usage_modelRow", + "value": "Model" + }, + { + "name": "usage_toolRow", + "value": "Tool" + }, + { + "name": "usage_filesChanged", + "value": "Files changed" + }, + { + "name": "usage_filesAdded", + "value": "Lines added" + }, + { + "name": "usage_filesDeleted", + "value": "Lines deleted" + }, + { + "name": "usage_compactionCount", + "value": "Compactions" + }, + { + "name": "usage_compactionSaved", + "value": "Tokens saved" + }, + { + "name": "usage_errorsTotal", + "value": "Errors" + }, + { + "name": "usage_errorsTools", + "value": "Tool errors" + }, + { + "name": "usage_errorsModels", + "value": "Model errors" + }, + { + "name": "usage_partialNote", + "value": "Some metrics were not reported by this session or provider." + }, + { + "name": "usage_yes", + "value": "Yes" + }, + { + "name": "usage_no", + "value": "No" + }, + { + "name": "usage_loadFailed", + "value": "Could not load the usage report" + }, + { + "name": "usage_empty", + "value": "No usage was reported for this session." + }, + { + "name": "commands_probeWaitReason", + "value": "Reading the skill list…" + }, + { + "name": "commands_probeRefusedReason", + "value": "The connected desktop refused to list skills." + }, + { + "name": "commands_probeUnreachableReason", + "value": "The connected desktop did not answer the skill list." + }, + { + "name": "commands_skills_refusedTitle", + "value": "Skills were refused" + }, + { + "name": "commands_skills_refusedBody", + "value": "The connected desktop answered, and its answer was a refusal. The desktop text below is what it said; check the session and try again." } ] } diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json index 93abf78ed1..cda5e8df5f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json @@ -2735,6 +2735,306 @@ { "name": "time_daysAgo", "value": "%1$s 天前" + }, + { + "name": "commands_compact", + "value": "压缩会话" + }, + { + "name": "commands_init", + "value": "生成 AGENTS.md" + }, + { + "name": "commands_goal", + "value": "会话目标" + }, + { + "name": "commands_usage", + "value": "会话用量" + }, + { + "name": "commands_skills", + "value": "技能" + }, + { + "name": "commands_upgradeBadge", + "value": "升级" + }, + { + "name": "commands_degradedReason", + "value": "桌面端版本过旧,无法提供技能列表。请升级该设备上的 OpenBitFun;本会话仍可正常发送消息。" + }, + { + "name": "commands_busyReason", + "value": "请等待当前会话空闲后再使用该命令。" + }, + { + "name": "commands_sessionReason", + "value": "打开一个会话后即可使用命令行。" + }, + { + "name": "commands_goalHint", + "value": "在 /goal 后补充目标描述,然后发送。" + }, + { + "name": "commands_skillToken", + "value": "技能" + }, + { + "name": "commands_compactRunning", + "value": "正在压缩上下文…" + }, + { + "name": "commands_compactDone", + "value": "会话已压缩" + }, + { + "name": "commands_compactFailed", + "value": "会话压缩失败" + }, + { + "name": "commands_initRunning", + "value": "正在生成 AGENTS.md…" + }, + { + "name": "commands_initDone", + "value": "AGENTS.md 已生成" + }, + { + "name": "commands_initFailed", + "value": "生成 AGENTS.md 失败" + }, + { + "name": "commands_skills_title", + "value": "技能" + }, + { + "name": "commands_skills_searchPlaceholder", + "value": "搜索技能" + }, + { + "name": "commands_skills_sectionBuiltin", + "value": "内置" + }, + { + "name": "commands_skills_sectionCustom", + "value": "自定义" + }, + { + "name": "commands_skills_filterAll", + "value": "全部" + }, + { + "name": "commands_skills_filterBuiltin", + "value": "内置" + }, + { + "name": "commands_skills_filterCustom", + "value": "自定义" + }, + { + "name": "commands_skills_noMatch", + "value": "没有匹配的技能。" + }, + { + "name": "commands_skills_empty", + "value": "当前会话没有可调用的技能。" + }, + { + "name": "commands_skills_recheck", + "value": "重新检查" + }, + { + "name": "commands_skills_unavailableTitle", + "value": "技能暂不可用" + }, + { + "name": "commands_skills_unavailableBody", + "value": "桌面端没有响应技能列表请求。请检查与该设备的连接后重试。" + }, + { + "name": "commands_skills_degradedTitle", + "value": "技能需要更新的桌面端" + }, + { + "name": "commands_skills_degradedBody", + "value": "已连接的桌面端版本过旧,无法读取技能列表。请升级该设备上的 OpenBitFun 后重新检查。" + }, + { + "name": "commands_skills_footer", + "value": "选择技能只写入草稿,不会直接发送。" + }, + { + "name": "commands_skills_footerUnavailable", + "value": "技能不可用期间草稿不会被修改。" + }, + { + "name": "usage_title", + "value": "会话用量" + }, + { + "name": "usage_sectionOverview", + "value": "概览" + }, + { + "name": "usage_sectionTime", + "value": "耗时" + }, + { + "name": "usage_sectionTokens", + "value": "Tokens" + }, + { + "name": "usage_sectionModels", + "value": "模型" + }, + { + "name": "usage_sectionTools", + "value": "工具" + }, + { + "name": "usage_sectionFiles", + "value": "文件" + }, + { + "name": "usage_sectionCompression", + "value": "压缩" + }, + { + "name": "usage_sectionErrors", + "value": "错误" + }, + { + "name": "usage_scopeTurns", + "value": "轮次" + }, + { + "name": "usage_scopeSubagents", + "value": "含子代理" + }, + { + "name": "usage_coverageLevel", + "value": "数据完整度" + }, + { + "name": "usage_coverageMissing", + "value": "未上报" + }, + { + "name": "usage_workspace", + "value": "项目" + }, + { + "name": "usage_timeSpan", + "value": "会话跨度" + }, + { + "name": "usage_timeTurns", + "value": "已记录轮次耗时" + }, + { + "name": "usage_timeModel", + "value": "模型耗时" + }, + { + "name": "usage_timeTools", + "value": "工具耗时" + }, + { + "name": "usage_tokensInput", + "value": "输入" + }, + { + "name": "usage_tokensOutput", + "value": "输出" + }, + { + "name": "usage_tokensTotal", + "value": "合计" + }, + { + "name": "usage_tokensCached", + "value": "缓存" + }, + { + "name": "usage_modelRow", + "value": "模型" + }, + { + "name": "usage_toolRow", + "value": "工具" + }, + { + "name": "usage_filesChanged", + "value": "变更文件" + }, + { + "name": "usage_filesAdded", + "value": "新增行" + }, + { + "name": "usage_filesDeleted", + "value": "删除行" + }, + { + "name": "usage_compactionCount", + "value": "压缩次数" + }, + { + "name": "usage_compactionSaved", + "value": "节省 Tokens" + }, + { + "name": "usage_errorsTotal", + "value": "错误" + }, + { + "name": "usage_errorsTools", + "value": "工具错误" + }, + { + "name": "usage_errorsModels", + "value": "模型错误" + }, + { + "name": "usage_partialNote", + "value": "部分指标没有被当前会话或服务商上报。" + }, + { + "name": "usage_yes", + "value": "是" + }, + { + "name": "usage_no", + "value": "否" + }, + { + "name": "usage_loadFailed", + "value": "无法加载用量报告" + }, + { + "name": "usage_empty", + "value": "当前会话没有可展示的用量数据。" + }, + { + "name": "commands_probeWaitReason", + "value": "正在读取技能列表…" + }, + { + "name": "commands_probeRefusedReason", + "value": "桌面端拒绝提供技能列表。" + }, + { + "name": "commands_probeUnreachableReason", + "value": "桌面端没有响应技能列表请求。" + }, + { + "name": "commands_skills_refusedTitle", + "value": "技能列表被拒绝" + }, + { + "name": "commands_skills_refusedBody", + "value": "桌面端有响应,但拒绝了请求。下方是桌面端的原文;请检查会话后重试。" } ] } diff --git a/src/apps/mobile/harmonyos/tools/tests/command-catalog.test.cjs b/src/apps/mobile/harmonyos/tools/tests/command-catalog.test.cjs new file mode 100644 index 0000000000..0512957033 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/command-catalog.test.cjs @@ -0,0 +1,433 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const ETS_ROOT = path.join(__dirname, '../../entry/src/main/ets'); +const cache = new Map(); + +function resolveSpecifier(fromFile, specifier) { + const base = path.resolve(path.dirname(fromFile), specifier); + for (const candidate of [base, `${base}.ets`, path.join(base, 'index.ets')]) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + } + return undefined; +} + +// Transpiles a real `.ets` module and resolves its relative imports the same way, +// so the projection under test is the production file rather than a copy. +function loadModule(file) { + if (cache.has(file)) { + return cache.get(file); + } + const exported = {}; + cache.set(file, exported); + const source = fs.readFileSync(file, 'utf8'); + const js = ts.transpileModule(source, { + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS} + }).outputText; + const localRequire = (specifier) => { + if (!specifier.startsWith('.')) { + throw new Error(`unexpected import "${specifier}" in ${path.basename(file)}`); + } + const resolved = resolveSpecifier(file, specifier); + if (resolved === undefined) { + throw new Error(`cannot resolve "${specifier}" from ${path.basename(file)}`); + } + return loadModule(resolved); + }; + new Function('require', 'exports', js)(localRequire, exported); + return exported; +} + +function load(relativePath) { + return loadModule(path.join(ETS_ROOT, relativePath)); +} + +const { + CommandCatalogProjection: Projection, + COMMAND_CATALOG_FILTER_ALL, + COMMAND_CATALOG_FILTER_BUILTIN, + COMMAND_CATALOG_FILTER_CUSTOM +} = load('services/CommandCatalogProjection.ets'); +const { CommandPalettePolicy } = load('services/CommandPalettePolicy.ets'); +const models = load('model/RemoteModels.ets'); +const managerSource = fs.readFileSync(path.join(ETS_ROOT, 'services/RemoteSessionManager.ets'), 'utf8'); + +// One host row, shaped exactly like `ModeSkillInfo`: a flattened `SkillInfo` +// beside the mode's availability flags. Built-in skills are the only ones the +// host gives a group key, which is what the picker's ordering leans on. +function row(key, overrides = {}) { + return Object.assign({ + key, + name: key, + description: `${key} description`, + path: `/skills/${key}`, + level: 'user', + dirName: key, + isBuiltin: false, + isShadowed: false, + allowUserInvocation: true, + defaultEnabled: true, + globallyEnabled: true, + effectiveEnabled: true, + disabledByMode: false, + selectedForRuntime: true, + stateReason: 'builtin_policy_enabled' + }, overrides); +} + +function builtinRow(key, groupKey, name = key) { + return row(key, {name, isBuiltin: true, groupKey}); +} + +function ids(catalog) { + return Projection.rows(catalog).map((entry) => entry.id); +} + +test('the host row shape maps onto one palette entry per skill', () => { + const catalog = Projection.parseHostCatalog([ + builtinRow('plan', 'workflow', 'Plan'), + row('review', {level: 'project', description: 'Review the change'}) + ]); + assert.equal(catalog.skills.length, 2); + const plan = catalog.skills[0]; + assert.equal(plan.id, 'plan', 'the id is the key the [\$key] token carries'); + assert.equal(plan.name, 'Plan'); + assert.equal(plan.description, 'plan description'); + assert.equal(plan.group, 'workflow', 'a built-in keeps the host group taxonomy'); + assert.equal(plan.source, models.REMOTE_SKILL_SOURCE_BUILTIN); + assert.equal(plan.shadowed, false); + assert.equal(plan.selectedForRuntime, true); + assert.equal(plan.effectiveEnabled, true); + assert.equal(plan.globallyEnabled, true); + assert.equal(plan.allowUserInvocation, true); + + const review = catalog.skills[1]; + assert.equal(review.group, '', 'a user skill has no host group of its own'); + assert.equal(review.source, models.REMOTE_SKILL_SOURCE_PROJECT, + 'a skill written for this project says so'); +}); + +test('the projection reads a snake_case host as well as the camelCase one', () => { + // The payload crosses a relay between two independently built versions. + const catalog = Projection.parseHostCatalog([{ + key: 'plan', + name: 'Plan', + description: 'Plan the work', + level: 'user', + is_builtin: true, + group_key: 'workflow', + is_shadowed: false, + allow_user_invocation: true, + selected_for_runtime: true, + effective_enabled: true, + globally_enabled: true + }]); + assert.deepEqual(ids(catalog), ['plan']); + const plan = catalog.skills[0]; + assert.equal(plan.group, 'workflow'); + assert.equal(plan.source, models.REMOTE_SKILL_SOURCE_BUILTIN); + assert.equal(Projection.invocable(plan), true); +}); + +test('a row without a key names nothing and is dropped', () => { + const catalog = Projection.parseHostCatalog([ + row(''), + row(' ', {name: 'blank'}), + row('plan') + ]); + assert.deepEqual(ids(catalog), ['plan'], + 'the key is what the picker lists and keys on, so a row without one is unusable'); +}); + +test('an empty or absent read yields an empty catalog rather than an error', () => { + assert.deepEqual(Projection.parseHostCatalog(undefined).skills, []); + assert.deepEqual(Projection.parseHostCatalog([]).skills, []); + assert.deepEqual(ids(undefined), []); +}); + +test('only the skill the host offered to the user is offered by the picker', () => { + // Mirrors the desktop composer's own rule. Each flag is a real refusal. + function parseOne(overrides = {}) { + return Projection.parseHostCatalog([row('plan', overrides)]).skills[0]; + } + + assert.equal(Projection.invocable(parseOne()), true); + assert.equal(Projection.invocable(parseOne({isShadowed: true})), false, + 'a shadowed row is a duplicate of the winner, never a second choice'); + assert.equal(Projection.invocable(parseOne({selectedForRuntime: false})), false); + assert.equal(Projection.invocable(parseOne({effectiveEnabled: false})), false, + 'a skill this mode disabled is not offered'); + assert.equal(Projection.invocable(parseOne({globallyEnabled: false})), false); + assert.equal(Projection.invocable(parseOne({allowUserInvocation: false})), false, + 'a skill the model may reach but the user may not must not be listed'); + + const catalog = Projection.parseHostCatalog([ + row('plan'), + row('shadowed', {isShadowed: true}), + row('mode-off', {effectiveEnabled: false}), + row('implicit-only', {allowUserInvocation: false}) + ]); + assert.deepEqual(ids(catalog), ['plan']); +}); + +test('a host that omits the availability flags offers nothing it did not state', () => { + // The three availability flags have no contract default, so an absent one reads + // as `false`: a half-instrumented host yields rows without claiming they are + // reachable. `allow_user_invocation` is the one flag whose default is `true`, + // because its absence is the browser-compatible shape rather than a refusal. + const catalog = Projection.parseHostCatalog([ + {key: 'valid', selected_for_runtime: true, effective_enabled: true, globally_enabled: true}, + {key: 'unstated', name: 'Unstated'} + ]); + const valid = catalog.skills.find((entry) => entry.id === 'valid'); + const unstated = catalog.skills.find((entry) => entry.id === 'unstated'); + assert.equal(valid.allowUserInvocation, true); + assert.equal(Projection.invocable(valid), true); + assert.equal(unstated.selectedForRuntime, false, 'an unstated flag is not a stated yes'); + assert.equal(unstated.effectiveEnabled, false); + assert.equal(unstated.globallyEnabled, false); + assert.equal(unstated.allowUserInvocation, true, 'the one flag with a true default'); + assert.equal(Projection.invocable(unstated), false); +}); + +test('one key is one row even when the host lists the winner and its shadow', () => { + // The host emits both rows under the same key, and a picker keyed by id would + // otherwise render the same choice twice. Keeping the first row could keep the + // shadowed one and hide the skill the user can actually invoke, so the + // invocable row wins whichever order they arrive in. + const shadowedFirst = Projection.parseHostCatalog([ + row('plan', {name: 'Plan (shadowed)', isShadowed: true}), + row('plan', {name: 'Plan'}) + ]); + assert.deepEqual(ids(shadowedFirst), ['plan']); + assert.equal(shadowedFirst.skills.length, 1); + assert.equal(shadowedFirst.skills[0].name, 'Plan', + 'the invocable row is the one the picker shows'); + + const invocableFirst = Projection.parseHostCatalog([ + row('plan', {name: 'Plan'}), + row('plan', {name: 'Plan (shadowed)', isShadowed: true}) + ]); + assert.deepEqual(invocableFirst.skills.map((entry) => entry.name), ['Plan']); + assert.equal(invocableFirst.skills[0].shadowed, false, + 'a later shadowed duplicate must not replace the winner'); +}); + +test('a name collision keeps both rows, custom after builtin', () => { + // The two sides of the sheet never merge: a user skill that reuses a built-in + // name stays its own row in the custom group instead of shadowing the built-in. + const catalog = Projection.parseHostCatalog([ + builtinRow('plan', 'workflow', 'Plan'), + row('plan.user', {name: 'Plan', level: 'project'}) + ]); + const presentation = Projection.present(catalog, '', COMMAND_CATALOG_FILTER_ALL); + assert.deepEqual(presentation.sections.map((section) => section.id), + [COMMAND_CATALOG_FILTER_BUILTIN, COMMAND_CATALOG_FILTER_CUSTOM], + 'the custom section always comes last'); + assert.deepEqual(presentation.sections[0].rows.map((entry) => entry.name), ['Plan']); + assert.deepEqual(presentation.sections[1].rows.map((entry) => entry.name), ['Plan']); + assert.notEqual(presentation.sections[0].rows[0].id, presentation.sections[1].rows[0].id); +}); + +test('the built-in section is ordered by the host group, then by name', () => { + const catalog = Projection.parseHostCatalog([ + builtinRow('zeta', 'workflow'), + builtinRow('alpha', 'writing'), + builtinRow('beta', 'workflow') + ]); + const presentation = Projection.present(catalog, '', COMMAND_CATALOG_FILTER_ALL); + const builtin = presentation.sections[0]; + assert.equal(builtin.titleKey, Projection.builtinSectionKey); + assert.deepEqual(builtin.rows.map((entry) => entry.id), ['beta', 'zeta', 'alpha'], + 'group order first, name inside the group'); + assert.deepEqual(builtin.rows.map((entry) => entry.group), ['workflow', 'workflow', 'writing']); + assert.equal(builtin.count, 3); +}); + +test('a user skill with no group still sorts inside the custom section', () => { + const catalog = Projection.parseHostCatalog([ + row('two', {name: 'Beta', level: 'project'}), + row('one', {name: 'Alpha', level: 'user'}) + ]); + const presentation = Projection.present(catalog, '', COMMAND_CATALOG_FILTER_ALL); + assert.deepEqual(presentation.sections.map((section) => section.id), [COMMAND_CATALOG_FILTER_CUSTOM], + 'with no built-ins the custom section is the only one, and it is still a section'); + assert.deepEqual(presentation.sections[0].rows.map((entry) => entry.name), ['Alpha', 'Beta']); + assert.equal(presentation.sections[0].titleKey, Projection.customSectionKey); + assert.equal(presentation.isEmpty, false); + assert.equal(presentation.hasNoMatches, false); +}); + +test('the filters count what each side actually holds', () => { + const catalog = Projection.parseHostCatalog([ + builtinRow('plan', 'workflow'), + builtinRow('review', 'workflow'), + row('shipping', {level: 'project'}) + ]); + const rows = Projection.rows(catalog); + assert.equal(rows.length, 3); + assert.equal(Projection.builtinCount(rows), 2); + assert.equal(Projection.customCount(rows), 1); + + const all = Projection.present(catalog, '', COMMAND_CATALOG_FILTER_ALL); + assert.deepEqual(all.filters.map((filter) => filter.count), [3, 2, 1]); + assert.deepEqual(all.filters.map((filter) => filter.labelKey), [ + 'commands.skills.filterAll', 'commands.skills.filterBuiltin', 'commands.skills.filterCustom' + ]); + + const builtinOnly = Projection.present(catalog, '', COMMAND_CATALOG_FILTER_BUILTIN); + assert.deepEqual(builtinOnly.sections.map((section) => section.id), [COMMAND_CATALOG_FILTER_BUILTIN]); + const customOnly = Projection.present(catalog, '', COMMAND_CATALOG_FILTER_CUSTOM); + assert.deepEqual(customOnly.sections.map((section) => section.id), [COMMAND_CATALOG_FILTER_CUSTOM]); +}); + +test('a search matches the names, keys, groups and descriptions', () => { + const catalog = Projection.parseHostCatalog([ + builtinRow('plan', 'workflow', 'Plan'), + row('shipping', {name: 'Shipping', description: 'Cut a release'}) + ]); + assert.deepEqual( + Projection.present(catalog, 'PLAN', COMMAND_CATALOG_FILTER_ALL) + .sections.map((section) => section.rows.map((entry) => entry.id)), + [['plan']], 'the query is case-insensitive and matches the name'); + assert.deepEqual( + Projection.present(catalog, 'shipping', COMMAND_CATALOG_FILTER_ALL) + .sections.map((section) => section.rows.map((entry) => entry.id)), + [['shipping']], 'the query matches the key'); + assert.deepEqual( + Projection.present(catalog, 'workflow', COMMAND_CATALOG_FILTER_ALL) + .sections.map((section) => section.rows.map((entry) => entry.id)), + [['plan']], 'the query matches the host group'); + assert.deepEqual( + Projection.present(catalog, 'cut a release', COMMAND_CATALOG_FILTER_ALL) + .sections.map((section) => section.rows.map((entry) => entry.id)), + [['shipping']], 'the query matches the description'); + + const nothing = Projection.present(catalog, 'nothing matches this', COMMAND_CATALOG_FILTER_ALL); + assert.deepEqual(nothing.sections, []); + assert.equal(nothing.hasNoMatches, true); + assert.equal(nothing.isEmpty, false, 'a filtered-out catalog is not an empty one'); + assert.deepEqual(nothing.filters.map((filter) => filter.count), [2, 1, 1], + 'the filter counts describe the whole catalog, not the query'); +}); + +test('an empty catalog is distinguishable from a filtered-out one', () => { + const empty = Projection.present(Projection.parseHostCatalog([]), '', COMMAND_CATALOG_FILTER_ALL); + assert.equal(empty.isEmpty, true, 'the host answered with nothing to invoke'); + assert.equal(empty.hasNoMatches, false); + assert.deepEqual(empty.sections, []); + assert.deepEqual(empty.filters.map((filter) => filter.count), [0, 0, 0]); + + const neverRead = Projection.present(undefined, '', COMMAND_CATALOG_FILTER_ALL); + assert.equal(neverRead.isEmpty, true, 'no catalog at all reads as the same empty state'); +}); + +test('the token label the picker hints at is the one the message carries', () => { + // The hint and the message's token are the same string built by the same rule: + // the desktop inserts `[$\u003cname\u003e]`, so a row hinted at its key would promise + // text the message does not contain. + const catalog = Projection.parseHostCatalog([builtinRow('plan', 'workflow', 'Plan')]); + const entry = Projection.rows(catalog)[0]; + assert.equal(entry.name, 'Plan'); + assert.equal(entry.insertHint, CommandPalettePolicy.skillTokenLabel(entry.name)); + assert.equal(entry.insertHint, '[$Plan]'); + assert.equal(entry.isBuiltin, true, 'the row draws its built-in icon from this'); + + const composed = CommandPalettePolicy.composeSubmissionText('fix the login', entry.name); + assert.ok(composed.startsWith(entry.insertHint), + 'the message must open with exactly the token the row hinted at'); + assert.notEqual(entry.insertHint, CommandPalettePolicy.skillTokenLabel(entry.id), + 'the key is not what the user sees, which is why this test compares the name'); +}); + +test('a row the host never named still yields a token that can be sent', () => { + const catalog = Projection.parseHostCatalog([{key: 'plan.sub', selectedForRuntime: true, + effectiveEnabled: true, globallyEnabled: true}]); + const entry = Projection.rows(catalog)[0]; + assert.equal(entry.name, 'plan.sub', 'the key stands in for a missing name'); + assert.equal(entry.insertHint, '[$plan.sub]'); +}); + +test('hostInvoke unwraps the bridge envelope instead of the command value', () => { + // `host_invoke` answers with `{ok, value, error}`; every palette verb parses the + // `value` and treats a refusal as a rejection, which is what makes the first + // catalog read a usable probe. + const hostInvoke = methodBody(managerSource, 'async hostInvoke(command: string, args: Object): Promise'); + assert.match(hostInvoke, /cmd: 'host_invoke'/); + assert.match(hostInvoke, /command: command/); + assert.match(hostInvoke, /args: args/); + assert.match(hostInvoke, /if \(!response\.ok\)/); + assert.match(hostInvoke, /throw new HostInvokeRefusal\(command, response\.error \|\| ''\)/, + 'a refused command must reject as a typed refusal so the caller can tell the host answering from the wire breaking'); + assert.match(hostInvoke, /return response\.value as T/); + assert.match(managerSource, /ok: boolean; value\?: T; error\?: string;/); + assert.match(managerSource, /import \{ HostInvokeRefusal \} from '\.\/HostInvokeRefusal';/); +}); + +test('a refusal carries the host words and a broken wire does not pretend to', () => { + // The type is the whole distinction: `host_invoke` sets `ok: false` when the + // host received the command and declined it, and nothing else on that wire ever + // reached a host. + const refusalSource = fs.readFileSync(path.join(ETS_ROOT, 'services/HostInvokeRefusal.ets'), 'utf8'); + assert.match(refusalSource, /export class HostInvokeRefusal extends Error/); + assert.match(refusalSource, /readonly hostText: string;/); + assert.match(refusalSource, /readonly command: string;/); + assert.match(refusalSource, /export function isHostInvokeRefusal\(err: Object\): boolean \{[\s\S]*?err instanceof HostInvokeRefusal/); + assert.match(refusalSource, /export function hostInvokeRefusalText\(err: Object\): string \{[\s\S]*?hostText/, + 'the host text is kept as it was sent rather than reworded by the connection policy'); +}); + +test('each palette verb sends the arguments its own Rust handler declares', () => { + // Flat camelCase for the skill read (its signature has no `request` wrapper), + // camelCase inside `request` for the two turn verbs, and the snake_case fields + // of the usage request, which is declared without `rename_all`. + const skill = methodBody(managerSource, 'async skillCatalog(query: RemoteSkillCatalogQuery): Promise'); + assert.match(skill, /'get_mode_skill_configs'/); + assert.match(skill, /modeId: query\.modeId/); + assert.match(skill, /forceRefresh: query\.forceRefresh/); + assert.doesNotMatch(skill, /request:/, 'the skill read takes flat arguments'); + assert.match(skill, /CommandCatalogProjection\.parseHostCatalog\(rows\)/); + + const compact = methodBody(managerSource, 'async compactSession(query: RemoteCommandPaletteScope): Promise'); + assert.match(compact, /'compact_session'/); + assert.match(compact, /SessionRequestArgs = \{ request: RemoteSessionManager\.sessionRequest\(query\) \}/); + + const init = methodBody(managerSource, 'async runInitAgentsMd(query: RemoteCommandPaletteScope): Promise'); + assert.match(init, /'run_init_agents_md'/); + assert.match(init, /SessionRequestArgs = \{ request: RemoteSessionManager\.sessionRequest\(query\) \}/); + + const usage = methodBody(managerSource, + 'async sessionUsageReport(query: RemoteCommandPaletteScope): Promise'); + assert.match(usage, /'get_session_usage_report'/); + assert.match(usage, /const request: SessionUsageRequest = \{ session_id: query\.sessionId \}/); + assert.match(usage, /request\.workspace_id = query\.workspaceId/); + assert.match(usage, /request\.workspace_path = query\.workspacePath/); + assert.match(usage, /request\.remote_connection_id = query\.remoteConnectionId/); + assert.match(usage, /request\.remote_ssh_host = query\.remoteSshHost/); + assert.doesNotMatch(usage, /\bsessionId:/, + 'the usage request is declared without `rename_all`, so its fields stay snake_case'); + + const request = methodBody(managerSource, 'private static sessionRequest(query: RemoteCommandPaletteScope): SessionRequest'); + assert.match(request, /const request: SessionRequest = \{ sessionId: query\.sessionId \}/); + assert.match(request, /request\.workspaceId = query\.workspaceId/); + assert.match(request, /request\.workspacePath = query\.workspacePath/); + assert.match(request, /request\.remoteConnectionId = query\.remoteConnectionId/); + assert.match(request, /request\.remoteSshHost = query\.remoteSshHost/); + assert.doesNotMatch(request, /workspace_id/, + 'the two turn verbs take the camelCase struct their handler declares'); +}); + +// Slices one method out of a class body, from its signature to the closing brace +// that sits at the class's own indentation. +function methodBody(source, signature) { + const start = source.indexOf(signature); + assert.notEqual(start, -1, `RemoteSessionManager must keep ${signature}`); + const end = source.indexOf('\n }\n', start); + assert.notEqual(end, -1, `${signature} must stay a closed method`); + return source.slice(start, end); +} diff --git a/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs new file mode 100644 index 0000000000..b6bd0dc663 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs @@ -0,0 +1,570 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +function load(relativePath, stubs = {}) { + const source = fs.readFileSync(path.join(__dirname, '../..', relativePath), 'utf8'); + const js = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + experimentalDecorators: true + } + }).outputText; + const exported = {}; + // ArkTS decorators are globals in the framework, and `__decorate` keeps the + // decorated member when a decorator returns nothing — which is what `@Trace` + // does. Returning the target instead would freeze a copy of it onto the + // prototype and shadow every field the constructor never assigned. + const decorator = () => undefined; + new Function('require', 'exports', 'ObservedV2', 'Trace', js)( + (id) => stubs[id] ?? {}, exported, decorator, decorator); + return exported; +} + +function read(relativePath) { + return fs.readFileSync(path.join(__dirname, '../..', relativePath), 'utf8'); +} + +// Reads one member method or @Builder out of a component, so an assertion can +// name the piece it is about instead of counting occurrences in the file. +function memberBody(source, name) { + const signature = new RegExp(`^ (?:private |public |protected |async )*${name}\\s*\\([^)]*\\)[^{]*\\{`, 'm'); + const match = signature.exec(source); + assert.notEqual(match, null, `the component must keep its ${name}() member`); + const start = match.index; + const braceStart = start + match[0].length - 1; + let depth = 0; + for (let index = braceStart; index < source.length; index++) { + if (source[index] === '{') { + depth += 1; + } else if (source[index] === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(start, index + 1); + } + } + } + throw new Error(`unbalanced ${name}() body`); +} + +const CLOSING_DELIMITER = { '(': ')', '{': '}', '[': ']' }; + +// Reads the index of the delimiter that closes the `(`, `{` or `[` at `open`, +// skipping strings and line comments so a label, a path or a nested call cannot +// unbalance the scan. +function matchEnd(source, open) { + const close = CLOSING_DELIMITER[source[open]]; + assert.notEqual(close, undefined, `not an opening delimiter: ${source[open]}`); + let depth = 0; + for (let index = open; index < source.length; index++) { + const char = source[index]; + if (char === "'" || char === '"' || char === '`') { + for (index += 1; index < source.length && source[index] !== char; index++) { + if (source[index] === '\\') { + index += 1; + } + } + continue; + } + if (char === '/' && source[index + 1] === '/') { + index = source.indexOf('\n', index); + if (index === -1) { + throw new Error('unterminated line comment'); + } + continue; + } + if (char === source[open]) { + depth += 1; + } else if (char === close) { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + throw new Error(`unbalanced ${source[open]}`); +} + +// Every builder node in a source file with its own modifier chain collapsed onto +// one line, so an assertion can name the node and every fill/content pair on it. +// A capitalized call is a node wherever it appears; a `.modifier` is not, because +// the `[^\w.]` guard keeps a chained call out of the marker. The chain stops at +// the node's closing paren and skips its builder body, so a label call such as +// RemoteI18n.t('x') neither leaks into the chain nor merges two nodes. +function modifierChains(source) { + const chains = []; + const marker = /(^|[^\w.])([A-Z]\w*)\(/g; + for (let match = marker.exec(source); match !== null; match = marker.exec(source)) { + const afterNode = marker.lastIndex - 1; + let cursor = matchEnd(source, afterNode) + 1; + let chain = match[2]; + for (;;) { + while (/\s/.test(source[cursor] ?? '')) { + cursor += 1; + } + if (source[cursor] === '{') { + cursor = matchEnd(source, cursor) + 1; + continue; + } + const modifier = /^\.(\w+)\(/.exec(source.slice(cursor)); + if (modifier === null) { + break; + } + const open = cursor + modifier[0].length - 1; + const close = matchEnd(source, open); + chain += `.${modifier[1]}${source.slice(open, close + 1).replace(/\s+/g, ' ')}`; + cursor = close + 1; + } + chains.push(chain); + // A node mounted inside a builder body is a node of its own, so the scan + // resumes at the node opening rather than after its chain. + marker.lastIndex = afterNode + 1; + } + return chains; +} + +function buttonChains(source) { + return modifierChains(source).filter((chain) => chain.startsWith('Button')); +} + +// The arguments of every `.name(...)` call on a collapsed chain. +function callArguments(chain, name) { + const args = []; + const marker = new RegExp(`\\.${name}\\(`, 'g'); + for (let match = marker.exec(chain); match !== null; match = marker.exec(chain)) { + const open = match.index + match[0].length - 1; + const close = matchEnd(chain, open); + args.push(chain.slice(open + 1, close)); + marker.lastIndex = close + 1; + } + return args; +} + +// The component layer's sources, keyed by their path under pages/components, so +// a rule about theme pairs covers every component rather than the one file that +// happened to expose the defect. +function componentSources() { + const root = path.join(__dirname, '../..', 'entry/src/main/ets/pages/components'); + const sources = new Map(); + const walk = (directory, prefix) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + walk(path.join(directory, entry.name), `${prefix}${entry.name}/`); + } else if (entry.name.endsWith('.ets')) { + sources.set(`${prefix}${entry.name}`, fs.readFileSync(path.join(directory, entry.name), 'utf8')); + } + } + }; + walk(root, ''); + return sources; +} + +const policy = load('entry/src/main/ets/services/CommandPalettePolicy.ets'); +const { + CommandPalettePolicy: Policy, + CommandPaletteAvailability, + CommandPaletteCatalogStatus, + CommandPaletteChipKind, + CommandPaletteChipState, + CommandPaletteProbeFailure +} = policy; +// The page state and the palette state it owns, loaded for their behavior: the +// draft-ownership test below drives the real method rather than its source. +const paletteState = load('entry/src/main/ets/pages/state/CommandPaletteState.ets', { + '../../services/CommandPalettePolicy': policy, + '../policy/UsageReportPresentationPolicy': { + UsageReportPresentationPolicy: {present: () => ({sections: [], partial: false})} + } +}); +const pageState = load('entry/src/main/ets/pages/state/RemotePageState.ets'); +const composerSource = read('entry/src/main/ets/pages/components/ComposerBar.ets'); +const conversationSource = read('entry/src/main/ets/pages/components/ConversationView.ets'); +const skillSheetSource = read('entry/src/main/ets/pages/components/SkillPickerSheet.ets'); +const chipRowSource = read('entry/src/main/ets/pages/components/CommandChipRow.ets'); +const toolsPanelSource = read('entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets'); +const themeSource = read('entry/src/main/ets/pages/components/Theme.ets'); +const transcriptSource = read('entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets'); + +test('the composer takes the token and the row as inputs, and gives the removal back', () => { + // Rule 12 keeps the surface to grouped state plus typed events; the token is + // two projected strings — the pick's key and the name it writes — and the row's + // presence is one boolean. + for (const declaration of [ + '@Param skillId: string = \'\';', + '@Param skillLabel: string = \'\';', + '@Param commandRowActive: boolean = false;', + '@Event onRemoveSkillToken: () => void' + ]) { + assert.ok(composerSource.includes(declaration), `the composer must declare ${declaration}`); + } + assert.match(composerSource, /this\.onRemoveSkillToken\(\);/, + 'the token chip must have exactly one way back to the conversation'); +}); + +test('the token draws the exact text the message will carry', () => { + const strip = memberBody(composerSource, 'CommandTokenStrip'); + assert.match(strip, /CommandPalettePolicy\.skillTokenLabel\(this\.skillLabel\)/, + 'the chip and the wire form must not be able to drift apart'); + assert.equal(strip.includes("RemoteI18n.t('commands.skillToken')"), true, + 'the chip needs an accessibility label of its own'); +}); + +test('the token strip only mounts when a skill is picked', () => { + const composer = memberBody(composerSource, 'AdaptiveComposer'); + const site = composer.indexOf('this.CommandTokenStrip()'); + assert.notEqual(site, -1, 'the composer must render the token strip'); + assert.match(composer.slice(Math.max(0, site - 120), site), + /if \(this\.hasSkillToken\(\)\) \{/, + 'an empty pick must not leave an empty row above the field'); + assert.ok(composer.indexOf('this.SelectedImageStrip()') > site, + 'the token keeps the attachment strip in its established place'); +}); + +test('the token is part of the composer card, so the card grows to hold it', () => { + // The strip lives inside the card; a card sized for the field alone would clip + // it, and the pill radius only reads as a pill while the card is one row tall. + const height = memberBody(composerSource, 'composerHeight'); + assert.match(height, /this\.hasSkillToken\(\) \? COMPOSER_TOKEN_STRIP_BLOCK : 0/, + 'the token row must be measured into the composer height'); + const radius = memberBody(composerSource, 'composerRadius'); + assert.match(radius, /this\.hasSkillToken\(\)/, + 'a card taller than the field must not keep the pill radius'); + // The label is the token's own text, so the strip cannot mount over an empty one. + assert.match(memberBody(composerSource, 'hasSkillToken'), /return this\.skillLabel\.length > 0;/); +}); + +test('a bare goal withholds Send without changing the local surface', () => { + // The row is the only thing that can produce a bare `/goal`, and that is a + // command still waiting for its argument rather than a message. + const action = memberBody(composerSource, 'primaryAction'); + assert.match(action, /this\.commandRowActive &&\s*\n\s*CommandPalettePolicy\.blocksSend\(this\.inputText\)/, + 'the composer must withhold Send for an argument-less host command'); + assert.match(action, /return ComposerPrimaryAction\.SendBlocked;/); +}); + +test('the token is prepended on send, and appended after a host command', () => { + assert.equal(Policy.composeSubmissionText('fix login', 'Plan'), '[$Plan] fix login'); + assert.equal(Policy.composeSubmissionText('/goal fix login', 'Plan'), '/goal fix login [$Plan]'); + assert.equal(Policy.composeSubmissionText('', 'Plan'), '[$Plan]'); + assert.equal(Policy.composeSubmissionText('fix login', ''), 'fix login'); +}); + +test('the send path is where the token becomes content', () => { + const send = memberBody(transcriptSource, 'sendRemoteMessage'); + assert.match(send, /CommandPalettePolicy\.composeSubmissionText\(rawText, skillLabel\)/, + 'the draft the field holds must travel with the token beside it'); + assert.match(send, /const skillLabel = this\.remote\.commandPalette\.skillLabel;/, + 'the token is built from the skill name, which is what the desktop inserts'); + assert.match(send, /if \(CommandPalettePolicy\.blocksSend\(rawText\)\) \{\s*\n\s*return;/, + 'a bare `/goal` must stop at the send path even if it got there another way'); +}); + +test('an accepted send consumes the token and a refused one puts it back', () => { + // The token travels with the draft's own ownership rules: consumed by the + // commit the host accepted, restored by the rollback that it did not. The key + // and the name are one pick, so both halves return together. + const state = read('entry/src/main/ets/pages/state/RemotePageState.ets'); + const prepare = state.slice(state.indexOf('prepareComposerSubmission(')); + assert.match(prepare, /const skillId = this\.commandPalette\.skillId;/); + assert.match(prepare, /const skillLabel = this\.commandPalette\.skillLabel;/); + assert.match(prepare, /this\.commandPalette\.clearSkill\(\);/); + assert.match(prepare, /this\.commandPalette\.setSkill\(skillId, skillLabel\);/, + 'a rollback must return the token the commit would have consumed'); +}); + +/** + * The page state's draft-ownership method, driven directly. + * + * `prepareComposerSubmission` is the one place that consumes and restores the + * composer's local picks, and it only reaches into the conversation's own + * submission and the palette state it owns, so the test can call the production + * method with those two collaborators and no ArkUI around it. + */ +function draftOwnership() { + const inner = {commits: 0, rollbacks: 0}; + const page = Object.create(pageState.RemotePageState.prototype); + page.conversation = { + prepareComposerSubmission: () => ({ + commit: () => { + inner.commits += 1; + }, + rollback: () => { + inner.rollbacks += 1; + } + }) + }; + page.commandPalette = new paletteState.CommandPaletteState(); + return {page, inner}; +} + +test('a sent goal stops being pending, and a refused one goes back to pending', () => { + // The lit chip says the argument is still missing. Leaving it lit over a + // `/goal` the host just accepted would contradict the conversation behind it, + // and a send the host refused must not take the chip away with it. + const accepted = draftOwnership(); + accepted.page.commandPalette.setSkill('plan', 'Plan'); + accepted.page.commandPalette.setGoalPending(true); + + const submission = accepted.page.prepareComposerSubmission('session-a', '/goal ship it', []); + submission.commit(); + assert.equal(accepted.inner.commits, 1, 'the draft it was given is the draft it commits'); + assert.equal(accepted.page.commandPalette.goalPending, false, 'the accepted send consumed the chip'); + assert.equal(accepted.page.commandPalette.skillId, '', 'and the token with it'); + assert.equal(accepted.page.commandPalette.skillLabel, ''); + + const refused = draftOwnership(); + refused.page.commandPalette.setSkill('plan', 'Plan'); + refused.page.commandPalette.setGoalPending(true); + refused.page.prepareComposerSubmission('session-a', '/goal ship it', []).rollback(); + assert.equal(refused.inner.rollbacks, 1, 'a refused send gives the draft back'); + assert.equal(refused.page.commandPalette.goalPending, true, 'and the lit chip with it'); + assert.equal(refused.page.commandPalette.skillId, 'plan'); + assert.equal(refused.page.commandPalette.skillLabel, 'Plan', + 'the restored token is the one that was taken, name included'); +}); + +test('a goal with no skill still stops being pending when it is sent', () => { + // The two picks are consumed independently: the token is optional, the chip is + // not, and a goal send carries no token at all. + const ownership = draftOwnership(); + ownership.page.commandPalette.setGoalPending(true); + ownership.page.prepareComposerSubmission('session-a', '/goal ship it', []).commit(); + assert.equal(ownership.page.commandPalette.goalPending, false); + assert.equal(ownership.page.commandPalette.skillId, ''); +}); + +test('the goal chip prefills the draft and takes it back on a second tap', () => { + const dispatch = transcriptSource.slice( + transcriptSource.indexOf('async dispatchCommandPalette('), + transcriptSource.indexOf('private commandPaletteModeId()')); + assert.match(dispatch, + /CommandPalettePolicy\.toggleGoal\(this\.remote\.chatInput, palette\.goalPending\)/); + assert.match(dispatch, /this\.remote\.setChatInput\(toggled\.draft\);/); + assert.match(dispatch, /palette\.setGoalPending\(toggled\.pending\);/); + + const first = Policy.toggleGoal('', false); + assert.equal(first.draft, '/goal '); + assert.equal(first.pending, true); + assert.equal(first.draft, Policy.goalPrefillText(), 'the prefill is the command and one space'); + const takenBack = Policy.toggleGoal(first.draft, first.pending); + assert.equal(takenBack.draft, '', 'the second tap takes the untouched prefill back off'); + assert.equal(takenBack.pending, false); + + const occupied = Policy.toggleGoal('fix login', false); + assert.equal(occupied.draft, 'fix login', 'one tap must never overwrite typed text'); + assert.equal(occupied.pending, false); +}); + +test('a host command with no argument is not sendable from the row either', () => { + assert.equal(Policy.blocksSend('/goal'), true); + const chips = Policy.chips(new CommandPaletteAvailability( + CommandPaletteCatalogStatus.Ready, CommandPaletteProbeFailure.None, true, false, '', true)); + const goal = chips.find((entry) => entry.kind === CommandPaletteChipKind.Goal); + assert.equal(goal.state, CommandPaletteChipState.Pending, + 'the lit chip is the row saying the argument is still missing'); + assert.equal(Policy.blocksSend('/goal fix login'), false); +}); + +test('the conversation keeps one sheet binding and names the sheet instead', () => { + // Found on device: a second `bindSheet` on the same node is dropped, and with + // it every sheet that node was meant to open. All three of the conversation's + // sheets therefore take turns on the root's single binding. + const bindings = [...conversationSource.matchAll(/\.bindSheet\(/g)]; + assert.equal(bindings.length, 1, + 'a node carries exactly one sheet binding, so a second one costs every sheet on it'); + assert.match(conversationSource, /\.bindSheet\(\$\$this\.sheetPresented, this\.ActiveSheet\(\)/); + for (const kind of ["'full_text'", "'skills'", "'usage'"]) { + assert.ok(conversationSource.includes(`= ${kind};`), `the sheet kind ${kind} must stay named`); + } + const surface = memberBody(conversationSource, 'ActiveSheet'); + assert.match(surface, /if \(this\.activeSheet === CONVERSATION_SHEET_FULL_TEXT\)/); + assert.match(surface, /else if \(this\.activeSheet === CONVERSATION_SHEET_SKILLS\)/); + assert.match(surface, /else if \(this\.activeSheet === CONVERSATION_SHEET_USAGE\)/); + const options = memberBody(conversationSource, 'activeSheetOptions'); + assert.match(options, /this\.skillPickerSheetPlacement/, + 'each sheet keeps its own placement through the shared binding'); + assert.match(options, /this\.usageReportSheetPlacement/); +}); + +test('the picker insets its search box from a wrapper, never with a margin', () => { + // Found on device: the bordered box rendered flush against the sheet's edges. + // A 100%-width child that insets itself with a horizontal margin pushes its own + // border past the parent, so the margin lands outside the frame the user sees + // and the box bleeds to the sheet edges. The inset has to belong to a + // full-width wrapper, with the bordered box filling the width left inside it. + const box = memberBody(skillSheetSource, 'SearchBox'); + assert.ok(!box.includes('.margin('), + 'the bordered search box must not combine width 100% with a horizontal margin'); + assert.match(box, + /\.width\('100%'\)\s*\n\s*\.padding\(\{ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING, top: 12 \}\)\s*\n\s*\}/, + 'the full-width wrapper is the node that owns the sheet inset, from the sheet\'s own constant'); + assert.equal(box.split('SHEET_HORIZONTAL_PADDING').length - 1, 2, + 'the wrapper insets both sides and nothing else in the builder does'); + + // The box inside it keeps the search geometry and the border, and fills the + // content width the wrapper leaves rather than the sheet's full width. + const bordered = box.slice(box.indexOf('Row({ space: 8 })')); + assert.match(bordered, + /\.width\('100%'\)\s*\n\s*\.height\(SKILL_SEARCH_HEIGHT\)\s*\n\s*\.padding\(\{ left: 12, right: 12 \}\)/, + 'the bordered box fills the width the wrapper left for it'); + assert.match(bordered, /\.border\(\{ width: 1, color: LINE \}\)\s*\n\s*\.borderRadius\(SKILL_SEARCH_RADIUS\)/, + 'the box keeps the border and radius the design calls for'); + assert.equal(bordered.split('.border(').length - 1, 1, + 'one bordered box in the builder, and the wrapper is not a second one'); + // A parent's modifiers are written after the child they wrap, so the inset + // coming last is what makes it the wrapper's own padding and not the box's. + assert.ok(box.indexOf('.padding({ left: SHEET_HORIZONTAL_PADDING') > + box.indexOf('.border({ width: 1, color: LINE })'), + 'the inset wraps the bordered box rather than sitting on it'); +}); + +test('the conversation hands the composer both inputs from one projection', () => { + const composer = conversationSource.slice(conversationSource.indexOf('ComposerBar({')); + assert.match(composer.slice(0, composer.indexOf('})') + 2), + /skillId: this\.commandPalette\.skillId,/); + assert.match(composer.slice(0, composer.indexOf('})') + 2), + /commandRowActive: this\.commandPalette\.visible,/); + assert.match(conversationSource, + /onRemoveSkillToken: \(\) => \{\s*\n\s*this\.onIntent\(ConversationIntents\.commandPalette\(\s*\n\s*new CommandPaletteRequest\(CommandPaletteAction\.ClearSkill\)\)\);/, + 'removing the token must leave as the one typed intent'); +}); + +test('the row mounts once, above the composer, for every posture', () => { + // Both the compact overlay and the half-folded pane build their composer from + // the same Composer() subtree, so mounting the row there covers all of them. + const sites = [...conversationSource.matchAll(/CommandChipRow\(\{/g)]; + assert.equal(sites.length, 1, 'the row must be mounted exactly once'); + const composer = conversationSource.slice( + conversationSource.indexOf(' Composer() {'), + conversationSource.indexOf('private onCommandPaletteRequest(')); + assert.ok(composer.indexOf('CommandChipRow({') < composer.indexOf('ComposerBar({'), + 'the chips sit above the composer'); + assert.match(composer, /if \(this\.commandPalette\.visible\) \{/, + 'the row renders only where it has a session to act on'); + + const composerSites = [...conversationSource.matchAll(/this\.Composer\(\)/g)]; + assert.ok(composerSites.length >= 2, + 'the same Composer() subtree is what the compact and half-folded panes both build'); +}); + +test('an ink-filled chip takes its content from the ink-inverse, not from the action color', () => { + // Seen on device in dark mode: the selected filter chip painted white text on a + // near-white INK chip, because CONTENT_ON_ACTION is pinned to #FFFFFF in both + // themes while INK inverts. The two ink surfaces are the picker's selected + // filter chip and the palette row's pending chip, and both must resolve their + // content through the token that inverts with INK. + assert.match(themeSource, + /export const CONTENT_ON_INK: ResourceColor = \$r\('app\.color\.page_bg'\);/, + 'the ink inverse must be the theme-aware page background, the resource INK inverts against'); + assert.match(themeSource, + /export const INK: ResourceColor = \$r\('app\.color\.ink'\);/, + 'the pin is only meaningful while INK stays the theme-inverting surface color'); + + // The selected filter chip: INK fill, so its label cannot come from the action color. + const filterChip = skillSheetSource.slice( + skillSheetSource.indexOf('Text(`${RemoteI18n.t(filter.labelKey)} ${filter.count}`)'), + skillSheetSource.indexOf('.onClick(')); + assert.match(filterChip, + /\.fontColor\(this\.filterId === filter\.id \? CONTENT_ON_INK : MUTED\)/, + 'the selected filter chip label takes the ink inverse'); + assert.match(filterChip, + /\.backgroundColor\(this\.filterId === filter\.id \? INK : SOFT\)/, + 'and the fill it contrasts with is still INK'); + assert.ok(!filterChip.includes('CONTENT_ON_ACTION'), + 'the action color does not invert with INK, so it cannot word an ink chip'); + + // The palette row's pending chip: same INK fill through chipBackgroundColor. + const background = memberBody(chipRowSource, 'chipBackgroundColor'); + assert.match(background, /chip\.state === CommandPaletteChipState\.Pending \? INK : SOFT/, + 'a pending chip is the row\'s ink surface'); + for (const member of ['chipLabelColor', 'chipGlyphColor']) { + const body = memberBody(chipRowSource, member); + assert.match(body, /if \(chip\.state === CommandPaletteChipState\.Pending\) return CONTENT_ON_INK;/, + `${member}() must take the ink inverse for the pending state`); + assert.ok(!body.includes('CONTENT_ON_ACTION'), + `${member}() must not borrow the action color for a chip filled with INK`); + } + + // Both files must carry the import, or the token above is a name nothing defines. + for (const [name, text] of [['SkillPickerSheet', skillSheetSource], ['CommandChipRow', chipRowSource]]) { + const themeImport = /import \{([\s\S]*?)\} from '\.\/Theme';/.exec(text); + assert.notEqual(themeImport, null, `${name} must import from the theme`); + assert.ok(/\bCONTENT_ON_INK\b/.test(themeImport[1]), + `${name} must import CONTENT_ON_INK from the theme`); + assert.ok(!/\bCONTENT_ON_ACTION\b/.test(themeImport[1]), + `${name} no longer needs CONTENT_ON_ACTION, which will not invert with an ink fill`); + } +}); + +test('the workspace tools panel words its ink-filled actions with the ink inverse', () => { + // The same root cause as the chip pin above, on the other ink surfaces seen on + // device in dark mode: the file-action submit, the editor's save and the + // terminal empty state's create button all fill with INK, so white-on-near-white + // is what CONTENT_ON_ACTION produces there. Every ink fill in this panel has to + // resolve its content through the token that inverts with INK. + const inkFilled = buttonChains(toolsPanelSource) + .filter((chain) => chain.includes('.backgroundColor(INK)')); + assert.equal(inkFilled.length, 3, + 'the three ink-filled actions, one per panel, are what this pin covers'); + for (const chain of inkFilled) { + assert.match(chain, /\.fontColor\(CONTENT_ON_INK\)/, + `an ink-filled action must take its content from the ink inverse: ${chain.slice(0, 72)}`); + assert.ok(!chain.includes('CONTENT_ON_ACTION'), + 'the action color does not invert with INK, so it cannot word an ink-filled button'); + } + + const themeImport = /import \{([\s\S]*?)\} from '\.\/Theme';/.exec(toolsPanelSource); + assert.notEqual(themeImport, null, 'the panel must import from the theme'); + assert.ok(/\bCONTENT_ON_INK\b/.test(themeImport[1]), + 'the panel must import CONTENT_ON_INK from the theme'); + assert.ok(!/\bCONTENT_ON_ACTION\b/.test(themeImport[1]), + 'the panel no longer needs CONTENT_ON_ACTION, which will not invert with an ink fill'); +}); + +test('no ink-filled surface in the component layer is worded with the action color', () => { + // The class-wide form of the pins above. content_on_action is #FFFFFF in both + // themes, so it only ever words a fill that is also theme-stable — the action + // and accent surfaces. INK inverts, so a node painted with INK has to resolve + // its content through a token that inverts with it: CONTENT_ON_INK, or the + // page/card surfaces the same way. The palette chip, the workspace tools panel + // and the sidebar's browse confirm button were three sightings of one defect, + // and this is the net that has to catch the fourth. + const inkInverseContent = ['CONTENT_ON_INK', 'CARD', 'PAGE_BG']; + const violations = []; + let inkFilled = 0; + + for (const [name, source] of componentSources()) { + for (const chain of new Set(modifierChains(source))) { + const fills = callArguments(chain, 'backgroundColor'); + if (!fills.some((fill) => /\bINK\b/.test(fill))) { + continue; + } + inkFilled += 1; + // The fill is INK, so the action color cannot word or glyph this node in + // either theme — whichever arm of a state ternary it sits in. + if (/\bCONTENT_ON_ACTION\b/.test(chain)) { + violations.push(`${name}: the action color cannot word an ink fill: ${chain}`); + } + // A node that reads its content colour in place has to name an inverting + // token; a ternary or a glyph list is read at the node that owns it. + for (const content of callArguments(chain, 'fontColor')) { + if (/[?[]/.test(content)) { + continue; + } + if (!inkInverseContent.includes(content.trim())) { + violations.push(`${name}: ${content.trim()} does not invert with an ink fill: ${chain}`); + } + } + } + } + + assert.deepEqual(violations, [], + `an ink-filled surface must take its content from the ink inverse:\n${violations.join('\n')}`); + // An empty tree, a broken scan or a renamed token would all leave the net above + // passing over nothing, so it has to keep seeing the ink fills it guards. + assert.ok(inkFilled >= 4, + `the scan must still see the component layer's ink fills (saw ${inkFilled})`); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/command-palette-controller.test.cjs b/src/apps/mobile/harmonyos/tools/tests/command-palette-controller.test.cjs new file mode 100644 index 0000000000..1e10f532e6 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-controller.test.cjs @@ -0,0 +1,188 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +// Loads an `.ets` module with the imports it needs supplied by the caller, so a +// test can drive production classes whose neighbours are ArkUI-bound. +function load(relativePath, stubs = {}) { + const source = fs.readFileSync(path.join(__dirname, '../..', relativePath), 'utf8'); + const js = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + experimentalDecorators: true + } + }).outputText; + const exported = {}; + // ArkTS decorators are globals in the framework, and `__decorate` keeps the + // decorated member when a decorator returns nothing — which is what `@Trace` + // does. A class decorator returning nothing keeps its class for the same + // reason, so one stub serves both. + const decorator = () => undefined; + new Function('require', 'exports', 'ObservedV2', 'Trace', js)( + (id) => stubs[id] ?? {}, exported, decorator, decorator); + return exported; +} + +const policy = load('entry/src/main/ets/services/CommandPalettePolicy.ets'); +const refusals = load('entry/src/main/ets/services/HostInvokeRefusal.ets'); +const { + CommandPaletteCatalogStatus, + CommandPaletteChipKind, + CommandPaletteChipState, + CommandPaletteProbeFailure +} = policy; + +const controllerModule = load('entry/src/main/ets/services/RemoteCommandPaletteController.ets', { + './CommandPalettePolicy': policy, + './HostInvokeRefusal': refusals, + './ConnectionErrorPolicy': { + // Only reachable for a non-refusal failure, where the app words the + // connection problem itself. + ConnectionErrorPolicy: {errorText: (err) => (err instanceof Error ? err.message : String(err))} + }, + './RemoteLogger': {RemoteLogger: {info: () => {}, warn: () => {}}} +}); +const { RemoteCommandPaletteController, CommandPaletteProbe } = controllerModule; + +// The production page state, minus the ArkUI decorators its class carries. +const stateModule = load('entry/src/main/ets/pages/state/CommandPaletteState.ets', { + '../../services/CommandPalettePolicy': policy, + '../policy/UsageReportPresentationPolicy': { + UsageReportPresentationPolicy: {present: () => ({sections: [], partial: false})} + } +}); +const { CommandPaletteState, CommandPalettePresentation } = stateModule; + +/** + * A page and a controller wired the way the composition wires them. + * + * `CommandPaletteState` is what the conversation reads and the controller is + * what owns the probe's answer, so a test can see exactly what a re-entry paints. + */ +function fixture(options = {}) { + const state = new CommandPaletteState(); + const calls = {catalog: 0, verbs: []}; + const client = { + skillCatalog: async () => { + calls.catalog += 1; + if (options.failCatalog) { + throw options.failCatalog; + } + return {skills: [{id: 'plan', name: 'Plan'}]}; + }, + compactSession: async () => 'ok', + runInitAgentsMd: async () => 'ok', + sessionUsageReport: async () => undefined + }; + const controller = new RemoteCommandPaletteController(client, { + onCatalogLoading: () => state.setCatalogLoading(), + onCatalogReady: (_sessionId, catalog) => state.setCatalogReady(catalog), + onCatalogFailed: (_sessionId, errorText, failure) => state.setCatalogFailed(errorText, failure), + onCommandStarted: () => {}, + onCommandSettled: () => {}, + onCommandNoticeExpired: () => {}, + onUsageLoading: () => {}, + onUsageReady: () => {}, + onUsageFailed: () => {} + }); + return {state, controller, calls}; +} + +// The row as the conversation view projects it: the chips a tap may act on. +function chips(fixtureState) { + return CommandPalettePresentation.project(fixtureState, true, false).chips; +} + +function chip(chipsShown, kind) { + const found = chipsShown.find((entry) => entry.kind === kind); + assert.ok(found, `the row must carry the ${kind} chip`); + return found; +} + +const isSessionReachable = () => true; + +test('re-entering a probed session repaints its row from the answer already in hand', async () => { + // The controller outlives the conversation's state, so a session that is left + // and re-entered would otherwise draw a waiting row over an answer the + // controller still holds — and, before this, a row waiting forever. + const {state, controller, calls} = fixture(); + await controller.loadCatalog('session-a', 'agentic', false, isSessionReachable); + assert.equal(calls.catalog, 1); + assert.equal(state.catalogStatus, CommandPaletteCatalogStatus.Ready); + assert.equal(chip(chips(state), CommandPaletteChipKind.Skills).state, CommandPaletteChipState.Ready); + + // Leaving the conversation drops every conversation-scoped value it owned. + state.reset(); + assert.equal(state.catalogStatus, CommandPaletteCatalogStatus.Idle); + assert.equal(state.catalog, undefined); + assert.equal(chip(chips(state), CommandPaletteChipKind.Skills).state, CommandPaletteChipState.Dimmed, + 'a waiting chip is what the page shows before anything answers'); + + await controller.loadCatalog('session-a', 'agentic', false, isSessionReachable); + assert.equal(calls.catalog, 1, 'the answer in hand is not read again'); + assert.equal(state.catalogStatus, CommandPaletteCatalogStatus.Ready, 'the row is not left waiting'); + assert.ok(state.catalog, 'the cached catalog is what the picker lists'); + assert.equal(state.catalog.skills.length, 1); + assert.equal(chip(chips(state), CommandPaletteChipKind.Skills).state, CommandPaletteChipState.Ready); + assert.equal(chip(chips(state), CommandPaletteChipKind.Skills).badgeKey, ''); + assert.equal(controller.catalogProbeFor('session-a'), CommandPaletteProbe.Probed); +}); + +test('a failed read is republished as the same failure, not as a fresh verdict', async () => { + // A timeout that happened once must not come back as an upgrade prompt, and a + // host that named the version gap must not come back as a timeout. + for (const [thrown, expected, badge] of [ + [new refusals.HostInvokeRefusal('get_mode_skill_configs', + "command 'get_mode_skill_configs' is unknown to this OpenBitFun desktop peer host version; " + + 'upgrade the peer host or check the command name'), + CommandPaletteProbeFailure.VersionGap, policy.CommandPalettePolicy.upgradeBadgeKey], + [new refusals.HostInvokeRefusal('get_mode_skill_configs', 'workspace not found'), + CommandPaletteProbeFailure.Refused, ''], + [new Error('request timed out'), CommandPaletteProbeFailure.Unreachable, ''] + ]) { + const {state, controller, calls} = fixture({failCatalog: thrown}); + await controller.loadCatalog('session-a', 'agentic', false, isSessionReachable); + assert.equal(state.catalogFailure, expected, `${expected}: first read`); + state.reset(); + + await controller.loadCatalog('session-a', 'agentic', false, isSessionReachable); + assert.equal(calls.catalog, 1, `${expected}: no second read`); + assert.equal(state.catalogStatus, CommandPaletteCatalogStatus.Failed, `${expected}: re-entry`); + assert.equal(state.catalogFailure, expected); + assert.equal(state.catalogErrorText.length > 0, true, `${expected}: the host text survives`); + assert.equal(chip(chips(state), CommandPaletteChipKind.Skills).badgeKey, badge); + assert.equal(controller.catalogFailureFor('session-a'), expected); + } +}); + +test('a forced re-check reads again and clears the previous verdict', async () => { + // 「重新检查」 is the user's own retry after upgrading the desktop, so it must + // never be served from the cache it exists to replace. + const {state, controller, calls} = fixture({ + failCatalog: new refusals.HostInvokeRefusal('get_mode_skill_configs', 'Command not found: get_mode_skill_configs') + }); + await controller.loadCatalog('session-a', 'agentic', false, isSessionReachable); + assert.equal(state.catalogStatus, CommandPaletteCatalogStatus.Failed); + + const recovered = fixture(); + await recovered.controller.loadCatalog('session-a', 'agentic', false, isSessionReachable); + assert.equal(recovered.calls.catalog, 1); + + // A forced read on the failing fixture goes out again and still fails. + await controller.loadCatalog('session-a', 'agentic', true, isSessionReachable); + assert.equal(calls.catalog, 2, 'a forced read is not served from cache'); + assert.equal(state.catalogStatus, CommandPaletteCatalogStatus.Failed); +}); + +test('an answer for a session the user left is dropped rather than painted', async () => { + // The fence is the last thing between a slow host and one session's catalog + // appearing over another's conversation. + const {state, controller} = fixture(); + await controller.loadCatalog('session-a', 'agentic', false, () => false); + assert.equal(state.catalogStatus, CommandPaletteCatalogStatus.Idle, + 'a read whose session is gone must not repaint anything'); + assert.equal(controller.catalogProbeFor('session-a'), CommandPaletteProbe.Unprobed); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/command-palette-policy.test.cjs b/src/apps/mobile/harmonyos/tools/tests/command-palette-policy.test.cjs new file mode 100644 index 0000000000..e48703ce7d --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-policy.test.cjs @@ -0,0 +1,422 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +function load(relativePath) { + const source = fs.readFileSync(path.join(__dirname, '../..', relativePath), 'utf8'); + const js = ts.transpileModule(source, { + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS} + }).outputText; + const exported = {}; + new Function('require', 'exports', js)(() => ({}), exported); + return exported; +} + +const { + CommandPalettePolicy: Policy, + CommandPaletteAvailability, + CommandPaletteCatalogStatus, + CommandPaletteChipKind, + CommandPaletteChipState, + CommandPaletteNoticeKind, + CommandPaletteProbeFailure, + commandPaletteProbeReady +} = load('entry/src/main/ets/services/CommandPalettePolicy.ets'); + +// A session whose skill read answered and which is idle: the row's baseline. +function available(overrides = {}) { + return new CommandPaletteAvailability( + overrides.probeStatus ?? CommandPaletteCatalogStatus.Ready, + overrides.probeFailure ?? CommandPaletteProbeFailure.None, + overrides.hasSession ?? true, + overrides.isBusy ?? false, + overrides.runningCommand ?? '', + overrides.goalPending ?? false + ); +} + +// The session verbs, which no skill read may gate. +const HOST_VERBS = [CommandPaletteChipKind.Compact, CommandPaletteChipKind.Init, + CommandPaletteChipKind.Usage]; + +function chip(policyChips, kind) { + const found = policyChips.find((entry) => entry.kind === kind); + assert.ok(found, `the row must always carry the ${kind} chip`); + return found; +} + +test('an answered read enables the picker and leaves the verbs alone', () => { + const chips = Policy.chips(available()); + for (const kind of [CommandPaletteChipKind.Compact, CommandPaletteChipKind.Init, + CommandPaletteChipKind.Usage, CommandPaletteChipKind.Skills]) { + assert.equal(chip(chips, kind).state, CommandPaletteChipState.Ready, `${kind} must be ready`); + assert.equal(chip(chips, kind).runsAction, true, `${kind} must reach the host`); + assert.equal(chip(chips, kind).badgeKey, '', `${kind} must not carry the upgrade badge`); + assert.equal(chip(chips, kind).reasonKey, '', `${kind} owes the user no reason`); + } + assert.equal(chip(chips, CommandPaletteChipKind.Skills).showsChevron, true, + 'only the skills chip opens a surface, which is what its chevron says'); + assert.equal(Policy.rowReasonKey(available()), '', + 'a healthy row must not explain itself'); +}); + +test('a failed skill read greys the picker and nothing else', () => { + // The three session verbs exist on every host this app can reach, and each + // answers for itself when it runs: refusing them in advance would take away + // working commands on the strength of a read none of them needed. + for (const failure of [CommandPaletteProbeFailure.VersionGap, CommandPaletteProbeFailure.Refused, + CommandPaletteProbeFailure.Unreachable]) { + const state = available({probeStatus: CommandPaletteCatalogStatus.Failed, probeFailure: failure}); + const chips = Policy.chips(state); + for (const kind of HOST_VERBS) { + assert.equal(chip(chips, kind).state, CommandPaletteChipState.Ready, + `${kind} must keep working when the skill read fails (${failure})`); + assert.equal(chip(chips, kind).runsAction, true, `${kind} must still reach the host`); + assert.equal(chip(chips, kind).badgeKey, '', `${kind} is not the chip that failed`); + } + const goal = chip(chips, CommandPaletteChipKind.Goal); + assert.equal(goal.state, CommandPaletteChipState.Ready, 'the goal chip is message text'); + const skills = chip(chips, CommandPaletteChipKind.Skills); + assert.equal(skills.state, CommandPaletteChipState.Dimmed, 'the picker chip greys'); + assert.equal(skills.runsAction, true, 'the picker must still open: it explains and re-checks'); + assert.equal(Policy.rowReasonKey(state), '', + 'one chip is not a degraded row, and saying so under four working chips would be wrong'); + } +}); + +test('only a host that named the version gap earns the upgrade badge', () => { + // The badge sends the user to update a desktop. A refusal for another reason + // and a connection that never answered are both real failures and neither is + // evidence about the desktop's version. + const gap = Policy.chips(available({ + probeStatus: CommandPaletteCatalogStatus.Failed, + probeFailure: CommandPaletteProbeFailure.VersionGap + })); + assert.equal(chip(gap, CommandPaletteChipKind.Skills).badgeKey, Policy.upgradeBadgeKey); + assert.equal(chip(gap, CommandPaletteChipKind.Skills).reasonKey, Policy.degradedReasonKey); + + const refused = Policy.chips(available({ + probeStatus: CommandPaletteCatalogStatus.Failed, + probeFailure: CommandPaletteProbeFailure.Refused + })); + assert.equal(chip(refused, CommandPaletteChipKind.Skills).badgeKey, '', + 'a refusal the host did not tie to a version is not an upgrade'); + assert.equal(chip(refused, CommandPaletteChipKind.Skills).reasonKey, Policy.probeRefusedReasonKey); + + const unreachable = Policy.chips(available({ + probeStatus: CommandPaletteCatalogStatus.Failed, + probeFailure: CommandPaletteProbeFailure.Unreachable + })); + assert.equal(chip(unreachable, CommandPaletteChipKind.Skills).badgeKey, '', + 'a timeout is not a version problem'); + assert.equal(chip(unreachable, CommandPaletteChipKind.Skills).reasonKey, Policy.probeUnreachableReasonKey); +}); + +test('a read still in flight waits without claiming anything', () => { + // The one round trip between opening a session and the host's answer must not + // read as a degradation while the verbs beside it stay lit. + for (const status of [CommandPaletteCatalogStatus.Idle, CommandPaletteCatalogStatus.Loading]) { + const state = available({probeStatus: status}); + const chips = Policy.chips(state); + const skills = chip(chips, CommandPaletteChipKind.Skills); + assert.equal(skills.state, CommandPaletteChipState.Dimmed, 'the picker chip waits'); + assert.equal(skills.badgeKey, '', 'waiting is not a version problem'); + assert.equal(skills.reasonKey, Policy.probeWaitReasonKey, 'it says what it is waiting for'); + assert.equal(skills.runsAction, false, 'there is nothing to open until the answer lands'); + assert.equal(skills.showsChevron, true, 'the chip keeps the affordance it will offer'); + for (const kind of HOST_VERBS) { + assert.equal(chip(chips, kind).runsAction, true, `${kind} does not wait for the skill read`); + } + assert.notEqual(skills.reasonKey, Policy.degradedReasonKey, + 'no sentence about the desktop before the desktop has spoken'); + } +}); + +test('a greyed chip still explains itself when tapped', () => { + // "Nothing happened" is not an answer: every chip that cannot run has to carry + // the sentence the row shows for it. + const waiting = Policy.chips(available({probeStatus: CommandPaletteCatalogStatus.Loading})); + const busy = Policy.chips(available({isBusy: true})); + const sessionless = Policy.chips(available({hasSession: false})); + const failed = Policy.chips(available({ + probeStatus: CommandPaletteCatalogStatus.Failed, + probeFailure: CommandPaletteProbeFailure.Refused + })); + for (const chips of [waiting, busy, sessionless, failed]) { + for (const entry of chips) { + if (entry.runsAction) { + continue; + } + assert.notEqual(entry.reasonKey, '', `${entry.kind} must explain why it cannot run`); + } + } +}); + +test('a busy session gates the verbs but never the draft', () => { + // Compact, init and usage all act on the host's session, so a running turn + // holds them back. Picking a skill only edits the draft, and the goal chip + // writes `/goal ` locally, so neither waits for the session. + const chips = Policy.chips(available({isBusy: true})); + for (const kind of HOST_VERBS) { + assert.equal(chip(chips, kind).state, CommandPaletteChipState.Dimmed, `${kind} must wait`); + assert.equal(chip(chips, kind).runsAction, false, `${kind} must not start while busy`); + assert.equal(chip(chips, kind).reasonKey, Policy.busyReasonKey, `${kind} must say the session is busy`); + assert.equal(chip(chips, kind).badgeKey, '', 'busy is not a version problem'); + } + assert.equal(chip(chips, CommandPaletteChipKind.Skills).runsAction, true, + 'picking a skill must stay available while the host is busy'); + assert.equal(chip(chips, CommandPaletteChipKind.Goal).runsAction, true, + 'the goal prefill is local and must stay available while the host is busy'); + assert.equal(Policy.rowReasonKey(available({isBusy: true})), '', + 'a busy single chip is not a degraded row'); +}); + +test('a verb in flight owns its chip and holds the others back', () => { + // One palette verb at a time: the host would otherwise act on a context the + // user has not seen the result of yet. + const chips = Policy.chips(available({runningCommand: Policy.compactWireAction})); + const compact = chip(chips, CommandPaletteChipKind.Compact); + assert.equal(compact.state, CommandPaletteChipState.InFlight, 'the running chip shows its spinner'); + assert.equal(compact.runsAction, false, 'a running verb must not be tapped again'); + assert.equal(compact.reasonKey, '', 'an in-flight chip is not a refusal'); + for (const kind of [CommandPaletteChipKind.Init, CommandPaletteChipKind.Usage]) { + assert.equal(chip(chips, kind).state, CommandPaletteChipState.Dimmed, `${kind} must wait its turn`); + assert.equal(chip(chips, kind).reasonKey, Policy.busyReasonKey, `${kind} must say why it waits`); + } + assert.equal(chip(chips, CommandPaletteChipKind.Skills).runsAction, true, + 'the picker does not collide with a running verb'); +}); + +test('the goal chip is not gated by the skill read or by a capability', () => { + // `/goal ` is plain message text the desktop already parses on its + // send path, so this chip is honest about working on a host whose skill list + // this app cannot read. It is one of the chips that stays lit throughout. + const failed = Policy.chips(available({ + probeStatus: CommandPaletteCatalogStatus.Failed, + probeFailure: CommandPaletteProbeFailure.Unreachable + })); + const goal = chip(failed, CommandPaletteChipKind.Goal); + assert.equal(goal.state, CommandPaletteChipState.Ready, 'the goal chip must stay lit'); + assert.equal(goal.runsAction, true, 'the goal chip must still toggle its prefill'); + assert.equal(goal.badgeKey, '', 'a goal is not a version problem'); + assert.equal(goal.reasonKey, '', 'the goal chip owes no reason'); + + const pending = chip(Policy.chips(available({goalPending: true})), CommandPaletteChipKind.Goal); + assert.equal(pending.state, CommandPaletteChipState.Pending, 'a prefilled goal reads as pending'); + assert.equal(pending.runsAction, true, 'a pending goal must be tappable to take the prefill back'); +}); + +test('the row needs a session and hides itself without one', () => { + const state = available({hasSession: false}); + assert.equal(Policy.visible(false), false, 'the row cannot act on a conversation that is not open'); + assert.equal(Policy.visible(true), true); + for (const entry of Policy.chips(state)) { + assert.equal(entry.state, CommandPaletteChipState.Dimmed, `${entry.kind} must wait for a session`); + assert.equal(entry.runsAction, false, `${entry.kind} must not act without a session`); + assert.equal(entry.reasonKey, Policy.sessionReasonKey(), `${entry.kind} must ask for a session`); + } + assert.equal(Policy.rowReasonKey(state), Policy.sessionReasonKey()); +}); + +test('only a settled failure earns a retry, and only for a runnable verb', () => { + assert.equal(Policy.canRetry(CommandPaletteNoticeKind.Failed, Policy.compactWireAction), true); + assert.equal(Policy.canRetry(CommandPaletteNoticeKind.Failed, Policy.initWireAction), true); + assert.equal(Policy.canRetry(CommandPaletteNoticeKind.Failed, ''), false, + 'a failed chip with no verb has nothing to repeat'); + assert.equal(Policy.canRetry(CommandPaletteNoticeKind.Failed, 'goal'), false, + 'the retry affordance repeats a host verb, not a draft edit'); + assert.equal(Policy.canRetry(CommandPaletteNoticeKind.Done, Policy.compactWireAction), false, + 'a completion line is not an error to retry'); + assert.equal(Policy.canRetry(CommandPaletteNoticeKind.Busy, Policy.compactWireAction), false); +}); + +test('each verb has its own in-flight, completion and failure line', () => { + const compact = Policy.compactWireAction; + const init = Policy.initWireAction; + for (const [wireAction, suffix] of [[compact, 'compact'], [init, 'init']]) { + assert.equal(Policy.busyNoticeKey(wireAction), `commands.${suffix}Running`); + assert.equal(Policy.doneNoticeKey(wireAction), `commands.${suffix}Done`); + assert.equal(Policy.failedNoticeKey(wireAction), `commands.${suffix}Failed`); + } + assert.notEqual(Policy.busyNoticeKey(compact), Policy.busyNoticeKey(init), + 'two verbs must not share one line'); + assert.equal(Policy.isRunnable(compact), true); + assert.equal(Policy.isRunnable(init), true); + assert.equal(Policy.isRunnable('goal'), false, 'a draft edit is not a host verb'); + assert.equal(Policy.wireAction(CommandPaletteChipKind.Goal), '', + 'the chips that only shape the draft start nothing'); + assert.equal(Policy.wireAction(CommandPaletteChipKind.Usage), ''); + assert.equal(Policy.wireAction(CommandPaletteChipKind.Compact), compact); + assert.equal(Policy.wireAction(CommandPaletteChipKind.Init), init); +}); + +test('the completion line dwells and a failure does not', () => { + assert.ok(Policy.noticeDwellMs(CommandPaletteNoticeKind.Done) > 0, + 'a completion is a moment, not a state, so it needs a dwell time'); + assert.equal(Policy.noticeDwellMs(CommandPaletteNoticeKind.Failed), 0, + 'a failure stays until it is acted on'); +}); + +test('completing a line with no argument is not a message yet', () => { + const goal = Policy.goalPrefillText(); + assert.equal(goal, '/goal '); + assert.equal(Policy.isBareGoalCommand('/goal'), true); + assert.equal(Policy.isBareGoalCommand('/goal '), true, 'the prefill alone is still bare'); + assert.equal(Policy.isBareGoalCommand(' /goal '), true); + assert.equal(Policy.isBareGoalCommand('/goal fix login'), false); + assert.equal(Policy.blocksSend('/goal'), true); + assert.equal(Policy.blocksSend('/goal fix login'), false); + assert.equal(Policy.blocksSend(''), false, 'an empty draft is blocked by the composer, not here'); + assert.equal(Policy.blocksSend('//goal'), false, 'only the real command is a command'); +}); + +test('the goal prefill only ever fills an empty draft', () => { + // One tap must not be a way to lose typed text. + const first = Policy.toggleGoal('', false); + assert.equal(first.draft, '/goal '); + assert.equal(first.pending, true); + assert.equal(Policy.canPrefillGoal(''), true); + assert.equal(Policy.canPrefillGoal(' '), true); + assert.equal(Policy.canPrefillGoal('draft'), false); + assert.equal(Policy.canPrefillGoal('/goal fix'), false, + 'a draft that already carries the command is not empty'); + + const takenBack = Policy.toggleGoal('/goal ', true); + assert.equal(takenBack.draft, '', 'a second tap takes the untouched prefill back off'); + assert.equal(takenBack.pending, false); + + const typed = Policy.toggleGoal('/goal fix login', true); + assert.equal(typed.draft, '/goal fix login', 'an argument typed after the prefill is user text'); + assert.equal(typed.pending, false, 'the chip only stops being pending'); + + const occupied = Policy.toggleGoal('fix login', false); + assert.equal(occupied.draft, 'fix login', 'a draft with content is left alone'); + assert.equal(occupied.pending, false); +}); + +test('a skill token survives the operator keeping or replacing it', () => { + assert.equal(Policy.skillTokenLabel('Plan'), '[$Plan]'); + assert.equal(Policy.skillTokenLabel(' Plan '), '[$Plan]'); + assert.equal(Policy.skillTokenLabel(''), '', 'no pick draws no token'); + assert.equal(Policy.applySkillSelection('', 'plan'), 'plan'); + assert.equal(Policy.applySkillSelection('plan', 'review'), 'review', + 'one token at a time: a new pick replaces the old one'); + assert.equal(Policy.applySkillSelection('plan', 'plan'), 'plan', 're-picking is a no-op'); + assert.equal(Policy.applySkillSelection('plan', ' '), 'plan', + 'an empty pick must not clear the token it did not ask to change'); +}); + +test('the picker row hints at the token the message will carry', () => { + // One rule serves both: the row's hint comes from the same call the send path + // composes the message with, so a hint can never describe a token the message + // does not use. The name is what the desktop inserts, hence the name here. + const token = Policy.skillTokenLabel('Plan'); + assert.equal(token, '[$Plan]'); + assert.equal(Policy.composeSubmissionText('draft', 'Plan'), `${token} draft`); + assert.equal(Policy.composeSubmissionText('/goal ship it', 'Plan'), + `/goal ship it ${token}`, 'a host command keeps the head it is parsed from'); +}); + +test('a failed skill read is classified by whether the host answered', () => { + // The classification is the only thing standing between a timeout and a claim + // that the user's desktop is out of date. + assert.equal(Policy.probeFailure(false, 'anything'), + CommandPaletteProbeFailure.Unreachable, 'nothing answered, so nothing is claimed'); + assert.equal(Policy.probeFailure(false, ''), + CommandPaletteProbeFailure.Unreachable, 'an empty transport error is still a transport error'); + assert.equal(Policy.probeFailure(true, "command 'get_mode_skill_configs' is unknown to this " + + 'OpenBitFun desktop peer host version; upgrade the peer host or check the command name'), + CommandPaletteProbeFailure.VersionGap, + "the host's own version-gap sentence is the one thing that licenses the badge"); + assert.equal(Policy.probeFailure(true, 'Command not found: get_mode_skill_configs'), + CommandPaletteProbeFailure.Refused, + "the desktop's command registry refusing by name is a command-level refusal"); + assert.equal(Policy.probeFailure(true, 'workspace not found'), + CommandPaletteProbeFailure.Refused, + 'a refusal about something else keeps the host text without claiming a version'); + + // Every kind has copy of its own, and only the version gap points at an update. + const kinds = [CommandPaletteProbeFailure.VersionGap, CommandPaletteProbeFailure.Refused, + CommandPaletteProbeFailure.Unreachable]; + assert.deepEqual(kinds.map((kind) => Policy.probeBadgeKey(kind)), + [Policy.upgradeBadgeKey, '', '']); + assert.deepEqual(kinds.map((kind) => Policy.probeReasonKey(kind)), + [Policy.degradedReasonKey, Policy.probeRefusedReasonKey, Policy.probeUnreachableReasonKey]); + assert.equal(new Set(kinds.map((kind) => Policy.probeTitleKey(kind))).size, 3, + 'each failure kind says its own thing'); + assert.equal(new Set(kinds.map((kind) => Policy.probeBodyKey(kind))).size, 3); + for (const key of kinds.map((kind) => Policy.probeTitleKey(kind)) + .concat(kinds.map((kind) => Policy.probeBodyKey(kind)))) { + assert.ok(key.startsWith('commands.'), `${key} must stay in the palette's namespace`); + } + assert.equal(commandPaletteProbeReady(CommandPaletteCatalogStatus.Ready), true); + assert.equal(commandPaletteProbeReady(CommandPaletteCatalogStatus.Failed), false); + assert.equal(commandPaletteProbeReady(CommandPaletteCatalogStatus.Loading), false); +}); + +test('the bridge failing to deliver is not the host refusing', () => { + // `host_invoke` answers `ok: false` both when a command is declined and when + // the bridge never reached a handler. The second kind must not borrow the + // refusal's copy — which says the desktop answered — nor its version verdict. + // The wordings are the ones `peer_host_invoke.rs:314-401` produces. + const bridgeFailures = [ + "peer host invoke timed out after 120s for 'get_mode_skill_configs'", + 'peer app handle not ready', + 'peer host invoke channel closed', + 'failed to emit peer host invoke request: window is gone', + 'peer host invoke lock poisoned: poisoned' + ]; + for (const text of bridgeFailures) { + assert.equal(Policy.probeFailure(true, text), CommandPaletteProbeFailure.Unreachable, + `"${text}" reached no command handler, so it is a connection to fix`); + assert.equal(Policy.probeBadgeKey(Policy.probeFailure(true, text)), '', + `"${text}" is no evidence about the desktop's version`); + } + // The refusal wording those failures sit beside still reads as one, and it + // earns no upgrade badge: only the sentence that names a version does. + assert.equal(Policy.probeFailure(true, 'Command not found: get_mode_skill_configs'), + CommandPaletteProbeFailure.Refused); + assert.equal(Policy.probeBadgeKey(Policy.probeFailure(true, 'Command not found: get_mode_skill_configs')), '', + 'a missing command name is not the host telling the user to update'); + // Non-answer markers are checked first, so a mixed sentence (synthetic: the + // two wordings never travel together) stays a connection problem. + assert.equal(Policy.probeFailure(true, 'peer host invoke timed out after 120s; peer host version'), + CommandPaletteProbeFailure.Unreachable); + + // The copy that stays behind the refusal kind is the one that asserts an answer. + assert.notEqual(Policy.probeBodyKey(CommandPaletteProbeFailure.Refused), + Policy.probeBodyKey(CommandPaletteProbeFailure.Unreachable)); +}); + +test('the token becomes message content in front of free text', () => { + assert.equal(Policy.composeSubmissionText('fix login', 'Plan'), '[$Plan] fix login'); + assert.equal(Policy.composeSubmissionText(' fix login ', 'Plan'), '[$Plan] fix login'); + assert.equal(Policy.composeSubmissionText('', 'Plan'), '[$Plan]', + 'a token with no draft is the whole message'); + assert.equal(Policy.composeSubmissionText('fix login', ''), 'fix login', + 'no token leaves the draft exactly as typed'); + assert.equal(Policy.composeSubmissionText('[$Plan] fix login', 'Plan'), '[$Plan] fix login', + 'the token is written once, so a repeated pick cannot stack it'); + assert.equal(Policy.composeSubmissionText('', ''), ''); +}); + +test('a host command keeps the head of the message', () => { + // The desktop parses the objective out of the message head + // (`goal_objective_from_prompt` refuses anything that does not start with + // `/goal `), so a token in front of it would silently turn a goal into an + // ordinary message. The token is appended for these drafts instead. + assert.equal(Policy.startsWithHostCommand('/goal fix login'), true); + assert.equal(Policy.startsWithHostCommand('/goal'), true); + assert.equal(Policy.startsWithHostCommand(' /goal fix'), true); + assert.equal(Policy.startsWithHostCommand('/goals'), false); + assert.equal(Policy.startsWithHostCommand('fix login'), false); + + assert.equal(Policy.composeSubmissionText('/goal fix login', 'Plan'), + '/goal fix login [$Plan]'); + assert.equal(Policy.composeSubmissionText(' /goal fix login ', 'Plan'), + '/goal fix login [$Plan]'); + assert.equal(Policy.composeSubmissionText('/goal', 'Plan'), '/goal [$Plan]', + 'a bare command is never sent on its own; if it ever reached here the token must not become the objective head'); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs b/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs index 0db2a974f0..72232c44cb 100644 --- a/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs @@ -406,11 +406,16 @@ test('after host restart the same conversation sends a new turn instead of steer '../../services/ChatTimelineStore': { ChatTimelineStore }, '../../services/RemoteUiState': ui, '../../services/RemoteLogger': logger, '../../i18n/RemoteI18n': i18n, '../../services/Encoding': { Encoding: { randomId: () => 'new-turn' } }, + '../../services/CommandPalettePolicy': load('CommandPalettePolicy'), './ConversationRuntime': { requireRemoteRuntime: value => value, shortSessionId: value => value } }); const remote = { chatInput:'continue after restart', selectedImages:[], isBusy:false, isVoiceListening:false, connectionState:'connected', activeSession:{sessionId:'session',agentType:'code'}, activeTurnMessage:timeline.activeTurnOrEmpty(), - supportsHostCapability:()=>true }; + supportsHostCapability:()=>true, + // The send path composes the draft with the command row's skill token, so the + // page state it reads carries one; an empty pick is the no-token case, which + // means both the row's key and the name its token would be built from. + commandPalette:{skillId:'', skillLabel:''} }; const controller = new RemoteTranscriptController(remote, { timeline, chat:command, connection:{ensureAvailable:()=>true}, polling:{nudge(){}}, hooks:{} }, ()=>{});