Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/apps/mobile/harmonyos/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ fake RPC: send must clear the input before pressing **Acknowledge**, and a
**Next draft** entered while pending must survive acknowledgment. Exercise both
compact and wide layouts and return to normal `EntryAbility` afterward.

For rollback reply ownership and session switching, run
`node --test tools/tests/rollback-session-isolation.test.cjs`.
In the `copy-rollback` native preview, stage a rollback, use **Resize**, and verify
the preview remains for the same session. Use **Switch session** and verify the
staging bar and full-text panel close while `SESSION-B-DRAFT` remains untouched.
Exercise compact and wide widths in light and dark modes, then restore normal
`EntryAbility`. Deferred-response unit tests cover switching while rollback,
send acknowledgment, and voice shutdown are pending; the preview uses a mock
host and is not evidence of live relay or physical fold behavior.
After installing the debug HAP on a wide device/emulator, run
`python3 tools/check-rollback-session-isolation.py --hdc "$HDC" --device <target>`
for compact/wide, light/dark and live resize checks with layout/screenshot evidence.

For history loading and explicit jump-to-bottom navigation, install the debug
HAP and run `python3 tools/check-history-scroll.py --hdc "$HDC"`. The
`history-scroll` preview holds a mock history response while the production
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,21 @@ 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 === 'copy-rollback' || scenarioId === 'copy-rollback-dark' ||
scenarioId === 'copy-rollback-send' || scenarioId === 'copy-rollback-send-dark' ||
scenarioId === 'copy-rollback-send-fail' || scenarioId === 'copy-rollback-fail' ||
scenarioId === 'copy-rollback-fail-dark' || scenarioId === 'copy-rollback-stale' ||
scenarioId === 'copy-rollback-busy' || scenarioId === 'copy-rollback-unsupported' ||
scenarioId === 'interaction-mailbox' || scenarioId === 'interaction-mailbox-dark') {
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 === 'copy-rollback-dark' || scenarioId === 'copy-rollback-send-dark' || scenarioId === 'copy-rollback-fail-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 === 'copy-rollback' || scenarioId === 'copy-rollback-send' || scenarioId === 'copy-rollback-send-fail' || scenarioId === 'copy-rollback-fail' || scenarioId === 'copy-rollback-stale' || scenarioId === 'copy-rollback-busy' || scenarioId === 'copy-rollback-unsupported'
? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT
: ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET;
this.context.getApplicationContext().setColorMode(colorMode);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ export interface RemoteCommand {
reason?: string;
mode?: RemotePermissionMode;
answers?: Object;
// rollback_session_to_turn: the user turn being withdrawn and the storage
// index the caller last saw for it. Omitting the index only widens the race
// window; it never changes which turn the host addresses.
target_turn_id?: string;
expected_storage_turn_index?: number;
image_contexts?: RemoteImageContext[];
images?: ImageAttachment[];
// provision_peer_device. `request_id` is minted by the device being
Expand Down Expand Up @@ -361,9 +366,41 @@ export interface SteerTurnResult {
steeringId: string;
}

/**
* Answer to `rollback_session_to_turn`, sent as `resp: "session_rolled_back"`.
*
* `retired_turn_ids` includes the target turn itself. `composer_text` is the
* prompt the desktop would have put back into its composer, offered so the
* client can show the same edit-and-resend continuation; the host omits the key
* when it has no prompt to offer. The transcript itself is repaired by the next
* poll's authoritative snapshot, not by this answer.
*/
export interface SessionRolledBackResponse extends CommandStatusResponse {
session_id?: string;
retired_turn_ids?: string[];
restored_files?: string[];
composer_text?: string;
changed?: boolean;
}

/** The decoded rollback answer, with absent host fields kept as absence. */
export interface SessionRolledBackResult {
sessionId: string;
retiredTurnIds: string[];
restoredFiles: string[];
composerText?: string;
changed: boolean;
}

export const REMOTE_CAPABILITY_DIALOG_STEER_V1: string = 'dialog_steer_v1';
export const REMOTE_CAPABILITY_PLAN_BUILD_V1: string = 'plan_build_v1';
export const REMOTE_CAPABILITY_HARNESS_PROFILES_V1: string = 'harness_profiles_v1';
/**
* The host can retire a turn and every turn after it, then restore the files
* those turns touched (`rollback_session_to_turn`). A host without it answers
* the command with an error, so the client checks before offering the entry.
*/
export const REMOTE_CAPABILITY_SESSION_ROLLBACK_V1: string = 'session_rollback_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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export enum ConversationIntentType {
OpenFilePreview = 'open_file_preview',
DownloadFile = 'download_file',
BuildPlan = 'build_plan',
RollbackSession = 'rollback_session',
RollbackResync = 'rollback_resync',
RollbackNotice = 'rollback_notice',
Send = 'send',
VoiceInput = 'voice_input',
ChatInputChanged = 'chat_input_changed'
Expand All @@ -41,6 +44,26 @@ export class PlanBuildRequest {
}
}

/**
* One staged rollback the view asks the host for.
*
* `sendText` is what makes the two exits one command: an empty value is the plain
* confirm, which leaves the composer alone, while a non-empty value is the
* send-while-staging exit, where the rollback has to complete before that text is
* sent from the withdrawn point. Both travel together because the order between
* them is the whole contract and splitting them into two intents would let a
* caller reverse it.
*/
export class SessionRollbackRequest {
readonly targetTurnId: string;
readonly sendText: string;

constructor(targetTurnId: string, sendText: string = '') {
this.targetTurnId = targetTurnId;
this.sendText = sendText;
}
}

/**
* What a `CopyMessage` intent asks the clipboard owner to write.
*
Expand All @@ -65,6 +88,10 @@ export class ConversationIntent {
readonly answers?: ConversationUiQuestionAnswer;
readonly filePreviewRequest?: FilePreviewRequest;
readonly planBuildRequest?: PlanBuildRequest;
// Assigned by the factory rather than through the constructor, the same way
// `mailboxAction` is: the class's positional constructor is already long, and a
// payload only one intent type reads does not belong in every other one's call.
rollbackRequest?: SessionRollbackRequest;
readonly copyScope: MessageCopyScope;
mailboxAction?: InteractionMailboxAction;

Expand Down Expand Up @@ -154,4 +181,30 @@ export class ConversationIntents {
new PlanBuildRequest(path, name)
);
}

/**
* The one rollback intent, for both staged exits.
*
* `sendText` is empty for a confirm and non-empty for send-while-staging; the
* host command itself is identical, and the viewmodel decides whether a message
* follows it.
*/
static rollback(targetTurnId: string, sendText: string = ''): ConversationIntent {
const intent = new ConversationIntent(ConversationIntentType.RollbackSession);
intent.rollbackRequest = new SessionRollbackRequest(targetTurnId, sendText);
return intent;
}

/** Refetch the authoritative transcript after a host outcome this client refused. */
static rollbackResync(): ConversationIntent {
return new ConversationIntent(ConversationIntentType.RollbackResync);
}

/**
* Say one refusal out loud. The value is a dotted i18n key, not a sentence: the
* policy owns which key a given denial resolves to and this app owns no copy.
*/
static rollbackNotice(key: string): ConversationIntent {
return new ConversationIntent(ConversationIntentType.RollbackNotice, key);
}
}
Original file line number Diff line number Diff line change
@@ -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, MessageCopyScope, SessionRollbackRequest } from './ConversationIntent';
import { toRemoteQuestionAnswer } from '../state/ConversationUiModels';
import { FilePreviewRequest } from '../../model/FilePreviewTarget';

Expand All @@ -26,6 +26,17 @@ export interface ConversationIntentDispatcherHooks {
readonly openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void;
readonly downloadFile: (path: string) => void;
readonly buildPlan: (path: string, name: string) => Promise<void>;
/**
* Run one staged rollback against the host.
*
* It resolves only when the host has answered, because a send-while-staging
* exit has to append its message after the turn the rollback retired.
*/
readonly rollback: (request: SessionRollbackRequest) => Promise<void>;
/** Refetch the transcript when a host outcome left this client unsure. */
readonly rollbackResync: () => Promise<void>;
/** Show one rollback refusal, keyed by i18n key. */
readonly rollbackNotice: (key: string) => void;
readonly send: () => Promise<void>;
readonly voiceInput: () => Promise<void>;
readonly inputChanged: (route: AppRoute, value: string) => void;
Expand Down Expand Up @@ -70,6 +81,10 @@ export class ConversationIntentDispatcher {
void this.hooks.buildPlan(intent.planBuildRequest.path, intent.planBuildRequest.name);
}
return;
case ConversationIntentType.RollbackSession:
if (intent.rollbackRequest) void this.hooks.rollback(intent.rollbackRequest); return;
case ConversationIntentType.RollbackResync: void this.hooks.rollbackResync(); return;
case ConversationIntentType.RollbackNotice: this.hooks.rollbackNotice(intent.value); return;
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,24 @@ export struct ChatUserMessageRow {
detail: ''
};
@Param showRetryAction: boolean = false;
/** Whether the staged rollback's preview covers this row. See ChatTimeline. */
@Param ghost: boolean = false;
@Event onCopyMessage: (text: string) => void = (_text: string) => {};
@Event onOpenFullText: (text: string) => void = (_text: string) => {};
@Event onOpenFullText: (text: string, turnId: string) => void =
(_text: string, _turnId: string) => {};
@Event onRetryMessage: (text: string) => void = (_text: string) => {};
@Event onRollbackMessage: (turnId: string, text: string) => void =
(_turnId: string, _text: string) => {};

build() {
ChatUserMessageBubble({
item: this.item,
showRetryAction: this.showRetryAction,
ghost: this.ghost,
onCopyMessage: this.onCopyMessage,
onOpenFullText: this.onOpenFullText,
onRetryMessage: this.onRetryMessage
onRetryMessage: this.onRetryMessage,
onRollbackMessage: this.onRollbackMessage
})
}
}
Expand Down Expand Up @@ -84,7 +91,18 @@ export struct ChatMessageBubble {
@Event onCopyMessage: (text: string) => void = (_text: string) => {};
@Event onCopyMessageScoped: (text: string, scope: MessageCopyScope) => void =
(_text: string, _scope: MessageCopyScope) => {};
@Event onOpenFullText: (text: string) => void = (_text: string) => {};
@Event onOpenFullText: (text: string, turnId: string) => void =
(_text: string, _turnId: string) => {};
/**
* The rollback entry, forwarded for the user-authored bubbles this row draws.
*
* A steering message is drawn with the user bubble, so it carries the entry
* too. It has no turn of its own, which is why the entry is wired rather than
* hidden: the tap reaches the pane and is answered out loud, instead of a menu
* item that silently does nothing.
*/
@Event onRollbackMessage: (turnId: string, text: string) => void =
(_turnId: string, _text: string) => {};
@Event onRetryMessage: (text: string) => void = (_text: string) => {};
@Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {};
@Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {};
Expand Down Expand Up @@ -381,7 +399,8 @@ export struct ChatMessageBubble {
item: this.steeringMessage(entry, path),
showRetryAction: false,
onCopyMessage: this.onCopyMessage,
onOpenFullText: this.onOpenFullText
onOpenFullText: this.onOpenFullText,
onRollbackMessage: this.onRollbackMessage
})
} else if (this.isThinkingEntry(entry)) {
this.Thinking(
Expand Down
Loading
Loading