From 6aa6702d0e7c44257b5f577f9bfbc13c98d1180f Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 10:46:01 +0800 Subject: [PATCH 01/15] feat(mobile): add command palette controllers and host_invoke wire layer The composer command row reaches the desktop through the existing host_invoke bridge rather than a capability handshake: the first skill catalog read is the behavior probe, and its answer decides whether the row may run anything. A host that predates a command refuses it with its own error text, which the row shows instead of pretending the tap did nothing. Adds the pure policies (chip gating, goal prefill, submission text), the host row projection, the session-fenced controller, its observable state and presentation, the conversation intent funnel, and the four client methods on RemoteSessionManager. --- .../entry/src/main/ets/model/RemoteModels.ets | 200 +++++++++ .../ets/pages/actions/ConversationIntent.ets | 56 ++- .../actions/ConversationIntentDispatcher.ets | 10 +- .../policy/UsageReportPresentationPolicy.ets | 305 ++++++++++++++ .../runtime/AppRootRuntimeComposition.ets | 73 +++- .../ets/pages/state/CommandPaletteState.ets | 290 +++++++++++++ .../ets/pages/state/ConversationViewState.ets | 11 + .../main/ets/pages/state/RemotePageState.ets | 48 ++- .../viewmodel/ConversationController.ets | 5 +- .../pages/viewmodel/ConversationRuntime.ets | 2 + .../viewmodel/RemoteTranscriptController.ets | 121 +++++- .../VisibleConversationController.ets | 5 + .../ets/services/CommandCatalogProjection.ets | 379 +++++++++++++++++ .../ets/services/CommandPalettePolicy.ets | 389 ++++++++++++++++++ .../RemoteCommandPaletteController.ets | 290 +++++++++++++ .../ets/services/RemoteSessionManager.ets | 128 +++++- 16 files changed, 2301 insertions(+), 11 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UsageReportPresentationPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/CommandPaletteState.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandCatalogProjection.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandPalettePolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandPaletteController.ets 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..df95af3530 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,47 @@ 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, for `SelectSkill`; empty otherwise. */ + readonly skillId: string; + /** The verb a `RetryCommand` repeats, empty otherwise. */ + readonly wireAction: string; + + constructor(action: CommandPaletteAction, skillId: string = '', wireAction: string = '') { + this.action = action; + this.skillId = skillId; + this.wireAction = wireAction; + } +} + export class PlanBuildRequest { readonly path: string; readonly name: string; @@ -66,6 +102,7 @@ export class ConversationIntent { readonly filePreviewRequest?: FilePreviewRequest; readonly planBuildRequest?: PlanBuildRequest; readonly copyScope: MessageCopyScope; + readonly commandPaletteRequest?: CommandPaletteRequest; mailboxAction?: InteractionMailboxAction; constructor( @@ -76,7 +113,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 +124,7 @@ export class ConversationIntent { this.filePreviewRequest = filePreviewRequest; this.planBuildRequest = planBuildRequest; this.copyScope = copyScope; + this.commandPaletteRequest = commandPaletteRequest; } } @@ -154,4 +193,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/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/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 6d13f2fd08..bd9830753e 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): void => { + if (!this.isCommandPaletteSession(sessionId)) return; + this.remotePageState.commandPalette.setCatalogFailed(errorText); + }, + 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..7740fd8bef --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/CommandPaletteState.ets @@ -0,0 +1,290 @@ +import { RemoteCommandCatalog, RemoteSessionUsageReport } from '../../model/RemoteModels'; +import { + CommandPaletteAvailability, + CommandPaletteChip, + CommandPaletteChipKind, + CommandPaletteChipState, + CommandPaletteNoticeKind, + CommandPalettePolicy +} from '../../services/CommandPalettePolicy'; +import { + UsageReportPresentationPolicy, + UsageReportSection +} from '../policy/UsageReportPresentationPolicy'; + +/** Lifecycle of the catalog read that doubles as the palette's behavior probe. */ +export enum CommandPaletteCatalogStatus { + /** No read yet: the row's chips wait rather than act. */ + Idle = 'idle', + /** The probe is in flight. */ + Loading = 'loading', + /** The host answered; its verbs are reachable. */ + Ready = 'ready', + /** The host refused the read. The row degrades and the sheet offers a re-check. */ + Failed = 'failed' +} + +export enum CommandPaletteUsageStatus { + Idle = 'idle', + Loading = 'loading', + Ready = 'ready', + Failed = 'failed' +} + +/** Whether the probe's answer lets the row act at all. */ +export function commandPaletteProbeReady(status: string): boolean { + return status === CommandPaletteCatalogStatus.Ready; +} + +/** + * 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; + /** + * The host's own refusal text for a failed probe. + * + * A palette 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 single selected skill token, or '' when the composer carries none. */ + @Trace skillId: string = ''; + /** The goal chip is lit and its `/goal ` prefill is in the composer. */ + @Trace goalPending: boolean = false; + + availability(probeReady: boolean, hasSession: boolean, isBusy: boolean): CommandPaletteAvailability { + return new CommandPaletteAvailability( + probeReady, hasSession, isBusy, this.runningCommand, this.goalPending); + } + + setCatalogLoading(): void { + this.catalogStatus = CommandPaletteCatalogStatus.Loading; + this.catalogErrorText = ''; + } + + setCatalogReady(catalog: RemoteCommandCatalog): void { + this.catalog = catalog; + this.catalogStatus = CommandPaletteCatalogStatus.Ready; + this.catalogErrorText = ''; + } + + setCatalogFailed(errorText: string): void { + this.catalog = undefined; + this.catalogErrorText = errorText; + 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; + } + + setSkill(skillId: string): void { + this.skillId = CommandPalettePolicy.applySkillSelection(this.skillId, skillId); + } + + clearSkill(): void { + this.skillId = ''; + } + + 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.catalogErrorText = ''; + this.runningCommand = ''; + this.notice = CommandPaletteNotice.none(); + this.usageStatus = CommandPaletteUsageStatus.Idle; + this.usageReport = undefined; + this.usageErrorText = ''; + this.skillId = ''; + 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 or degraded surface. */ + readonly rowReasonKey: string; + readonly notice: CommandPaletteNotice; + readonly catalogStatus: string; + readonly catalog?: RemoteCommandCatalog; + /** The host's refusal text for a failed probe, empty otherwise. */ + readonly catalogErrorText: string; + readonly usageStatus: string; + readonly usageSections: UsageReportSection[]; + readonly usagePartial: boolean; + readonly usageErrorText: string; + readonly skillId: string; + + constructor( + visible: boolean, + chips: CommandPaletteChip[], + rowReasonKey: string, + notice: CommandPaletteNotice, + catalogStatus: string, + catalog: RemoteCommandCatalog | undefined, + catalogErrorText: string, + usageStatus: string, + usageSections: UsageReportSection[], + usagePartial: boolean, + usageErrorText: string, + skillId: string + ) { + this.visible = visible; + this.chips = chips; + this.rowReasonKey = rowReasonKey; + this.notice = notice; + this.catalogStatus = catalogStatus; + this.catalog = catalog; + this.catalogErrorText = catalogErrorText; + this.usageStatus = usageStatus; + this.usageSections = usageSections; + this.usagePartial = usagePartial; + this.usageErrorText = usageErrorText; + this.skillId = skillId; + } + + /** 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, '', + CommandPaletteUsageStatus.Idle, [], false, '', ''); + } + + static project( + state: CommandPaletteState, + probeReady: boolean, + hasSession: boolean, + isBusy: boolean + ): CommandPalettePresentation { + if (!CommandPalettePolicy.visible(hasSession)) { + return CommandPalettePresentation.hidden(); + } + const availability = state.availability(probeReady, 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.catalogErrorText, + state.usageStatus, + usage.sections, + usage.partial, + state.usageErrorText, + state.skillId); + } + + 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..d97ace4e01 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, commandPaletteProbeReady } 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,11 @@ export class ConversationViewState { state.downloadedFilePath = remote.downloadedFilePath; state.fileDownloadStatus = remote.fileDownloadStatus; state.interactionMailbox = remote.interactionMailbox; + state.commandPalette = CommandPalettePresentation.project( + remote.commandPalette, + commandPaletteProbeReady(remote.commandPalette.catalogStatus), + 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..4118541144 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,37 @@ export class RemotePageState { this.conversation.clearComposer(); } + /** + * Draft ownership for a send, including the palette's skill token. + * + * The token is a chip beside the field rather than text inside it, so it has + * to travel with the same ownership rules as the draft: consumed by the commit + * that the host accepted, and put back by the rollback that it did not. + */ + prepareComposerSubmission( + sessionId: string, + text: string, + images: SelectedImageAttachment[] + ): ComposerSubmission { + const inner = this.conversation.prepareComposerSubmission(sessionId, text, images); + const skillId = this.commandPalette.skillId; + return { + commit: (): void => { + const hadSkill = this.commandPalette.skillId.length > 0; + inner.commit(); + if (hadSkill) { + this.commandPalette.clearSkill(); + } + }, + rollback: (): void => { + inner.rollback(); + if (skillId.length > 0 && this.commandPalette.skillId.length === 0) { + this.commandPalette.setSkill(skillId); + } + } + }; + } + setVoiceListening(isVoiceListening: boolean): void { this.conversation.setVoiceListening(isVoiceListening); } @@ -382,6 +427,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..dae2ce6627 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); + 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,16 @@ 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. + const skillId = this.remote.commandPalette.skillId; + const text = rawText.length > 0 ? CommandPalettePolicy.composeSubmissionText(rawText, skillId) : (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..f4a1c1b3f6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandCatalogProjection.ets @@ -0,0 +1,379 @@ +import { + RemoteCommandCatalog, + RemoteSkillCatalogEntry, + REMOTE_SKILL_SOURCE_BUILTIN, + REMOTE_SKILL_SOURCE_PROJECT, + REMOTE_SKILL_SOURCE_USER +} from '../model/RemoteModels'; + +/** + * 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: `selectedForRuntime` + * absent means "not stated", which the availability rule reads as reachable + * only when the host said nothing that contradicts it. + */ +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 text the row says it will insert, e.g. `$plan`. */ + 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 = `$${entry.id}`; + } +} + +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.name(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; + } + + private static name(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..3c4467fd90 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandPalettePolicy.ets @@ -0,0 +1,389 @@ +/** + * 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 runs a verb before + * the host has answered the probe, and never rewrites a draft the user owns. + */ + +/** 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. + * + * There is no capability to consult: a failed probe is the only reason the whole + * row degrades, and a busy session is the only reason a single chip does. + */ +export enum CommandPaletteDegradeReason { + /** The host did not answer the skill catalog read. */ + 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 host answered the skill catalog read, so its verbs are reachable. */ + readonly probeReady: boolean; + 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( + probeReady: boolean, + hasSession: boolean, + isBusy: boolean, + runningCommand: string = '', + goalPending: boolean = false + ) { + this.probeReady = probeReady; + this.hasSession = hasSession; + this.isBusy = isBusy; + this.runningCommand = runningCommand; + this.goalPending = goalPending; + } +} + +/** 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'; + + /** + * 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); + } + if (!availability.probeReady) { + // The skills chip keeps opening, because the sheet is where the reason and + // the 「重新检查」 live; the verbs have nothing to run, so their taps only + // reveal the explanation the chip already carries. + return new CommandPaletteChip( + kind, + CommandPaletteChipState.Dimmed, + labelKey, + CommandPalettePolicy.upgradeBadgeKey, + chevron, + CommandPalettePolicy.degradedReasonKey, + kind === CommandPaletteChipKind.Skills); + } + // Picking a skill only edits the draft, so the sheet opens even while a turn + // or another palette verb is running. + if (kind === CommandPaletteChipKind.Skills) { + return new CommandPaletteChip( + kind, CommandPaletteChipState.Ready, labelKey, '', true, '', true); + } + 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); + } + + 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'; + } + + /** A degraded row always carries its explanation under the chips. */ + static rowReasonKey(availability: CommandPaletteAvailability): string { + if (!availability.hasSession) return CommandPalettePolicy.sessionReasonKey(); + if (!availability.probeReady) return CommandPalettePolicy.degradedReasonKey; + return ''; + } + + /** 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. + */ + 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. */ + static skillTokenLabel(skillId: string): string { + const id = skillId.trim(); + return id.length === 0 ? '' : `[$${id}]`; + } + + /** 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. 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, skillId: string): string { + const token = CommandPalettePolicy.skillTokenLabel(skillId); + 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/RemoteCommandPaletteController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandPaletteController.ets new file mode 100644 index 0000000000..100f46f957 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandPaletteController.ets @@ -0,0 +1,290 @@ +import { + RemoteCommandCatalog, + RemoteSessionUsageReport +} from '../model/RemoteModels'; +import { CommandPaletteNoticeKind, CommandPalettePolicy } from './CommandPalettePolicy'; +import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; +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. This is the probe's negative answer — the row degrades and + * the sheet offers 「重新检查」, which replays it. + */ + onCatalogFailed: (sessionId: string, errorText: 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 row reads this instead: + * `Probed` is the only state whose chips may reach the host, `Unprobed` is where + * every session starts (and reads like a busy row for one round trip), and + * `Failed` is a desktop that refused the read and needs a re-check or an update. + */ +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; + /** 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; + } + + /** 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.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 nothing, which is + * what keeps this off the render path. + */ + 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) { + return; + } + this.catalogSessionId = sessionId; + this.catalogProbe = CommandPaletteProbe.Unprobed; + 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 host that does not serve the read is a degraded palette, not a hidden + // one: the row greys itself and the sheet says why, with a way to re-check. + this.catalog = undefined; + this.catalogProbe = CommandPaletteProbe.Failed; + this.catalogErrorText = ConnectionErrorPolicy.errorText(err); + RemoteLogger.warn( + `command catalog failed session=${RemoteCommandPaletteController.shortSessionId(sessionId)}: ${String(err)}`); + this.callbacks.onCatalogFailed(sessionId, this.catalogErrorText); + } + } + + /** + * 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..f84aad78c3 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, RemoteModeSkillRow, 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,8 @@ import { RemoteChatCommandClient } from './RemoteChatCommandController'; import { RemoteFileDownloadClient } from './RemoteFileDownloadController'; import { RemoteLogger } from './RemoteLogger'; import { RemoteModelClient } from './RemoteModelController'; +import { RemoteCommandPaletteClient, RemoteCommandPaletteScope, RemoteSkillCatalogQuery } from './RemoteCommandPaletteController'; +import { CommandCatalogProjection } from './CommandCatalogProjection'; import { RemoteResponseMapper } from './RemoteResponseMapper'; import { stampRemoteSessionsWorkspace } from './RemoteSessionIdentity'; import { TranscriptIntegrityPolicy } from './TranscriptIntegrityPolicy'; @@ -38,7 +40,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; @@ -447,6 +484,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, From ad5711059c1ae69de0c5ba641f5b223e8907afe9 Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 10:46:16 +0800 Subject: [PATCH 02/15] feat(mobile): mount the command chip row, skill picker and usage sheet The chip row hangs above the composer inside the one Composer() subtree the compact overlay, the half-folded pane and the wide detail pane all share, so every posture gets the same row from the same builder. The two sheets are view state of the conversation pane, the way the full-text panel is: nothing outside this pane reads whether one is open, and the only thing that leaves the pane is the typed conversation intent. The picker explains a degraded host in place -- why, plus the host's own refusal text and a way to re-check it -- because a dead chip would say less than an empty catalog with a reason. The composer carries the selected skill as a removable token beside the draft, drawn as the exact [\] text the message will send, and withholds Send for a bare /goal that is still waiting for its argument. --- .../pages/components/AppRootPresentation.ets | 10 + .../ets/pages/components/CommandChipRow.ets | 318 +++++++++++++ .../main/ets/pages/components/ComposerBar.ets | 91 +++- .../components/ConversationRouteSurface.ets | 6 + .../ets/pages/components/ConversationView.ets | 110 ++++- .../components/ConversationViewContract.ets | 9 + .../pages/components/ConversationViewHost.ets | 6 + .../ets/pages/components/SkillPickerSheet.ets | 434 ++++++++++++++++++ .../ets/pages/components/UsageReportSheet.ets | 206 +++++++++ .../pages/components/WideConversationHost.ets | 6 + .../pages/policy/SettingsPlacementPolicy.ets | 31 +- 11 files changed, 1218 insertions(+), 9 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CommandChipRow.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SkillPickerSheet.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/UsageReportSheet.ets 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..da88f1f887 --- /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_ACTION, + 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_ACTION; + return chip.state === CommandPaletteChipState.Dimmed ? MUTED : INK; + } + + private chipGlyphColor(chip: CommandPaletteChip): ResourceColor { + if (chip.state === CommandPaletteChipState.Pending) return CONTENT_ON_ACTION; + 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..d83048785c 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,21 @@ 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. + */ + @Param skillId: 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 +104,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 +159,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.skillId.length > 0) { + this.CommandTokenStrip() + } if (this.selectedImages.length > 0) { this.SelectedImageStrip() } @@ -659,6 +687,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.skillId)) + .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.skillId)}`) + .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 +776,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 +787,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 +806,13 @@ export struct ComposerBar { ); } - // Attachments live inside the card, so the card has to grow to hold them. + // 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.skillId.length > 0 ? COMPOSER_TOKEN_STRIP_BLOCK : 0; + return base + attachmentBlock + tokenBlock; } private collapsedComposerHeight(): number { @@ -739,7 +822,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.skillId.length > 0) { 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..5235ebe286 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 { @@ -62,6 +73,15 @@ export struct ConversationView { */ @Local showFullText: boolean = false; @Local fullTextMessage: string = ''; + /** + * The two command-row sheets, opened from the 技能 and 用量 chips. + * + * Same reasoning as the full-text panel below: they are view state of this + * pane, not projected fields, because nothing outside it reads whether a sheet + * happens to be open. + */ + @Local showSkillPicker: boolean = false; + @Local showUsageReport: boolean = false; /** * How much of the pane the two floating layers cover, measured rather than * assumed: the composer grows with multiline text and attachments, and the @@ -118,6 +138,8 @@ export struct ConversationView { .height('100%') .backgroundColor(PAGE_BG) .bindSheet($$this.showFullText, this.FullTextSheet(), this.fullTextSheetOptions()) + .bindSheet($$this.showSkillPicker, this.SkillPickerSheetSurface(), this.skillPickerSheetOptions()) + .bindSheet($$this.showUsageReport, this.UsageReportSheetSurface(), this.usageReportSheetOptions()) } @Builder @@ -130,6 +152,14 @@ export struct ConversationView { }) } + private skillPickerSheetOptions(): SheetOptions { + return this.adaptiveSheetOptions(this.skillPickerSheetPlacement); + } + + private usageReportSheetOptions(): SheetOptions { + return this.adaptiveSheetOptions(this.usageReportSheetPlacement); + } + /** * The transcript owns the whole pane; the header and the composer float over * it and the transcript scrolls underneath both, fading out as it reaches @@ -479,12 +509,26 @@ 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, + commandRowActive: this.commandPalette.visible, isBusy: this.isBusy, canStop: this.canStop, connectionState: this.connectionState, @@ -497,6 +541,10 @@ export struct ConversationView { onRemoveImage: (imageId: string) => { this.onRemoveImage(imageId); }, + onRemoveSkillToken: () => { + this.onIntent(ConversationIntents.commandPalette( + new CommandPaletteRequest(CommandPaletteAction.ClearSkill))); + }, onSend: () => { this.onSend(); }, @@ -517,6 +565,63 @@ 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.showSkillPicker = true; + } else if (request.action === CommandPaletteAction.OpenUsage) { + this.showUsageReport = true; + } + this.onIntent(ConversationIntents.commandPalette(request)); + } + + @Builder + private SkillPickerSheetSurface() { + SkillPickerSheet({ + palette: this.commandPalette, + onSelect: (skillId: string) => { + this.showSkillPicker = false; + this.onIntent(ConversationIntents.commandPalette( + new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId))); + }, + onRefresh: () => { + this.onIntent(ConversationIntents.commandPalette( + new CommandPaletteRequest(CommandPaletteAction.RefreshCatalog))); + }, + onClose: () => { + this.showSkillPicker = false; + } + }) + } + + @Builder + private UsageReportSheetSurface() { + UsageReportSheet({ + palette: this.commandPalette, + onRetry: () => { + this.onIntent(ConversationIntents.commandPalette( + new CommandPaletteRequest(CommandPaletteAction.RetryUsage))); + }, + onClose: () => { + this.showUsageReport = false; + } + }) + } + + 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 +783,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 +794,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); } 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/SkillPickerSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SkillPickerSheet.ets new file mode 100644 index 0000000000..12e3203d38 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SkillPickerSheet.ets @@ -0,0 +1,434 @@ +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, 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_ACTION, 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) => void = (_skillId: 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({ 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) + .margin({ 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_ACTION : 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); + }) + } + + @Builder + LoadingState() { + Column() { + LoadingProgress() + .width(24) + .height(24) + .color(MUTED) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + } + + /** The catalog read answered nothing: say why, and offer the re-check. */ + @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 probe failed, or a chip was reached while it still had to wait. + * + * Both land on the same explanation because both mean "this desktop did not + * answer"; the difference is only which sentence says it. + */ + private isUnavailable(): boolean { + if (this.palette.catalogStatus === CommandPaletteCatalogStatus.Failed) { + return true; + } + return !this.isLoading() && this.palette.rowReasonKey.length > 0; + } + + private unavailableTitleKey(): string { + return this.palette.rowReasonKey.length > 0 ? + 'commands.skills.degradedTitle' : 'commands.skills.unavailableTitle'; + } + + private unavailableBodyKey(): string { + return this.palette.rowReasonKey.length > 0 ? + 'commands.skills.degradedBody' : 'commands.skills.unavailableBody'; + } + + 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/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/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 { From 4a70d3ccb8e1b0921653a6803c537dec74ce8846 Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 10:47:37 +0800 Subject: [PATCH 03/15] feat(mobile): add the command palette and usage report copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventy keys per locale, appended in lockstep: the five chip labels and their reasons, the skill picker's sections, filters and both degraded sentences, and the usage report's rows. Chip and sheet titles follow the approved desktop wording (压缩会话 / 生成 AGENTS.md / 会话目标 / 会话用量), and the degraded copy says what actually happened -- a desktop that is too old -- plus that the session can still send messages. --- .../main/resources/en_US/element/string.json | 280 ++++++++++++++++++ .../main/resources/zh_CN/element/string.json | 280 ++++++++++++++++++ 2 files changed, 560 insertions(+) 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..bc07e7907f 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,286 @@ { "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 for these commands. Upgrade OpenBitFun on that device; you can still send messages 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. Upgrade OpenBitFun on 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." } ] } 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..aaf7932207 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,286 @@ { "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": "已连接的桌面端未返回技能列表。请升级该设备上的 OpenBitFun 后重新检查。" + }, + { + "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": "当前会话没有可展示的用量数据。" } ] } From 55158b03b7526cdd45cc6bef103212270b6762ca Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 10:55:45 +0800 Subject: [PATCH 04/15] test(mobile): cover the command palette's gating, catalog and composer rules The gating matrix is the point of the first file: an answered probe enables the verbs, a refused one greys them and badges them as needing an upgrade, a busy session holds the host verbs back without touching the draft, an in-flight verb owns its chip, and every chip that cannot run still carries the sentence the row shows when it is tapped. The goal chip is asserted to be independent of the probe, because \/goal \ is plain message text. The catalog file drives the real projection through a loader that resolves its imports, so it exercises the production file: the host's ModeSkillInfo rows, the snake_case spelling an older host may send, the availability rule that decides which skills the picker may offer, the id-collision case that must keep the invocable row, and the section ordering that puts custom skills after built-ins. The composer file pins the token and goal rules, and the two bridge facts they depend on: hostInvoke unwraps {ok, value, error} rather than the command's own value, and each of the four verbs sends the argument shape its own Rust handler declares. session-record's controller harness now supplies the command palette policy and the token state the send path reads. --- .../tools/tests/command-catalog.test.cjs | 395 ++++++++++++++++++ .../tests/command-palette-composer.test.cjs | 191 +++++++++ .../tests/command-palette-policy.test.cjs | 276 ++++++++++++ .../tools/tests/session-record.test.cjs | 6 +- 4 files changed, 867 insertions(+), 1 deletion(-) create mode 100644 src/apps/mobile/harmonyos/tools/tests/command-catalog.test.cjs create mode 100644 src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs create mode 100644 src/apps/mobile/harmonyos/tools/tests/command-palette-policy.test.cjs 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..d3e5bfa9af --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/command-catalog.test.cjs @@ -0,0 +1,395 @@ +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 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('an older host that omits the flags cannot silently hide the catalog', () => { + // `allow_user_invocation` defaults to true in the contract, so its absence must + // not read as a refusal; the availability flags have no such default and are + // read as `false` until the host says otherwise. + 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); + 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', () => { + const catalog = Projection.parseHostCatalog([builtinRow('plan', 'workflow')]); + const entry = Projection.rows(catalog)[0]; + assert.equal(entry.insertHint, '$plan'); + assert.equal(entry.isBuiltin, true, 'the row draws its built-in icon from this'); +}); + +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 Error\(response\.error/, + 'a refused command must reject so the caller can surface the host text'); + assert.match(hostInvoke, /return response\.value as T/); + assert.match(managerSource, /ok: boolean; value\?: T; error\?: string;/); +}); + +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..41510d0b3e --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs @@ -0,0 +1,191 @@ +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; +} + +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 { + CommandPalettePolicy: Policy, + CommandPaletteChipKind, + CommandPaletteChipState, + CommandPaletteAvailability +} = load('entry/src/main/ets/services/CommandPalettePolicy.ets'); +const composerSource = read('entry/src/main/ets/pages/components/ComposerBar.ets'); +const conversationSource = read('entry/src/main/ets/pages/components/ConversationView.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 + // one projected string and the row's presence is one boolean. + for (const declaration of [ + '@Param skillId: 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\.skillId\)/, + '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\.skillId\.length > 0\) \{/, + '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\.skillId\.length > 0 \? COMPOSER_TOKEN_STRIP_BLOCK : 0/, + 'the token row must be measured into the composer height'); + const radius = memberBody(composerSource, 'composerRadius'); + assert.match(radius, /this\.skillId\.length > 0/, + 'a card taller than the field must not keep the pill radius'); +}); + +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, skillId\)/, + 'the draft the field holds must travel with the token beside it'); + assert.match(send, /const skillId = this\.remote\.commandPalette\.skillId;/); + 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. + 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, /this\.commandPalette\.clearSkill\(\);/); + assert.match(prepare, /this\.commandPalette\.setSkill\(skillId\);/, + 'a rollback must return the token the commit would have consumed'); +}); + +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(true, 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 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'); +}); 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..26cb6221b6 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-policy.test.cjs @@ -0,0 +1,276 @@ +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, + CommandPaletteChipKind, + CommandPaletteChipState, + CommandPaletteNoticeKind +} = load('entry/src/main/ets/services/CommandPalettePolicy.ets'); + +// A session with the probe answered and nothing running: the row's baseline. +function available(overrides = {}) { + return new CommandPaletteAvailability( + overrides.probeReady ?? true, + overrides.hasSession ?? true, + overrides.isBusy ?? false, + overrides.runningCommand ?? '', + overrides.goalPending ?? false + ); +} + +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 probe enables the verbs and keeps the skills chip opening', () => { + 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 refused probe degrades the row and badges it as needing an upgrade', () => { + // The probe is the skill catalog read; a host that will not serve it will not + // serve the verbs either. The row stays on screen and stays explainable. + const state = available({probeReady: false, runningCommand: ''}); + const chips = Policy.chips(state); + for (const kind of [CommandPaletteChipKind.Compact, CommandPaletteChipKind.Init, + CommandPaletteChipKind.Usage]) { + assert.equal(chip(chips, kind).state, CommandPaletteChipState.Dimmed, `${kind} must grey out`); + assert.equal(chip(chips, kind).runsAction, false, `${kind} must not reach the host`); + assert.equal(chip(chips, kind).badgeKey, Policy.upgradeBadgeKey, `${kind} must carry the badge`); + assert.equal(chip(chips, kind).reasonKey, Policy.degradedReasonKey, `${kind} must carry the reason`); + } + // The picker is where the reason, the host's own text and the re-check live, so + // its chip is the one control that keeps working on a degraded row. + const skills = chip(chips, CommandPaletteChipKind.Skills); + assert.equal(skills.state, CommandPaletteChipState.Dimmed, 'the skills chip still reads grey'); + assert.equal(skills.runsAction, true, 'the skills chip must still open the picker'); + assert.equal(skills.badgeKey, Policy.upgradeBadgeKey, 'the skills chip carries the same badge'); + assert.equal(Policy.rowReasonKey(state), Policy.degradedReasonKey, + 'the row must say why it is grey'); +}); + +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 degraded = Policy.chips(available({probeReady: false})); + const busy = Policy.chips(available({isBusy: true})); + const sessionless = Policy.chips(available({hasSession: false})); + for (const chips of [degraded, busy, sessionless]) { + 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 [CommandPaletteChipKind.Compact, CommandPaletteChipKind.Init, + CommandPaletteChipKind.Usage]) { + 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 probe 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 verbs this + // app cannot call. It is the one chip that stays lit on a degraded row. + const degraded = Policy.chips(available({probeReady: false})); + const goal = chip(degraded, 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 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..033059e2ca 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,15 @@ 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 id is the no-token case. + commandPalette:{skillId:''} }; const controller = new RemoteTranscriptController(remote, { timeline, chat:command, connection:{ensureAvailable:()=>true}, polling:{nudge(){}}, hooks:{} }, ()=>{}); From 57dc7f8be89829eadbb154167dc5bd43f4e68bf1 Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 11:09:05 +0800 Subject: [PATCH 05/15] feat(mobile): add the command palette design preview The fixture mounts the real chip row, picker and usage sheet over a network-free host: only the four host_invoke verbs are answered, so what a screenshot shows 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. Six variants cover the states the design settled on -- the row at rest, the picker open over a grouped catalog, a picked token, a verb in flight, a settled failure with its retry, a refused probe and the usage report. The scenario id also selects them, so each is one \a start\ away. Want.parameters is untyped, so the scenario id is narrowed to a string once in onCreate; the three parallel whitelists then branch on text. --- .../main/ets/entryability/EntryAbility.ets | 12 +- .../pages/preview/CommandPalettePreview.ets | 406 ++++++++++++++++++ .../ets/pages/preview/MobileDesignGallery.ets | 3 + 3 files changed, 417 insertions(+), 4 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CommandPalettePreview.ets 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/pages/preview/CommandPalettePreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CommandPalettePreview.ets new file mode 100644 index 0000000000..6ca65e2a87 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CommandPalettePreview.ets @@ -0,0 +1,406 @@ +import { + RemoteCommandPaletteClient, + RemoteCommandPaletteController, + RemoteCommandPaletteScope, + RemoteSkillCatalogQuery +} from '../../services/RemoteCommandPaletteController'; +import { CommandCatalogProjection, RemoteModeSkillRow } from '../../services/CommandCatalogProjection'; +import { CommandPalettePolicy } from '../../services/CommandPalettePolicy'; +import { + CommandPalettePresentation, + CommandPaletteState, + commandPaletteProbeReady +} 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; a refusal throws the way + * the bridge does when a desktop does not know a command, and the held + * compaction never settles until the fixture is asked to let it. + */ +@ObservedV2 +class CommandPalettePreviewClient implements RemoteCommandPaletteClient { + refuseCatalog: boolean = false; + failCompact: boolean = false; + holdCompact: boolean = false; + private release: () => void = () => {}; + + async skillCatalog(_query: RemoteSkillCatalogQuery): Promise { + if (this.refuseCatalog) { + throw new Error('Command not found: 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 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): void => { + this.palette.setCatalogFailed(errorText); + 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.refuseCatalog = variant === 'degraded'; + this.client.failCompact = variant === 'failed'; + this.client.holdCompact = variant === 'busy'; + this.draft = variant === 'token' ? '' : 'Check the redirect handler'; + if (variant === 'sheet' || variant === 'degraded') { + this.showSkillPicker = true; + } + if (variant === 'usage') { + this.showUsageReport = true; + } + if (variant === 'token' || variant === 'busy') { + this.palette.setSkill('plan'); + } + // The catalog read is also the probe, so this is the same first move a + // session entry makes: one read decides whether the row may run 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()); + } + } + + build() { + 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')); + }) + 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, + 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) + .bindSheet($$this.showSkillPicker, this.SkillPickerSurface(), AdaptiveSheetOptions.fromPlacement( + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SkillPicker), true)) + .bindSheet($$this.showUsageReport, this.UsageReportSurface(), AdaptiveSheetOptions.fromPlacement( + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UsageReport), true)) + .onAreaChange((_old: Area, area: Area) => { + this.wide = Number(area.width) >= 600; + }) + } + + @Builder + private SkillPickerSurface() { + SkillPickerSheet({ + palette: this.presentation(), + onSelect: (skillId: string) => { + this.showSkillPicker = false; + this.dispatch(new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId)); + }, + onRefresh: () => { + void this.controller.loadCatalog('preview-session', 'agentic', true, this.canApply()); + }, + onClose: () => { + this.showSkillPicker = false; + } + }) + } + + @Builder + private UsageReportSurface() { + UsageReportSheet({ + palette: this.presentation(), + onRetry: () => { + void this.controller.loadUsageReport('preview-session', this.scope, this.canApply()); + }, + onClose: () => { + this.showUsageReport = 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); + this.stateChanged(); + return; + } + if (request.action === CommandPaletteAction.ClearSkill) { + this.palette.clearSkill(); + this.stateChanged(); + return; + } + if (request.action === CommandPaletteAction.OpenUsage || + request.action === CommandPaletteAction.RetryUsage) { + this.showUsageReport = true; + void this.controller.loadUsageReport('preview-session', this.scope, this.canApply()); + return; + } + if (request.action === CommandPaletteAction.OpenSkills || + request.action === CommandPaletteAction.RefreshCatalog) { + this.showSkillPicker = 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 = '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/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, + commandPaletteProbeReady(this.palette.catalogStatus), + true, + false + ); + } + + private stateChanged(): void { + this.revision += 1; + } + + /** What the fixture is showing: the probe's verdict and the row's own line. */ + private stateLine(): string { + const probe = commandPaletteProbeReady(this.palette.catalogStatus) ? 'probe ok' : 'probe refused'; + const notice = this.palette.notice; + const running = this.palette.runningCommand.length > 0 ? + ` · running ${this.palette.runningCommand}` : ''; + return notice.visible ? `${probe}${running} · ${RemoteI18n.t(notice.labelKey)}` : `${probe}${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')) { From 611ff519a37a2e945b856b885060d1bc407cb0ac Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 11:27:19 +0800 Subject: [PATCH 06/15] fix(mobile): let the conversation's sheets take turns on one binding Found on the emulator while verifying the palette: a node carries exactly one sheet binding, so the second and third bindSheet added to the conversation's root node were dropped -- and with them every sheet on that node, the pre-existing full-text panel included. The root now binds once and the sheet kind, not a per-sheet flag, is what the content and the options switch on; the fixture mirrors it because the same constraint applies there. A device run of each sheet is what caught this: the layout dump showed no sheet node at all while the flag the binding reads was already true. --- .../ets/pages/components/ConversationView.ets | 160 ++++++++++-------- .../pages/preview/CommandPalettePreview.ets | 100 +++++++---- .../tests/command-palette-composer.test.cjs | 21 +++ 3 files changed, 173 insertions(+), 108 deletions(-) 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 5235ebe286..d62b79a390 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 @@ -55,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(); @@ -63,25 +75,24 @@ 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. * - * 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. - */ - @Local showFullText: boolean = false; - @Local fullTextMessage: string = ''; - /** - * The two command-row sheets, opened from the 技能 and 用量 chips. + * 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. * - * Same reasoning as the full-text panel below: they are view state of this - * pane, not projected fields, because nothing outside it reads whether a sheet - * happens to be open. + * 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 showSkillPicker: boolean = false; - @Local showUsageReport: 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 * assumed: the composer grows with multiline text and attachments, and the @@ -137,27 +148,71 @@ export struct ConversationView { .width('100%') .height('100%') .backgroundColor(PAGE_BG) - .bindSheet($$this.showFullText, this.FullTextSheet(), this.fullTextSheetOptions()) - .bindSheet($$this.showSkillPicker, this.SkillPickerSheetSurface(), this.skillPickerSheetOptions()) - .bindSheet($$this.showUsageReport, this.UsageReportSheetSurface(), this.usageReportSheetOptions()) + .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) => { + this.closeSheet(); + this.onIntent(ConversationIntents.commandPalette( + new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId))); + }, + 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 skillPickerSheetOptions(): SheetOptions { - return this.adaptiveSheetOptions(this.skillPickerSheetPlacement); + 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 usageReportSheetOptions(): SheetOptions { - return this.adaptiveSheetOptions(this.usageReportSheetPlacement); + private openSheet(kind: string): void { + this.activeSheet = kind; + this.sheetPresented = true; + } + + private closeSheet(): void { + this.sheetPresented = false; } /** @@ -575,46 +630,13 @@ export struct ConversationView { */ private onCommandPaletteRequest(request: CommandPaletteRequest): void { if (request.action === CommandPaletteAction.OpenSkills) { - this.showSkillPicker = true; + this.openSheet(CONVERSATION_SHEET_SKILLS); } else if (request.action === CommandPaletteAction.OpenUsage) { - this.showUsageReport = true; + this.openSheet(CONVERSATION_SHEET_USAGE); } this.onIntent(ConversationIntents.commandPalette(request)); } - @Builder - private SkillPickerSheetSurface() { - SkillPickerSheet({ - palette: this.commandPalette, - onSelect: (skillId: string) => { - this.showSkillPicker = false; - this.onIntent(ConversationIntents.commandPalette( - new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId))); - }, - onRefresh: () => { - this.onIntent(ConversationIntents.commandPalette( - new CommandPaletteRequest(CommandPaletteAction.RefreshCatalog))); - }, - onClose: () => { - this.showSkillPicker = false; - } - }) - } - - @Builder - private UsageReportSheetSurface() { - UsageReportSheet({ - palette: this.commandPalette, - onRetry: () => { - this.onIntent(ConversationIntents.commandPalette( - new CommandPaletteRequest(CommandPaletteAction.RetryUsage))); - }, - onClose: () => { - this.showUsageReport = false; - } - }) - } - 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 @@ -829,13 +851,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/preview/CommandPalettePreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CommandPalettePreview.ets index 6ca65e2a87..7aead74049 100644 --- 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 @@ -137,6 +137,7 @@ 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. @@ -196,14 +197,16 @@ export struct CommandPalettePreview { this.client.failCompact = variant === 'failed'; this.client.holdCompact = variant === 'busy'; this.draft = variant === 'token' ? '' : 'Check the redirect handler'; + if (variant === 'token' || variant === 'busy') { + this.palette.setSkill('plan'); + } + // 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' || variant === 'degraded') { - this.showSkillPicker = true; + this.openSheet(true); } if (variant === 'usage') { - this.showUsageReport = true; - } - if (variant === 'token' || variant === 'busy') { - this.palette.setSkill('plan'); + 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 row may run anything. @@ -218,6 +221,18 @@ export struct CommandPalettePreview { } 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) @@ -281,43 +296,56 @@ export struct CommandPalettePreview { .height('100%') .padding({ left: 12, right: 12, top: 12 }) .backgroundColor(PAGE_BG) - .bindSheet($$this.showSkillPicker, this.SkillPickerSurface(), AdaptiveSheetOptions.fromPlacement( - SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SkillPicker), true)) - .bindSheet($$this.showUsageReport, this.UsageReportSurface(), AdaptiveSheetOptions.fromPlacement( - SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UsageReport), true)) + // 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 SkillPickerSurface() { - SkillPickerSheet({ - palette: this.presentation(), - onSelect: (skillId: string) => { - this.showSkillPicker = false; - this.dispatch(new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId)); - }, - onRefresh: () => { - void this.controller.loadCatalog('preview-session', 'agentic', true, this.canApply()); - }, - onClose: () => { - this.showSkillPicker = false; - } - }) + private ActiveSheet() { + if (this.showSkillPicker) { + SkillPickerSheet({ + palette: this.presentation(), + onSelect: (skillId: string) => { + this.closeSheet(); + this.dispatch(new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId)); + }, + 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(); + } + }) + } } - @Builder - private UsageReportSurface() { - UsageReportSheet({ - palette: this.presentation(), - onRetry: () => { - void this.controller.loadUsageReport('preview-session', this.scope, this.canApply()); - }, - onClose: () => { - this.showUsageReport = false; - } - }) + 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; } /** @@ -348,13 +376,13 @@ export struct CommandPalettePreview { } if (request.action === CommandPaletteAction.OpenUsage || request.action === CommandPaletteAction.RetryUsage) { - this.showUsageReport = true; + this.openSheet(false); void this.controller.loadUsageReport('preview-session', this.scope, this.canApply()); return; } if (request.action === CommandPaletteAction.OpenSkills || request.action === CommandPaletteAction.RefreshCatalog) { - this.showSkillPicker = true; + this.openSheet(true); void this.controller.loadCatalog('preview-session', 'agentic', request.action === CommandPaletteAction.RefreshCatalog, this.canApply()); return; 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 index 41510d0b3e..bd36fb436f 100644 --- a/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs @@ -161,6 +161,27 @@ test('a host command with no argument is not sendable from the row either', () = 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 conversation hands the composer both inputs from one projection', () => { const composer = conversationSource.slice(conversationSource.indexOf('ComposerBar({')); assert.match(composer.slice(0, composer.indexOf('})') + 2), From d6c43d3ba7ffec2420354a66b2145b0339869137 Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 11:28:18 +0800 Subject: [PATCH 07/15] fix(mobile): compile the catalog projection against the real row type ArkTS refuses a static member named \ ame\ -- it collides with Function.name -- so the row's display-name fallback is \ owName\, and the raw host row type belongs to the projection that declares it rather than to the models module. Also stores the preview with the line endings the rest of the tree uses. --- .../entry/src/main/ets/services/CommandCatalogProjection.ets | 4 ++-- .../entry/src/main/ets/services/RemoteSessionManager.ets | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index f4a1c1b3f6..8381dfe365 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandCatalogProjection.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandCatalogProjection.ets @@ -156,7 +156,7 @@ export class CommandCatalogProjection { const builtin = CommandCatalogProjection.flag(row.isBuiltin, row.is_builtin); parsed.push({ id, - name: CommandCatalogProjection.name(row.name, 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. @@ -368,7 +368,7 @@ export class CommandCatalogProjection { return left < right ? -1 : 1; } - private static name(name: string | undefined, fallback: string): string { + private static rowName(name: string | undefined, fallback: string): string { const resolved = CommandCatalogProjection.trimmed(name); return resolved.length > 0 ? resolved : fallback; } 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 f84aad78c3..8a856ce5df 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, RemoteCommandCatalog, RemoteImageContext, RemoteModeSkillRow, 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 { 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'; @@ -17,7 +17,7 @@ import { RemoteFileDownloadClient } from './RemoteFileDownloadController'; import { RemoteLogger } from './RemoteLogger'; import { RemoteModelClient } from './RemoteModelController'; import { RemoteCommandPaletteClient, RemoteCommandPaletteScope, RemoteSkillCatalogQuery } from './RemoteCommandPaletteController'; -import { CommandCatalogProjection } from './CommandCatalogProjection'; +import { CommandCatalogProjection, RemoteModeSkillRow } from './CommandCatalogProjection'; import { RemoteResponseMapper } from './RemoteResponseMapper'; import { stampRemoteSessionsWorkspace } from './RemoteSessionIdentity'; import { TranscriptIntegrityPolicy } from './TranscriptIntegrityPolicy'; From 6e041bce8a613c5010400086c36229c3ecc17651 Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 12:05:51 +0800 Subject: [PATCH 08/15] fix(mobile): stop the palette claiming more than it knows Five review findings, all of them the row saying something it had no evidence for. A session that was opened, left and re-entered drew a waiting row over an answer the controller still held: the cached probe answer is now handed back whenever it is not read again, because the controller outlives the page state it was painted into. A timeout was reported as an out-of-date desktop. A failed read is now classified by whether the host answered at all -- a new typed HostInvokeRefusal separates a host declining a command from a wire that never reached one -- and only a host whose own words name the version gap gets the upgrade badge. A read that is still in flight says it is waiting instead. The skill read gated all five chips, which took away three commands that exist on every host this app can talk to. Only the skills chip is gated now; compact, init and usage are enabled and answer for themselves when they run. The token carried the skill key while the desktop inserts the skill name. The row hint and the message now both use the bracketed name form, with the key kept for the pick's identity and used as the fallback when a host row carries no name. A /goal the host accepted left its chip lit, saying the objective was still missing after it had been sent. The chip is consumed by the same commit that consumes the draft, and put back by a rollback. Also corrects the projection's comment about absent flags: flag() reads them as false, and only allow_user_invocation has a true default. --- .../ets/pages/actions/ConversationIntent.ets | 18 +- .../main/ets/pages/components/ComposerBar.ets | 23 +- .../ets/pages/components/ConversationView.ets | 5 +- .../ets/pages/components/SkillPickerSheet.ets | 46 +++- .../runtime/AppRootRuntimeComposition.ets | 4 +- .../ets/pages/state/CommandPaletteState.ets | 99 +++++--- .../ets/pages/state/ConversationViewState.ets | 3 +- .../main/ets/pages/state/RemotePageState.ets | 20 +- .../viewmodel/RemoteTranscriptController.ets | 9 +- .../ets/services/CommandCatalogProjection.ets | 25 +- .../ets/services/CommandPalettePolicy.ets | 231 ++++++++++++++---- .../main/ets/services/HostInvokeRefusal.ets | 44 ++++ .../RemoteCommandPaletteController.ets | 69 ++++-- .../ets/services/RemoteSessionManager.ets | 10 +- .../main/resources/en_US/element/string.json | 24 +- .../main/resources/zh_CN/element/string.json | 24 +- 16 files changed, 519 insertions(+), 135 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/HostInvokeRefusal.ets 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 df95af3530..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 @@ -55,15 +55,29 @@ export enum CommandPaletteAction { export class CommandPaletteRequest { readonly action: CommandPaletteAction; - /** The picked skill, for `SelectSkill`; empty otherwise. */ + /** 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 = '') { + constructor( + action: CommandPaletteAction, + skillId: string = '', + wireAction: string = '', + skillLabel: string = '' + ) { this.action = action; this.skillId = skillId; this.wireAction = wireAction; + this.skillLabel = skillLabel; } } 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 d83048785c..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 @@ -77,8 +77,11 @@ export struct ComposerBar { * * 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. * @@ -162,7 +165,7 @@ export struct ComposerBar { // 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.skillId.length > 0) { + if (this.hasSkillToken()) { this.CommandTokenStrip() } if (this.selectedImages.length > 0) { @@ -701,7 +704,7 @@ export struct ComposerBar { .fontSize(12) .fontColor([INK]) .opacity(0.7) - Text(CommandPalettePolicy.skillTokenLabel(this.skillId)) + Text(CommandPalettePolicy.skillTokenLabel(this.skillLabel)) .fontSize(MobileDesignTypography.labelSmall.size) .lineHeight(MobileDesignTypography.labelSmall.lineHeight) .fontWeight(FontWeight.Medium) @@ -717,7 +720,7 @@ export struct ComposerBar { .backgroundColor(SOFT) .borderRadius(COMPOSER_TOKEN_HEIGHT / 2) .accessibilityText(`${RemoteI18n.t('commands.skillToken')} · ` + - `${CommandPalettePolicy.skillTokenLabel(this.skillId)}`) + `${CommandPalettePolicy.skillTokenLabel(this.skillLabel)}`) .onClick(() => { this.onRemoveSkillToken(); }) @@ -806,12 +809,22 @@ export struct ComposerBar { ); } + /** + * 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(); const attachmentBlock = this.selectedImages.length > 0 ? COMPOSER_IMAGE_STRIP_BLOCK : 0; - const tokenBlock = this.skillId.length > 0 ? COMPOSER_TOKEN_STRIP_BLOCK : 0; + const tokenBlock = this.hasSkillToken() ? COMPOSER_TOKEN_STRIP_BLOCK : 0; return base + attachmentBlock + tokenBlock; } @@ -822,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 || this.skillId.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/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index d62b79a390..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 @@ -169,10 +169,10 @@ export struct ConversationView { } else if (this.activeSheet === CONVERSATION_SHEET_SKILLS) { SkillPickerSheet({ palette: this.commandPalette, - onSelect: (skillId: string) => { + onSelect: (skillId: string, skillLabel: string) => { this.closeSheet(); this.onIntent(ConversationIntents.commandPalette( - new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId))); + new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId, '', skillLabel))); }, onRefresh: () => { this.onIntent(ConversationIntents.commandPalette( @@ -583,6 +583,7 @@ export struct ConversationView { chatInput: this.chatInput, selectedImages: this.selectedImages, skillId: this.commandPalette.skillId, + skillLabel: this.commandPalette.skillLabel, commandRowActive: this.commandPalette.visible, isBusy: this.isBusy, canStop: this.canStop, 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 index 12e3203d38..e06ab23e3a 100644 --- 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 @@ -8,7 +8,12 @@ import { SkillCatalogRow, SkillCatalogSection } from '../../services/CommandCatalogProjection'; -import { CommandPaletteCatalogStatus, CommandPalettePresentation } from '../state/CommandPaletteState'; +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'; @@ -42,7 +47,7 @@ const SKILL_UNAVAILABLE_OPACITY: number = 0.4; @ComponentV2 export struct SkillPickerSheet { @Param palette: CommandPalettePresentation = CommandPalettePresentation.hidden(); - @Event onSelect: (skillId: string) => void = (_skillId: string) => {}; + @Event onSelect: (skillId: string, skillLabel: string) => void = (_skillId: string, _skillLabel: string) => {}; @Event onRefresh: () => void = () => {}; @Event onClose: () => void = () => {}; @Local query: string = ''; @@ -265,7 +270,7 @@ export struct SkillPickerSheet { .borderRadius(12) .accessibilityText(`${row.name} · ${row.insertHint}`) .onClick(() => { - this.onSelect(row.id); + this.onSelect(row.id, row.name); }) } @@ -282,7 +287,15 @@ export struct SkillPickerSheet { .justifyContent(FlexAlign.Center) } - /** The catalog read answered nothing: say why, and offer the re-check. */ + /** + * 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 }) { @@ -402,10 +415,10 @@ export struct SkillPickerSheet { } /** - * The probe failed, or a chip was reached while it still had to wait. + * The read did not answer, so there is no list to show. * - * Both land on the same explanation because both mean "this desktop did not - * answer"; the difference is only which sentence says it. + * 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) { @@ -415,13 +428,24 @@ export struct SkillPickerSheet { } private unavailableTitleKey(): string { - return this.palette.rowReasonKey.length > 0 ? - 'commands.skills.degradedTitle' : 'commands.skills.unavailableTitle'; + return CommandPalettePolicy.probeTitleKey(this.failure()); } private unavailableBodyKey(): string { - return this.palette.rowReasonKey.length > 0 ? - 'commands.skills.degradedBody' : 'commands.skills.unavailableBody'; + 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 { 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 bd9830753e..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 @@ -606,9 +606,9 @@ export abstract class AppRootRuntimeComposition { if (!this.isCommandPaletteSession(sessionId)) return; this.remotePageState.commandPalette.setCatalogReady(catalog); }, - onCatalogFailed: (sessionId: string, errorText: string): void => { + onCatalogFailed: (sessionId: string, errorText: string, failure: string): void => { if (!this.isCommandPaletteSession(sessionId)) return; - this.remotePageState.commandPalette.setCatalogFailed(errorText); + this.remotePageState.commandPalette.setCatalogFailed(errorText, failure); }, onCommandStarted: (sessionId: string, wireAction: string): void => { if (!this.isCommandPaletteSession(sessionId)) return; 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 index 7740fd8bef..680676b196 100644 --- 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 @@ -1,29 +1,19 @@ import { RemoteCommandCatalog, RemoteSessionUsageReport } from '../../model/RemoteModels'; import { CommandPaletteAvailability, + CommandPaletteCatalogStatus, CommandPaletteChip, CommandPaletteChipKind, CommandPaletteChipState, CommandPaletteNoticeKind, - CommandPalettePolicy + CommandPalettePolicy, + CommandPaletteProbeFailure } from '../../services/CommandPalettePolicy'; import { UsageReportPresentationPolicy, UsageReportSection } from '../policy/UsageReportPresentationPolicy'; -/** Lifecycle of the catalog read that doubles as the palette's behavior probe. */ -export enum CommandPaletteCatalogStatus { - /** No read yet: the row's chips wait rather than act. */ - Idle = 'idle', - /** The probe is in flight. */ - Loading = 'loading', - /** The host answered; its verbs are reachable. */ - Ready = 'ready', - /** The host refused the read. The row degrades and the sheet offers a re-check. */ - Failed = 'failed' -} - export enum CommandPaletteUsageStatus { Idle = 'idle', Loading = 'loading', @@ -31,11 +21,6 @@ export enum CommandPaletteUsageStatus { Failed = 'failed' } -/** Whether the probe's answer lets the row act at all. */ -export function commandPaletteProbeReady(status: string): boolean { - return status === CommandPaletteCatalogStatus.Ready; -} - /** * What the chip row says under itself after a verb ran. * @@ -83,11 +68,19 @@ export class CommandPaletteState { @Trace catalogStatus: string = CommandPaletteCatalogStatus.Idle; @Trace catalog: RemoteCommandCatalog | undefined = undefined; /** - * The host's own refusal text for a failed probe. + * Why the skill read failed, when it did. * - * A palette 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. + * 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 = ''; @@ -95,30 +88,43 @@ export class CommandPaletteState { @Trace usageStatus: string = CommandPaletteUsageStatus.Idle; @Trace usageReport: RemoteSessionUsageReport | undefined = undefined; @Trace usageErrorText: string = ''; - /** The single selected skill token, or '' when the composer carries none. */ + /** 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; - availability(probeReady: boolean, hasSession: boolean, isBusy: boolean): CommandPaletteAvailability { + /** + * 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( - probeReady, hasSession, isBusy, this.runningCommand, this.goalPending); + 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): void { + setCatalogFailed(errorText: string, failure: string): void { this.catalog = undefined; this.catalogErrorText = errorText; + this.catalogFailure = failure; this.catalogStatus = CommandPaletteCatalogStatus.Failed; } @@ -172,12 +178,21 @@ export class CommandPaletteState { this.usageStatus = CommandPaletteUsageStatus.Failed; } - setSkill(skillId: string): void { - this.skillId = CommandPalettePolicy.applySkillSelection(this.skillId, skillId); + /** 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 { @@ -188,6 +203,7 @@ export class CommandPaletteState { reset(): void { this.catalogStatus = CommandPaletteCatalogStatus.Idle; this.catalog = undefined; + this.catalogFailure = CommandPaletteProbeFailure.None; this.catalogErrorText = ''; this.runningCommand = ''; this.notice = CommandPaletteNotice.none(); @@ -195,6 +211,7 @@ export class CommandPaletteState { this.usageReport = undefined; this.usageErrorText = ''; this.skillId = ''; + this.skillLabel = ''; this.goalPending = false; } } @@ -208,18 +225,23 @@ export class CommandPaletteState { export class CommandPalettePresentation { readonly visible: boolean; readonly chips: CommandPaletteChip[]; - /** Persistent explanation under the row: a session-less or degraded surface. */ + /** Persistent explanation under the row: a session-less surface. */ readonly rowReasonKey: string; readonly notice: CommandPaletteNotice; readonly catalogStatus: string; readonly catalog?: RemoteCommandCatalog; - /** The host's refusal text for a failed probe, empty otherwise. */ + /** 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, @@ -228,12 +250,14 @@ export class CommandPalettePresentation { notice: CommandPaletteNotice, catalogStatus: string, catalog: RemoteCommandCatalog | undefined, + catalogFailure: string, catalogErrorText: string, usageStatus: string, usageSections: UsageReportSection[], usagePartial: boolean, usageErrorText: string, - skillId: string + skillId: string, + skillLabel: string ) { this.visible = visible; this.chips = chips; @@ -241,32 +265,33 @@ export class CommandPalettePresentation { 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, '', - CommandPaletteUsageStatus.Idle, [], false, '', ''); + CommandPaletteCatalogStatus.Idle, undefined, CommandPaletteProbeFailure.None, '', + CommandPaletteUsageStatus.Idle, [], false, '', '', ''); } static project( state: CommandPaletteState, - probeReady: boolean, hasSession: boolean, isBusy: boolean ): CommandPalettePresentation { if (!CommandPalettePolicy.visible(hasSession)) { return CommandPalettePresentation.hidden(); } - const availability = state.availability(probeReady, hasSession, isBusy); + const availability = state.availability(hasSession, isBusy); const usage = UsageReportPresentationPolicy.present(state.usageReport); return new CommandPalettePresentation( true, @@ -275,12 +300,14 @@ export class CommandPalettePresentation { state.notice, state.catalogStatus, state.catalog, + state.catalogFailure, state.catalogErrorText, state.usageStatus, usage.sections, usage.partial, state.usageErrorText, - state.skillId); + state.skillId, + state.skillLabel); } chipState(kind: CommandPaletteChipKind): CommandPaletteChipState { 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 d97ace4e01..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,7 +22,7 @@ import { AppRoute } from '../navigation/AppRouteContract'; import { ConversationCoreState } from './ConversationCoreState'; import { GeneralChatPageState } from './GeneralChatPageState'; import { RemotePageState } from './RemotePageState'; -import { CommandPalettePresentation, commandPaletteProbeReady } from './CommandPaletteState'; +import { CommandPalettePresentation } from './CommandPaletteState'; /** Immutable presentation state consumed by ConversationView. */ export class ConversationViewState { @@ -94,7 +94,6 @@ export class ConversationViewState { state.interactionMailbox = remote.interactionMailbox; state.commandPalette = CommandPalettePresentation.project( remote.commandPalette, - commandPaletteProbeReady(remote.commandPalette.catalogStatus), 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 4118541144..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 @@ -352,11 +352,13 @@ export class RemotePageState { } /** - * Draft ownership for a send, including the palette's skill token. + * Draft ownership for a send, including the palette's two local picks. * - * The token is a chip beside the field rather than text inside it, so it has - * to travel with the same ownership rules as the draft: consumed by the commit - * that the host accepted, and put back by the rollback that it did not. + * 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, @@ -365,6 +367,8 @@ export class RemotePageState { ): 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; @@ -372,11 +376,17 @@ export class RemotePageState { 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); + this.commandPalette.setSkill(skillId, skillLabel); + } + if (goalPending && !this.commandPalette.goalPending) { + this.commandPalette.setGoalPending(true); } } }; 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 dae2ce6627..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 @@ -112,7 +112,7 @@ export class RemoteTranscriptController { return; } if (request.action === CommandPaletteAction.SelectSkill) { - palette.setSkill(request.skillId); + palette.setSkill(request.skillId, request.skillLabel); return; } if (request.action === CommandPaletteAction.ClearSkill) { @@ -221,9 +221,10 @@ export class RemoteTranscriptController { 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. - const skillId = this.remote.commandPalette.skillId; - const text = rawText.length > 0 ? CommandPalettePolicy.composeSubmissionText(rawText, skillId) : + // 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/services/CommandCatalogProjection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandCatalogProjection.ets index 8381dfe365..b9ea144e39 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandCatalogProjection.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandCatalogProjection.ets @@ -5,6 +5,7 @@ import { 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. @@ -28,9 +29,10 @@ 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: `selectedForRuntime` - * absent means "not stated", which the availability rule reads as reachable - * only when the host said nothing that contradicts it. + * 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; @@ -60,7 +62,13 @@ export class SkillCatalogRow { readonly group: string; readonly source: string; readonly isBuiltin: boolean; - /** The text the row says it will insert, e.g. `$plan`. */ + /** + * 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) { @@ -70,7 +78,7 @@ export class SkillCatalogRow { this.group = entry.group; this.source = entry.source; this.isBuiltin = isBuiltin; - this.insertHint = `$${entry.id}`; + this.insertHint = CommandPalettePolicy.skillTokenLabel(entry.name); } } @@ -368,6 +376,13 @@ export class CommandCatalogProjection { 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; 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 index 3c4467fd90..3c86514538 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandPalettePolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandPalettePolicy.ets @@ -4,10 +4,62 @@ * * 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 runs a verb before - * the host has answered the probe, and never rewrites a draft the user owns. + * 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 the + * desktop's command registry answers `Command not found:` for a name it does not + * carry. Both say the same thing about the command set, which is the only claim + * the palette makes; every other refusal keeps the host's text without turning + * it into a version verdict. + */ +const VERSION_GAP_MARKERS: string[] = [ + 'unknown to this openbitfun', + 'peer host version', + 'command not found' +]; + /** The five chips of the composer command row, in their on-screen order. */ export enum CommandPaletteChipKind { Compact = 'compact', @@ -30,11 +82,13 @@ export enum CommandPaletteChipState { /** * Why a chip is not ready, and therefore what the row says about it. * - * There is no capability to consult: a failed probe is the only reason the whole - * row degrades, and a busy session is the only reason a single chip does. + * 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 host did not answer the skill catalog read. */ + /** The skill read has not answered, or answered with a failure. */ Probe = 'probe', /** Another palette verb is still running. */ Busy = 'busy', @@ -51,8 +105,10 @@ export enum CommandPaletteNoticeKind { /** What the row is allowed to do, as a snapshot of the live surface. */ export class CommandPaletteAvailability { - /** The host answered the skill catalog read, so its verbs are reachable. */ - readonly probeReady: boolean; + /** 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. */ @@ -61,18 +117,24 @@ export class CommandPaletteAvailability { readonly goalPending: boolean; constructor( - probeReady: boolean, + probeStatus: string, + probeFailure: string, hasSession: boolean, isBusy: boolean, runningCommand: string = '', goalPending: boolean = false ) { - this.probeReady = probeReady; + 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. */ @@ -130,6 +192,9 @@ export class CommandPalettePolicy { 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. @@ -189,25 +254,14 @@ export class CommandPalettePolicy { availability.goalPending ? CommandPaletteChipState.Pending : CommandPaletteChipState.Ready, labelKey, '', false, '', true); } - if (!availability.probeReady) { - // The skills chip keeps opening, because the sheet is where the reason and - // the 「重新检查」 live; the verbs have nothing to run, so their taps only - // reveal the explanation the chip already carries. - return new CommandPaletteChip( - kind, - CommandPaletteChipState.Dimmed, - labelKey, - CommandPalettePolicy.upgradeBadgeKey, - chevron, - CommandPalettePolicy.degradedReasonKey, - kind === CommandPaletteChipKind.Skills); - } - // Picking a skill only edits the draft, so the sheet opens even while a turn - // or another palette verb is running. + // 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 new CommandPaletteChip( - kind, CommandPaletteChipState.Ready, labelKey, '', true, '', true); + 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( @@ -222,6 +276,88 @@ export class CommandPalettePolicy { 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 the part the app can be sure of: the host replied to this very + * command. Its wording then decides whether the reply was the version gap it + * names explicitly or a refusal for some other reason, and a read that never + * reached a host is neither. + */ + static probeFailure(answered: boolean, hostText: string): string { + if (!answered) { + return CommandPaletteProbeFailure.Unreachable; + } + const text = hostText.toLowerCase(); + 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; @@ -234,11 +370,15 @@ export class CommandPalettePolicy { return 'commands.sessionReason'; } - /** A degraded row always carries its explanation under the chips. */ + /** + * 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 { - if (!availability.hasSession) return CommandPalettePolicy.sessionReasonKey(); - if (!availability.probeReady) return CommandPalettePolicy.degradedReasonKey; - return ''; + return availability.hasSession ? '' : CommandPalettePolicy.sessionReasonKey(); } /** Whether the palette renders at all: one chip row needs a session to act on. */ @@ -325,7 +465,9 @@ export class CommandPalettePolicy { * 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 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(); @@ -338,10 +480,17 @@ export class CommandPalettePolicy { return picked; } - /** The token as the composer draws it: the exact text the message will carry. */ - static skillTokenLabel(skillId: string): string { - const id = skillId.trim(); - return id.length === 0 ? '' : `[$${id}]`; + /** + * 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. */ @@ -354,9 +503,9 @@ export class CommandPalettePolicy { * 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. 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. + * 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`: @@ -365,10 +514,10 @@ export class CommandPalettePolicy { * 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]`. + * which is what stops a repeated pick from stacking `[$Plan] [$Plan]`. */ - static composeSubmissionText(draft: string, skillId: string): string { - const token = CommandPalettePolicy.skillTokenLabel(skillId); + static composeSubmissionText(draft: string, skillLabel: string): string { + const token = CommandPalettePolicy.skillTokenLabel(skillLabel); const text = draft.trim(); if (token.length === 0) { return draft; 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 index 100f46f957..231fe000cb 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandPaletteController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandPaletteController.ets @@ -2,8 +2,9 @@ import { RemoteCommandCatalog, RemoteSessionUsageReport } from '../model/RemoteModels'; -import { CommandPaletteNoticeKind, CommandPalettePolicy } from './CommandPalettePolicy'; +import { CommandPaletteNoticeKind, CommandPalettePolicy, CommandPaletteProbeFailure } from './CommandPalettePolicy'; import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; +import { hostInvokeRefusalText, isHostInvokeRefusal } from './HostInvokeRefusal'; import { RemoteLogger } from './RemoteLogger'; /** @@ -45,10 +46,11 @@ export interface RemoteCommandPaletteCallbacks { onCatalogLoading: (sessionId: string) => void; onCatalogReady: (sessionId: string, catalog: RemoteCommandCatalog) => void; /** - * The read failed. This is the probe's negative answer — the row degrades and - * the sheet offers 「重新检查」, which replays it. + * 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) => void; + 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; @@ -62,10 +64,11 @@ export interface RemoteCommandPaletteCallbacks { /** * How far the palette's behavior probe has got for the active session. * - * There is no capability handshake to consult, so the row reads this instead: - * `Probed` is the only state whose chips may reach the host, `Unprobed` is where - * every session starts (and reads like a busy row for one round trip), and - * `Failed` is a desktop that refused the read and needs a re-check or an update. + * 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', @@ -80,6 +83,8 @@ export class RemoteCommandPaletteController { 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. */ @@ -108,6 +113,11 @@ export class RemoteCommandPaletteController { 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 : ''; @@ -119,6 +129,7 @@ export class RemoteCommandPaletteController { this.catalogSessionId = ''; this.catalog = undefined; this.catalogProbe = CommandPaletteProbe.Unprobed; + this.catalogFailure = CommandPaletteProbeFailure.None; this.catalogErrorText = ''; this.runningAction = ''; } @@ -133,8 +144,12 @@ export class RemoteCommandPaletteController { * * `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 nothing, which is - * what keeps this off the render path. + * 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, @@ -147,10 +162,12 @@ export class RemoteCommandPaletteController { } 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; @@ -175,14 +192,36 @@ export class RemoteCommandPaletteController { if (!canApply(sessionId)) { return; } - // A host that does not serve the read is a degraded palette, not a hidden - // one: the row greys itself and the sheet says why, with a way to re-check. + // 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 = ConnectionErrorPolicy.errorText(err); + this.catalogErrorText = errorText; + this.catalogFailure = CommandPalettePolicy.probeFailure(answered, errorText); RemoteLogger.warn( - `command catalog failed session=${RemoteCommandPaletteController.shortSessionId(sessionId)}: ${String(err)}`); - this.callbacks.onCatalogFailed(sessionId, this.catalogErrorText); + `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); } } 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 8a856ce5df..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 @@ -17,6 +17,7 @@ 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'; @@ -101,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; } 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 bc07e7907f..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 @@ -2762,7 +2762,7 @@ }, { "name": "commands_degradedReason", - "value": "The connected desktop is too old for these commands. Upgrade OpenBitFun on that device; you can still send messages in this session." + "value": "The connected desktop is too old to list skills. Upgrade OpenBitFun on that device; messages still send in this session." }, { "name": "commands_busyReason", @@ -2850,7 +2850,7 @@ }, { "name": "commands_skills_unavailableBody", - "value": "The connected desktop did not answer the skill list. Upgrade OpenBitFun on that device, then check again." + "value": "The connected desktop did not answer the skill list. Check the connection to that device, then check again." }, { "name": "commands_skills_degradedTitle", @@ -3015,6 +3015,26 @@ { "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 aaf7932207..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 @@ -2762,7 +2762,7 @@ }, { "name": "commands_degradedReason", - "value": "已连接的桌面端版本过旧,无法使用这些命令。请升级该设备上的 OpenBitFun 后使用;当前会话仍可直接发消息。" + "value": "桌面端版本过旧,无法提供技能列表。请升级该设备上的 OpenBitFun;本会话仍可正常发送消息。" }, { "name": "commands_busyReason", @@ -2850,7 +2850,7 @@ }, { "name": "commands_skills_unavailableBody", - "value": "已连接的桌面端未返回技能列表。请升级该设备上的 OpenBitFun 后重新检查。" + "value": "桌面端没有响应技能列表请求。请检查与该设备的连接后重试。" }, { "name": "commands_skills_degradedTitle", @@ -3015,6 +3015,26 @@ { "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": "桌面端有响应,但拒绝了请求。下方是桌面端的原文;请检查会话后重试。" } ] } From 99c43d85be99ea2a0160c30205845be4df0de998 Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 12:06:05 +0800 Subject: [PATCH 09/15] test(mobile): pin the palette's gating, tokens and re-entry The policy matrix is rewritten to the reviewed rule: a failed skill read greys the picker's chip and leaves the three session verbs alone, and only a host that named the version gap is badged as needing an update. The failure classification is covered for all three of its outcomes, and the waiting chip is checked for carrying no badge and no version sentence. The controller test drives the production controller and the production page state through the sequence that produced the false degradation: read, state reset, re-enter the same session. It asserts the second entry reads nothing and still ends with a ready picker and the cached catalog, that a failed read comes back as the same failure kind, and that an answer for a session the user left is dropped. The catalog tests now compare the row's hint with the token the message actually carries rather than with a key-shaped string, cover the name-fallback token, and the flag test says what it pins: absent availability flags read as false. --- .../tools/tests/command-catalog.test.cjs | 56 ++++- .../tests/command-palette-composer.test.cjs | 53 +++-- .../tests/command-palette-controller.test.cjs | 186 ++++++++++++++++ .../tests/command-palette-policy.test.cjs | 198 ++++++++++++++---- .../tools/tests/session-record.test.cjs | 5 +- 5 files changed, 430 insertions(+), 68 deletions(-) create mode 100644 src/apps/mobile/harmonyos/tools/tests/command-palette-controller.test.cjs diff --git a/src/apps/mobile/harmonyos/tools/tests/command-catalog.test.cjs b/src/apps/mobile/harmonyos/tools/tests/command-catalog.test.cjs index d3e5bfa9af..0512957033 100644 --- a/src/apps/mobile/harmonyos/tools/tests/command-catalog.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/command-catalog.test.cjs @@ -53,6 +53,7 @@ const { 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'); @@ -174,10 +175,11 @@ test('only the skill the host offered to the user is offered by the picker', () assert.deepEqual(ids(catalog), ['plan']); }); -test('an older host that omits the flags cannot silently hide the catalog', () => { - // `allow_user_invocation` defaults to true in the contract, so its absence must - // not read as a refusal; the availability flags have no such default and are - // read as `false` until the host says otherwise. +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'} @@ -186,7 +188,10 @@ test('an older host that omits the flags cannot silently hide the catalog', () = 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); + 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); }); @@ -323,10 +328,29 @@ test('an empty catalog is distinguishable from a filtered-out one', () => { }); test('the token label the picker hints at is the one the message carries', () => { - const catalog = Projection.parseHostCatalog([builtinRow('plan', 'workflow')]); + // 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.insertHint, '$plan'); + 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', () => { @@ -338,10 +362,24 @@ test('hostInvoke unwraps the bridge envelope instead of the command value', () = assert.match(hostInvoke, /command: command/); assert.match(hostInvoke, /args: args/); assert.match(hostInvoke, /if \(!response\.ok\)/); - assert.match(hostInvoke, /throw new Error\(response\.error/, - 'a refused command must reject so the caller can surface the host text'); + 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', () => { 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 index bd36fb436f..a8a9551d54 100644 --- a/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs @@ -42,9 +42,11 @@ function memberBody(source, name) { const { CommandPalettePolicy: Policy, + CommandPaletteAvailability, + CommandPaletteCatalogStatus, CommandPaletteChipKind, CommandPaletteChipState, - CommandPaletteAvailability + CommandPaletteProbeFailure } = load('entry/src/main/ets/services/CommandPalettePolicy.ets'); const composerSource = read('entry/src/main/ets/pages/components/ComposerBar.ets'); const conversationSource = read('entry/src/main/ets/pages/components/ConversationView.ets'); @@ -52,9 +54,11 @@ const transcriptSource = read('entry/src/main/ets/pages/viewmodel/RemoteTranscri 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 - // one projected string and the row's presence is one boolean. + // 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' ]) { @@ -66,7 +70,7 @@ test('the composer takes the token and the row as inputs, and gives the removal test('the token draws the exact text the message will carry', () => { const strip = memberBody(composerSource, 'CommandTokenStrip'); - assert.match(strip, /CommandPalettePolicy\.skillTokenLabel\(this\.skillId\)/, + 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'); @@ -77,7 +81,7 @@ test('the token strip only mounts when a skill is picked', () => { 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\.skillId\.length > 0\) \{/, + /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'); @@ -87,11 +91,13 @@ 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\.skillId\.length > 0 \? COMPOSER_TOKEN_STRIP_BLOCK : 0/, + 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\.skillId\.length > 0/, + 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', () => { @@ -104,32 +110,50 @@ test('a bare goal withholds Send without changing the local surface', () => { }); 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', '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, skillId\)/, + assert.match(send, /CommandPalettePolicy\.composeSubmissionText\(rawText, skillLabel\)/, 'the draft the field holds must travel with the token beside it'); - assert.match(send, /const skillId = this\.remote\.commandPalette\.skillId;/); + 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. + // 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\);/, + assert.match(prepare, /this\.commandPalette\.setSkill\(skillId, skillLabel\);/, 'a rollback must return the token the commit would have consumed'); }); +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. + const state = read('entry/src/main/ets/pages/state/RemotePageState.ets'); + const prepare = state.slice(state.indexOf('prepareComposerSubmission('), + state.indexOf('setVoiceListening(')); + assert.match(prepare, /const goalPending = this\.commandPalette\.goalPending;/); + assert.match(prepare, /if \(goalPending\) \{\s*\n\s*this\.commandPalette\.setGoalPending\(false\);\s*\n\s*\}/, + 'the commit that consumed the draft consumes the lit chip'); + assert.match(prepare, /if \(goalPending && !this\.commandPalette\.goalPending\) \{\s*\n\s*this\.commandPalette\.setGoalPending\(true\);\s*\n\s*\}/, + 'a rollback puts the chip back exactly as the commit found it'); + assert.ok(prepare.indexOf('setGoalPending(false)') < prepare.indexOf('rollback:'), + 'the chip is cleared on commit and only restored by a rollback'); +}); + test('the goal chip prefills the draft and takes it back on a second tap', () => { const dispatch = transcriptSource.slice( transcriptSource.indexOf('async dispatchCommandPalette('), @@ -154,7 +178,8 @@ test('the goal chip prefills the draft and takes it back on a second tap', () => 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(true, true, false, '', 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'); 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..407042aeb2 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-controller.test.cjs @@ -0,0 +1,186 @@ +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; here they only have to exist, + // and a class decorator must hand the class back or the class is replaced by it. + const decorator = (target) => target; + 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 index 26cb6221b6..b6eb57c9fd 100644 --- a/src/apps/mobile/harmonyos/tools/tests/command-palette-policy.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-policy.test.cjs @@ -17,15 +17,19 @@ function load(relativePath) { const { CommandPalettePolicy: Policy, CommandPaletteAvailability, + CommandPaletteCatalogStatus, CommandPaletteChipKind, CommandPaletteChipState, - CommandPaletteNoticeKind + CommandPaletteNoticeKind, + CommandPaletteProbeFailure, + commandPaletteProbeReady } = load('entry/src/main/ets/services/CommandPalettePolicy.ets'); -// A session with the probe answered and nothing running: the row's baseline. +// A session whose skill read answered and which is idle: the row's baseline. function available(overrides = {}) { return new CommandPaletteAvailability( - overrides.probeReady ?? true, + overrides.probeStatus ?? CommandPaletteCatalogStatus.Ready, + overrides.probeFailure ?? CommandPaletteProbeFailure.None, overrides.hasSession ?? true, overrides.isBusy ?? false, overrides.runningCommand ?? '', @@ -33,13 +37,17 @@ function available(overrides = {}) { ); } +// 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 probe enables the verbs and keeps the skills chip opening', () => { +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]) { @@ -54,35 +62,89 @@ test('an answered probe enables the verbs and keeps the skills chip opening', () 'a healthy row must not explain itself'); }); -test('a refused probe degrades the row and badges it as needing an upgrade', () => { - // The probe is the skill catalog read; a host that will not serve it will not - // serve the verbs either. The row stays on screen and stays explainable. - const state = available({probeReady: false, runningCommand: ''}); - const chips = Policy.chips(state); - for (const kind of [CommandPaletteChipKind.Compact, CommandPaletteChipKind.Init, - CommandPaletteChipKind.Usage]) { - assert.equal(chip(chips, kind).state, CommandPaletteChipState.Dimmed, `${kind} must grey out`); - assert.equal(chip(chips, kind).runsAction, false, `${kind} must not reach the host`); - assert.equal(chip(chips, kind).badgeKey, Policy.upgradeBadgeKey, `${kind} must carry the badge`); - assert.equal(chip(chips, kind).reasonKey, Policy.degradedReasonKey, `${kind} must carry the reason`); +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'); } - // The picker is where the reason, the host's own text and the re-check live, so - // its chip is the one control that keeps working on a degraded row. - const skills = chip(chips, CommandPaletteChipKind.Skills); - assert.equal(skills.state, CommandPaletteChipState.Dimmed, 'the skills chip still reads grey'); - assert.equal(skills.runsAction, true, 'the skills chip must still open the picker'); - assert.equal(skills.badgeKey, Policy.upgradeBadgeKey, 'the skills chip carries the same badge'); - assert.equal(Policy.rowReasonKey(state), Policy.degradedReasonKey, - 'the row must say why it is grey'); }); 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 degraded = Policy.chips(available({probeReady: false})); + const waiting = Policy.chips(available({probeStatus: CommandPaletteCatalogStatus.Loading})); const busy = Policy.chips(available({isBusy: true})); const sessionless = Policy.chips(available({hasSession: false})); - for (const chips of [degraded, busy, sessionless]) { + 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; @@ -97,8 +159,7 @@ test('a busy session gates the verbs but never the draft', () => { // 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 [CommandPaletteChipKind.Compact, CommandPaletteChipKind.Init, - CommandPaletteChipKind.Usage]) { + 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`); @@ -128,12 +189,15 @@ test('a verb in flight owns its chip and holds the others back', () => { 'the picker does not collide with a running verb'); }); -test('the goal chip is not gated by the probe or by a capability', () => { +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 verbs this - // app cannot call. It is the one chip that stays lit on a degraded row. - const degraded = Policy.chips(available({probeReady: false})); - const goal = chip(degraded, CommandPaletteChipKind.Goal); + // 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'); @@ -233,8 +297,8 @@ test('the goal prefill only ever fills an empty draft', () => { }); 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('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', @@ -244,14 +308,62 @@ test('a skill token survives the operator keeping or replacing it', () => { '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.VersionGap, + "the desktop's command registry says the same thing in its own words"); + 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 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]', + 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', + 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('', ''), ''); }); @@ -267,10 +379,10 @@ test('a host command keeps the head of the message', () => { 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]', + 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 033059e2ca..72232c44cb 100644 --- a/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs @@ -413,8 +413,9 @@ test('after host restart the same conversation sends a new turn instead of steer connectionState:'connected', activeSession:{sessionId:'session',agentType:'code'}, activeTurnMessage:timeline.activeTurnOrEmpty(), 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 id is the no-token case. - commandPalette:{skillId:''} }; + // 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:{} }, ()=>{}); From 67be52f66dd46f3bca3fbc1f80634710b383bdba Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 12:06:12 +0800 Subject: [PATCH 10/15] fix(mobile): make the palette preview show the states the row can reach The fixture could only show a refusal, which is now three different outcomes: a host naming the version gap, a host refusing for a reason of its own, and a wire that answered nothing. Each is a scenario of its own, so a screenshot can be read against the words a person would get. It also gains the read that has not answered yet (the waiting chip, with no badge, and the sheet deliberately left shut so the row is what shows), and the two halves of the goal chip's ownership rule: lit with its prefill in the draft, and the state the commit leaves behind. The token scenarios pass the skill name with the key, and the fixture's state line names the failure kind, so a screenshot records which of the three the frame is about. --- .../pages/preview/CommandPalettePreview.ets | 121 +++++++++++++----- 1 file changed, 92 insertions(+), 29 deletions(-) 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 index 7aead74049..7408009042 100644 --- 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 @@ -5,12 +5,14 @@ import { RemoteSkillCatalogQuery } from '../../services/RemoteCommandPaletteController'; import { CommandCatalogProjection, RemoteModeSkillRow } from '../../services/CommandCatalogProjection'; -import { CommandPalettePolicy } from '../../services/CommandPalettePolicy'; import { - CommandPalettePresentation, - CommandPaletteState, + CommandPaletteCatalogStatus, + CommandPalettePolicy, + CommandPaletteProbeFailure, commandPaletteProbeReady -} from '../state/CommandPaletteState'; +} 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'; @@ -80,20 +82,35 @@ const PREVIEW_USAGE: RemoteSessionUsageReport = { /** * The host, minus the network. * - * Only the four `host_invoke` verbs are answered here; a refusal throws the way - * the bridge does when a desktop does not know a command, and the held - * compaction never settles until the fixture is asked to let it. + * 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 { - refuseCatalog: boolean = false; + /** '' | 'version_gap' | 'refused' | 'unreachable' | 'pending'. */ + catalogOutcome: string = ''; failCompact: boolean = false; holdCompact: boolean = false; private release: () => void = () => {}; async skillCatalog(_query: RemoteSkillCatalogQuery): Promise { - if (this.refuseCatalog) { - throw new Error('Command not found: get_mode_skill_configs'); + 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') { + throw new Error('request timed out'); } return CommandCatalogProjection.parseHostCatalog(PREVIEW_SKILL_ROWS); } @@ -160,8 +177,8 @@ export struct CommandPalettePreview { this.palette.setCatalogReady(catalog); this.stateChanged(); }, - onCatalogFailed: (_sessionId: string, errorText: string): void => { - this.palette.setCatalogFailed(errorText); + onCatalogFailed: (_sessionId: string, errorText: string, failure: string): void => { + this.palette.setCatalogFailed(errorText, failure); this.stateChanged(); }, onCommandStarted: (_sessionId: string, wireAction: string): void => { @@ -193,23 +210,36 @@ export struct CommandPalettePreview { aboutToAppear(): void { const variant = this.variant(); - this.client.refuseCatalog = variant === 'degraded'; + this.client.catalogOutcome = CommandPalettePreview.catalogOutcomeFor(variant); this.client.failCompact = variant === 'failed'; this.client.holdCompact = variant === 'busy'; - this.draft = variant === 'token' ? '' : 'Check the redirect handler'; + this.draft = variant === 'token' || variant === 'goal' ? '' : 'Check the redirect handler'; if (variant === 'token' || variant === 'busy') { - this.palette.setSkill('plan'); + 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' || variant === 'degraded') { + 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 row may run anything. + // 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, @@ -220,6 +250,38 @@ export struct CommandPalettePreview { } } + /** + * 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 for a reason of its own, and a wire that + * answered nothing. + */ + 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() } @@ -262,7 +324,7 @@ export struct CommandPalettePreview { Button('Token') .id('palette-token') .onClick(() => { - this.dispatch(new CommandPaletteRequest(CommandPaletteAction.SelectSkill, 'plan')); + this.dispatch(new CommandPaletteRequest(CommandPaletteAction.SelectSkill, 'plan', '', 'Plan')); }) Button('Release') .id('palette-release') @@ -283,6 +345,7 @@ export struct CommandPalettePreview { 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; @@ -309,9 +372,9 @@ export struct CommandPalettePreview { if (this.showSkillPicker) { SkillPickerSheet({ palette: this.presentation(), - onSelect: (skillId: string) => { + onSelect: (skillId: string, skillLabel: string) => { this.closeSheet(); - this.dispatch(new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId)); + this.dispatch(new CommandPaletteRequest(CommandPaletteAction.SelectSkill, skillId, '', skillLabel)); }, onRefresh: () => { void this.controller.loadCatalog('preview-session', 'agentic', true, this.canApply()); @@ -365,7 +428,7 @@ export struct CommandPalettePreview { return; } if (request.action === CommandPaletteAction.SelectSkill) { - this.palette.setSkill(request.skillId); + this.palette.setSkill(request.skillId, request.skillLabel); this.stateChanged(); return; } @@ -406,25 +469,25 @@ export struct CommandPalettePreview { } private presentation(): CommandPalettePresentation { - return CommandPalettePresentation.project( - this.palette, - commandPaletteProbeReady(this.palette.catalogStatus), - true, - false - ); + return CommandPalettePresentation.project(this.palette, true, false); } private stateChanged(): void { this.revision += 1; } - /** What the fixture is showing: the probe's verdict and the row's own line. */ + /** 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}${running} · ${RemoteI18n.t(notice.labelKey)}` : `${probe}${running}`; + return notice.visible ? + `${probe}${failure}${goal}${running} · ${RemoteI18n.t(notice.labelKey)}` : + `${probe}${failure}${goal}${running}`; } private variant(): string { From 35ba6cbc6c606417bbf6e3f7e664063e96d7d268 Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 12:29:17 +0800 Subject: [PATCH 11/15] fix(mobile): tell a refused command from a bridge that never arrived `host_invoke` answers ok:false for two unrelated events, and the palette had one bucket for both. The bridge's own failures -- the app handle not up yet, a request that could not be emitted, a closed channel, the 120s invoke timeout -- are read as the command having been refused, so the sheet told the user the desktop had answered and the row could badge the desktop as out of date on a timeout. Those wordings now classify as unreachable, which is the same kind a wire that answered nothing gets, and they are checked before anything else so a bridge failure never inherits a version verdict. `Command not found:` is a command-level refusal rather than the host naming a version, so it keeps the refusal copy and earns no badge; only the peer-host registry's sentence about the peer host version does. The goal chip's draft ownership is now driven instead of pattern-matched: the test loads the real page state, calls its prepareComposerSubmission, and asserts the commit clears the chip while the rollback restores it with the token's name. Driving it also fixed the decorator stub both loaders share -- returning the target from a member decorator freezes a copy of the prototype onto every field that has no constructor assignment, which is what kept the page state's collaborators unreachable. --- .../pages/preview/CommandPalettePreview.ets | 9 +- .../ets/services/CommandPalettePolicy.ets | 49 +++++++--- .../tests/command-palette-composer.test.cjs | 96 ++++++++++++++++--- .../tests/command-palette-controller.test.cjs | 8 +- .../tests/command-palette-policy.test.cjs | 38 +++++++- 5 files changed, 166 insertions(+), 34 deletions(-) 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 index 7408009042..b5d21bd5f8 100644 --- 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 @@ -110,7 +110,10 @@ class CommandPalettePreviewClient implements RemoteCommandPaletteClient { throw new HostInvokeRefusal('get_mode_skill_configs', 'workspace not found for this session'); } if (this.catalogOutcome === 'unreachable') { - throw new Error('request timed out'); + // 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); } @@ -255,8 +258,8 @@ export struct CommandPalettePreview { * * 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 for a reason of its own, and a wire that - * answered nothing. + * 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') { 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 index 3c86514538..dd61a92904 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandPalettePolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CommandPalettePolicy.ets @@ -48,16 +48,34 @@ export function commandPaletteProbeReady(status: string): boolean { /** * 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 the - * desktop's command registry answers `Command not found:` for a name it does not - * carry. Both say the same thing about the command set, which is the only claim - * the palette makes; every other refusal keeps the host's text without turning - * it into a version verdict. + * `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', - 'command not found' + '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. */ @@ -340,16 +358,25 @@ export class CommandPalettePolicy { /** * Classifies a failed skill read. * - * `answered` is the part the app can be sure of: the host replied to this very - * command. Its wording then decides whether the reply was the version gap it - * names explicitly or a refusal for some other reason, and a read that never - * reached a host is neither. + * `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; 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 index a8a9551d54..af4a6f3b2b 100644 --- a/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs @@ -4,13 +4,23 @@ const fs = require('node:fs'); const path = require('node:path'); const ts = require('typescript'); -function load(relativePath) { +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} + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + experimentalDecorators: true + } }).outputText; const exported = {}; - new Function('require', 'exports', js)(() => ({}), 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; } @@ -40,6 +50,7 @@ function memberBody(source, name) { throw new Error(`unbalanced ${name}() body`); } +const policy = load('entry/src/main/ets/services/CommandPalettePolicy.ets'); const { CommandPalettePolicy: Policy, CommandPaletteAvailability, @@ -47,7 +58,16 @@ const { CommandPaletteChipKind, CommandPaletteChipState, CommandPaletteProbeFailure -} = load('entry/src/main/ets/services/CommandPalettePolicy.ets'); +} = 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 transcriptSource = read('entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets'); @@ -139,19 +159,65 @@ test('an accepted send consumes the token and a refused one puts it back', () => '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. - const state = read('entry/src/main/ets/pages/state/RemotePageState.ets'); - const prepare = state.slice(state.indexOf('prepareComposerSubmission('), - state.indexOf('setVoiceListening(')); - assert.match(prepare, /const goalPending = this\.commandPalette\.goalPending;/); - assert.match(prepare, /if \(goalPending\) \{\s*\n\s*this\.commandPalette\.setGoalPending\(false\);\s*\n\s*\}/, - 'the commit that consumed the draft consumes the lit chip'); - assert.match(prepare, /if \(goalPending && !this\.commandPalette\.goalPending\) \{\s*\n\s*this\.commandPalette\.setGoalPending\(true\);\s*\n\s*\}/, - 'a rollback puts the chip back exactly as the commit found it'); - assert.ok(prepare.indexOf('setGoalPending(false)') < prepare.indexOf('rollback:'), - 'the chip is cleared on commit and only restored by a rollback'); + // `/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', () => { 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 index 407042aeb2..1e10f532e6 100644 --- a/src/apps/mobile/harmonyos/tools/tests/command-palette-controller.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-controller.test.cjs @@ -16,9 +16,11 @@ function load(relativePath, stubs = {}) { } }).outputText; const exported = {}; - // ArkTS decorators are globals in the framework; here they only have to exist, - // and a class decorator must hand the class back or the class is replaced by it. - const decorator = (target) => target; + // 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; 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 index b6eb57c9fd..e48703ce7d 100644 --- a/src/apps/mobile/harmonyos/tools/tests/command-palette-policy.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-policy.test.cjs @@ -331,8 +331,8 @@ test('a failed skill read is classified by whether the host answered', () => { 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.VersionGap, - "the desktop's command registry says the same thing in its own words"); + 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'); @@ -356,6 +356,40 @@ test('a failed skill read is classified by whether the host answered', () => { 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'); From 9ad7c75571dc50c000d224c88182a8c6a233b8e9 Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 14:27:42 +0800 Subject: [PATCH 12/15] fix(mobile): inset the skill picker's search box inside the sheet The bordered search box carried `.width('100%')` and a horizontal margin at the same time. A margin lands outside a percentage width, so the box was laid out at the sheet's own width and then pushed its border past the parent: on device it rendered flush against the sheet's left and right edges while the filter chips below it kept their 20vp gutter. The inset now belongs to a full-width wrapper Row, and the bordered box fills the width left inside it. Measured on the emulator from a `uitest dumpLayout` tree of the preview's skills sheet: the box spans x 70..1185 of the 1256px sheet, which is 20.00vp of inset on each side at the 3.5px/vp the box's own 42vp height calibrates to, against 0px/0px before the change. Both insets are equal, and the same dump puts the filter chip's left edge on the same 70px. `command-palette-composer.test.cjs` pins the structure rather than the screenshot: the builder carries no margin at all, the width and the sheet's own padding constant meet on one node, and the bordered box keeps its border inside that wrapper with the search geometry it had. --- .../ets/pages/components/SkillPickerSheet.ets | 54 ++++++++++--------- .../tests/command-palette-composer.test.cjs | 33 ++++++++++++ 2 files changed, 63 insertions(+), 24 deletions(-) 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 index e06ab23e3a..887fa0e0c5 100644 --- 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 @@ -101,32 +101,38 @@ export struct SkillPickerSheet { @Builder SearchBox() { - 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; - }) + 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%') - .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) - .margin({ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING, top: 12 }) + .padding({ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING, top: 12 }) } @Builder 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 index af4a6f3b2b..59b0907cc9 100644 --- a/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs @@ -70,6 +70,7 @@ const paletteState = load('entry/src/main/ets/pages/state/CommandPaletteState.et 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 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', () => { @@ -273,6 +274,38 @@ test('the conversation keeps one sheet binding and names the sheet instead', () 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), From b8758dc8e7d778b9e5d04315815853fc9fd578bf Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 14:54:27 +0800 Subject: [PATCH 13/15] fix(mobile): give ink-filled chips a theme-aware content color The selected filter chip and the palette row's pending chip are filled with INK, which inverts per theme, but both took their label and glyph from CONTENT_ON_ACTION. That token is pinned to #FFFFFF in the light and the dark theme alike, so in dark mode the two chips painted white text on INK's near-white #F4F3EF: 1.11:1, which is not a contrast ratio so much as a missing glyph. CONTENT_ON_INK is the semantic inverse of INK -- it resolves to page_bg, the surface INK is defined against -- and the two INK fills now use it: SkillPickerSheet.ets the selected filter chip's label CommandChipRow.ets the pending chip's label and glyph CONTENT_ON_ACTION is untouched everywhere the fill is PRIMARY_ACTION, where it remains the right token; no other call site changes. Measured from the emulator's own screenshots at 3.5px/vp, chip fill and content read as the mode's two colors rather than as one: light filter chip #171717 fill, #FFFFFF content 17.93:1 dark filter chip #F4F3EF fill, #100F0B content 17.27:1 dark goal chip #F4F3EF fill, #121210 content 16.89:1 `command-palette-composer.test.cjs` pins it in the source: the alias resolves to page_bg and not to the action color, the selected filter chip and both pending-chip accessors take the ink inverse, and the fill each one contrasts with is still INK. It also rejects a Theme import that still carries CONTENT_ON_ACTION, which is the shape the bug had. --- .../ets/pages/components/CommandChipRow.ets | 6 +-- .../ets/pages/components/SkillPickerSheet.ets | 4 +- .../src/main/ets/pages/components/Theme.ets | 6 +++ .../tests/command-palette-composer.test.cjs | 51 +++++++++++++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) 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 index da88f1f887..97aeb9f0b6 100644 --- 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 @@ -5,7 +5,7 @@ import { CommandPaletteNotice, CommandPalettePresentation } from '../state/Comma import { CommandPaletteAction, CommandPaletteRequest } from '../actions/ConversationIntent'; import { ComposerPresentation } from './ComposerBar'; import { - CONTENT_ON_ACTION, + CONTENT_ON_INK, INK, LINE, MUTED, @@ -252,12 +252,12 @@ export struct CommandChipRow { } private chipLabelColor(chip: CommandPaletteChip): ResourceColor { - if (chip.state === CommandPaletteChipState.Pending) return CONTENT_ON_ACTION; + 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_ACTION; + if (chip.state === CommandPaletteChipState.Pending) return CONTENT_ON_INK; return chip.state === CommandPaletteChipState.Dimmed ? MUTED : INK; } 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 index 887fa0e0c5..cfb8e1bbcf 100644 --- 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 @@ -24,7 +24,7 @@ import { SHEET_HORIZONTAL_PADDING, SHEET_TOP_RADIUS } from './SheetLayout'; -import { CARD, CONTENT_ON_ACTION, INK, LINE, MUTED, PAGE_BG, SOFT, SUBTLE, TRANSPARENT } from './Theme'; +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; @@ -144,7 +144,7 @@ export struct SkillPickerSheet { .fontSize(MobileDesignTypography.labelSmall.size) .lineHeight(MobileDesignTypography.labelSmall.lineHeight) .fontWeight(FontWeight.Medium) - .fontColor(this.filterId === filter.id ? CONTENT_ON_ACTION : MUTED) + .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 }) 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/tools/tests/command-palette-composer.test.cjs b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs index 59b0907cc9..9946539913 100644 --- a/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs @@ -71,6 +71,8 @@ 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 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', () => { @@ -334,3 +336,52 @@ test('the row mounts once, above the composer, for every posture', () => { 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`); + } +}); From ab150457086f82d4aeb6b6eaec08a209f75cf2d5 Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 15:13:16 +0800 Subject: [PATCH 14/15] fix(mobile): extend the ink-inverse content color to the workspace tools panel The file-action submit, the editor save and the terminal create buttons all fill with INK, and CONTENT_ON_ACTION stays #FFFFFF in both themes while INK inverts. In dark mode that painted white content on the near-white INK fill. Route them through CONTENT_ON_INK, the same ink-inverse the palette chips use, and pin every ink-filled button in the panel to it. --- .../pages/components/WorkspaceToolsPanel.ets | 8 +- .../tests/command-palette-composer.test.cjs | 80 +++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) 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/tools/tests/command-palette-composer.test.cjs b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs index 9946539913..177416ed90 100644 --- a/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs @@ -50,6 +50,60 @@ function memberBody(source, name) { throw new Error(`unbalanced ${name}() body`); } +// Reads the index of the paren that closes the call opening at `open`. +function callEnd(source, open) { + let depth = 0; + for (let index = open; index < source.length; index++) { + if (source[index] === '(') { + depth += 1; + } else if (source[index] === ')') { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + throw new Error('unbalanced call'); +} + +// Every `Button` in a source file with its modifier chain collapsed to one line, +// so an assertion can name the button and every fill/ink pair on it. The scan +// stops at the call's own close paren and skips a builder body, so a label call +// such as RemoteI18n.t('x') neither leaks into the chain nor merges two buttons. +function buttonChains(source) { + const chains = []; + const marker = /(^|[^\w.])Button\(/g; + for (let match = marker.exec(source); match !== null; match = marker.exec(source)) { + let cursor = callEnd(source, match.index + match[1].length + 'Button'.length) + 1; + let chain = 'Button'; + for (;;) { + while (/\s/.test(source[cursor] ?? '')) { + cursor += 1; + } + if (source[cursor] === '{') { + let depth = 0; + for (; cursor < source.length; cursor++) { + if (source[cursor] === '{') depth += 1; + else if (source[cursor] === '}' && --depth === 0) break; + } + cursor += 1; + continue; + } + const modifier = /^\.(\w+)\(/.exec(source.slice(cursor)); + if (modifier === null) { + break; + } + const open = cursor + modifier[0].length - 1; + const close = callEnd(source, open); + chain += `.${modifier[1]}${source.slice(open, close + 1).replace(/\s+/g, ' ')}`; + cursor = close + 1; + } + chains.push(chain); + marker.lastIndex = cursor; + } + return chains; +} + const policy = load('entry/src/main/ets/services/CommandPalettePolicy.ets'); const { CommandPalettePolicy: Policy, @@ -72,6 +126,7 @@ 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'); @@ -385,3 +440,28 @@ test('an ink-filled chip takes its content from the ink-inverse, not from the ac `${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'); +}); From 633f6059f90528afcd3bce503a118c11b4aa11d0 Mon Sep 17 00:00:00 2001 From: Tant Date: Sat, 10 Oct 2026 15:23:17 +0800 Subject: [PATCH 15/15] fix(mobile): word the browse confirm button with the ink inverse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder browser's confirm button fills with INK and took its content from CONTENT_ON_ACTION, which stays #FFFFFF in both themes while INK inverts — white on a near-white fill in dark mode. It now uses the same CONTENT_ON_INK the other ink surfaces do. The contrast pin becomes a component-wide net: every ink-filled node in pages/components is checked, so the next sighting of this class fails the suite instead of shipping. --- .../components/SidebarWorkspacePicker.ets | 4 +- .../tests/command-palette-composer.test.cjs | 145 +++++++++++++++--- 2 files changed, 126 insertions(+), 23 deletions(-) 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/tools/tests/command-palette-composer.test.cjs b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs index 177416ed90..b6bd0dc663 100644 --- a/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/command-palette-composer.test.cjs @@ -50,43 +50,63 @@ function memberBody(source, name) { throw new Error(`unbalanced ${name}() body`); } -// Reads the index of the paren that closes the call opening at `open`. -function callEnd(source, open) { +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++) { - if (source[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 (source[index] === ')') { + } else if (char === close) { depth -= 1; if (depth === 0) { return index; } } } - throw new Error('unbalanced call'); + throw new Error(`unbalanced ${source[open]}`); } -// Every `Button` in a source file with its modifier chain collapsed to one line, -// so an assertion can name the button and every fill/ink pair on it. The scan -// stops at the call's own close paren and skips a builder body, so a label call -// such as RemoteI18n.t('x') neither leaks into the chain nor merges two buttons. -function buttonChains(source) { +// 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.])Button\(/g; + const marker = /(^|[^\w.])([A-Z]\w*)\(/g; for (let match = marker.exec(source); match !== null; match = marker.exec(source)) { - let cursor = callEnd(source, match.index + match[1].length + 'Button'.length) + 1; - let chain = 'Button'; + 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] === '{') { - let depth = 0; - for (; cursor < source.length; cursor++) { - if (source[cursor] === '{') depth += 1; - else if (source[cursor] === '}' && --depth === 0) break; - } - cursor += 1; + cursor = matchEnd(source, cursor) + 1; continue; } const modifier = /^\.(\w+)\(/.exec(source.slice(cursor)); @@ -94,16 +114,54 @@ function buttonChains(source) { break; } const open = cursor + modifier[0].length - 1; - const close = callEnd(source, open); + const close = matchEnd(source, open); chain += `.${modifier[1]}${source.slice(open, close + 1).replace(/\s+/g, ' ')}`; cursor = close + 1; } chains.push(chain); - marker.lastIndex = cursor; + // 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, @@ -465,3 +523,48 @@ test('the workspace tools panel words its ink-filled actions with the ink invers 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})`); +});