Skip to content

Commit c39bfb3

Browse files
authored
Merge pull request #3372 from wgqqqqq/wgq/mobile-rollback-cross-device-sync
feat(mobile): staged rollback with cross-device session synchronization
2 parents f7ab259 + 5a63cb5 commit c39bfb3

45 files changed

Lines changed: 4060 additions & 52 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/apps/mobile/harmonyos/AGENTS.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,19 @@ fake RPC: send must clear the input before pressing **Acknowledge**, and a
100100
**Next draft** entered while pending must survive acknowledgment. Exercise both
101101
compact and wide layouts and return to normal `EntryAbility` afterward.
102102

103+
For rollback reply ownership and session switching, run
104+
`node --test tools/tests/rollback-session-isolation.test.cjs`.
105+
In the `copy-rollback` native preview, stage a rollback, use **Resize**, and verify
106+
the preview remains for the same session. Use **Switch session** and verify the
107+
staging bar and full-text panel close while `SESSION-B-DRAFT` remains untouched.
108+
Exercise compact and wide widths in light and dark modes, then restore normal
109+
`EntryAbility`. Deferred-response unit tests cover switching while rollback,
110+
send acknowledgment, and voice shutdown are pending; the preview uses a mock
111+
host and is not evidence of live relay or physical fold behavior.
112+
After installing the debug HAP on a wide device/emulator, run
113+
`python3 tools/check-rollback-session-isolation.py --hdc "$HDC" --device <target>`
114+
for compact/wide, light/dark and live resize checks with layout/screenshot evidence.
115+
103116
For history loading and explicit jump-to-bottom navigation, install the debug
104117
HAP and run `python3 tools/check-history-scroll.py --hdc "$HDC"`. The
105118
`history-scroll` preview holds a mock history response while the production

‎src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,21 @@ export default class EntryAbility extends UIAbility {
2020
scenarioId === 'cold-start-home' || scenarioId === 'cold-start-home-dark' ||
2121
scenarioId === 'copy-selection' || scenarioId === 'copy-selection-dark' ||
2222
scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' ||
23+
scenarioId === 'copy-rollback' || scenarioId === 'copy-rollback-dark' ||
24+
scenarioId === 'copy-rollback-send' || scenarioId === 'copy-rollback-send-dark' ||
25+
scenarioId === 'copy-rollback-send-fail' || scenarioId === 'copy-rollback-fail' ||
26+
scenarioId === 'copy-rollback-fail-dark' || scenarioId === 'copy-rollback-stale' ||
27+
scenarioId === 'copy-rollback-busy' || scenarioId === 'copy-rollback-unsupported' ||
2328
scenarioId === 'interaction-mailbox' || scenarioId === 'interaction-mailbox-dark') {
2429
this.initialPage = 'pages/preview/MobileDesignGallery';
2530
this.previewStorage = new LocalStorage();
2631
this.previewStorage.setOrCreate('scenarioId', scenarioId);
2732
AppStorage.setOrCreate('scenarioId', scenarioId);
2833
}
2934
try {
30-
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'
35+
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'
3136
? ConfigurationConstant.ColorMode.COLOR_MODE_DARK
32-
: 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'
37+
: 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'
3338
? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT
3439
: ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET;
3540
this.context.getApplicationContext().setColorMode(colorMode);

‎src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ export interface RemoteCommand {
159159
reason?: string;
160160
mode?: RemotePermissionMode;
161161
answers?: Object;
162+
// rollback_session_to_turn: the user turn being withdrawn and the storage
163+
// index the caller last saw for it. Omitting the index only widens the race
164+
// window; it never changes which turn the host addresses.
165+
target_turn_id?: string;
166+
expected_storage_turn_index?: number;
162167
image_contexts?: RemoteImageContext[];
163168
images?: ImageAttachment[];
164169
// provision_peer_device. `request_id` is minted by the device being
@@ -361,9 +366,41 @@ export interface SteerTurnResult {
361366
steeringId: string;
362367
}
363368

369+
/**
370+
* Answer to `rollback_session_to_turn`, sent as `resp: "session_rolled_back"`.
371+
*
372+
* `retired_turn_ids` includes the target turn itself. `composer_text` is the
373+
* prompt the desktop would have put back into its composer, offered so the
374+
* client can show the same edit-and-resend continuation; the host omits the key
375+
* when it has no prompt to offer. The transcript itself is repaired by the next
376+
* poll's authoritative snapshot, not by this answer.
377+
*/
378+
export interface SessionRolledBackResponse extends CommandStatusResponse {
379+
session_id?: string;
380+
retired_turn_ids?: string[];
381+
restored_files?: string[];
382+
composer_text?: string;
383+
changed?: boolean;
384+
}
385+
386+
/** The decoded rollback answer, with absent host fields kept as absence. */
387+
export interface SessionRolledBackResult {
388+
sessionId: string;
389+
retiredTurnIds: string[];
390+
restoredFiles: string[];
391+
composerText?: string;
392+
changed: boolean;
393+
}
394+
364395
export const REMOTE_CAPABILITY_DIALOG_STEER_V1: string = 'dialog_steer_v1';
365396
export const REMOTE_CAPABILITY_PLAN_BUILD_V1: string = 'plan_build_v1';
366397
export const REMOTE_CAPABILITY_HARNESS_PROFILES_V1: string = 'harness_profiles_v1';
398+
/**
399+
* The host can retire a turn and every turn after it, then restore the files
400+
* those turns touched (`rollback_session_to_turn`). A host without it answers
401+
* the command with an error, so the client checks before offering the entry.
402+
*/
403+
export const REMOTE_CAPABILITY_SESSION_ROLLBACK_V1: string = 'session_rollback_v1';
367404
/** The host resolves `workspace_id` in commands and stamps it on its projections. */
368405
export const REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1: string = 'workspace_id_references_v1';
369406

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export enum ConversationIntentType {
2626
OpenFilePreview = 'open_file_preview',
2727
DownloadFile = 'download_file',
2828
BuildPlan = 'build_plan',
29+
RollbackSession = 'rollback_session',
30+
RollbackResync = 'rollback_resync',
31+
RollbackNotice = 'rollback_notice',
2932
Send = 'send',
3033
VoiceInput = 'voice_input',
3134
ChatInputChanged = 'chat_input_changed'
@@ -41,6 +44,26 @@ export class PlanBuildRequest {
4144
}
4245
}
4346

47+
/**
48+
* One staged rollback the view asks the host for.
49+
*
50+
* `sendText` is what makes the two exits one command: an empty value is the plain
51+
* confirm, which leaves the composer alone, while a non-empty value is the
52+
* send-while-staging exit, where the rollback has to complete before that text is
53+
* sent from the withdrawn point. Both travel together because the order between
54+
* them is the whole contract and splitting them into two intents would let a
55+
* caller reverse it.
56+
*/
57+
export class SessionRollbackRequest {
58+
readonly targetTurnId: string;
59+
readonly sendText: string;
60+
61+
constructor(targetTurnId: string, sendText: string = '') {
62+
this.targetTurnId = targetTurnId;
63+
this.sendText = sendText;
64+
}
65+
}
66+
4467
/**
4568
* What a `CopyMessage` intent asks the clipboard owner to write.
4669
*
@@ -65,6 +88,10 @@ export class ConversationIntent {
6588
readonly answers?: ConversationUiQuestionAnswer;
6689
readonly filePreviewRequest?: FilePreviewRequest;
6790
readonly planBuildRequest?: PlanBuildRequest;
91+
// Assigned by the factory rather than through the constructor, the same way
92+
// `mailboxAction` is: the class's positional constructor is already long, and a
93+
// payload only one intent type reads does not belong in every other one's call.
94+
rollbackRequest?: SessionRollbackRequest;
6895
readonly copyScope: MessageCopyScope;
6996
mailboxAction?: InteractionMailboxAction;
7097

@@ -154,4 +181,30 @@ export class ConversationIntents {
154181
new PlanBuildRequest(path, name)
155182
);
156183
}
184+
185+
/**
186+
* The one rollback intent, for both staged exits.
187+
*
188+
* `sendText` is empty for a confirm and non-empty for send-while-staging; the
189+
* host command itself is identical, and the viewmodel decides whether a message
190+
* follows it.
191+
*/
192+
static rollback(targetTurnId: string, sendText: string = ''): ConversationIntent {
193+
const intent = new ConversationIntent(ConversationIntentType.RollbackSession);
194+
intent.rollbackRequest = new SessionRollbackRequest(targetTurnId, sendText);
195+
return intent;
196+
}
197+
198+
/** Refetch the authoritative transcript after a host outcome this client refused. */
199+
static rollbackResync(): ConversationIntent {
200+
return new ConversationIntent(ConversationIntentType.RollbackResync);
201+
}
202+
203+
/**
204+
* Say one refusal out loud. The value is a dotted i18n key, not a sentence: the
205+
* policy owns which key a given denial resolves to and this app owns no copy.
206+
*/
207+
static rollbackNotice(key: string): ConversationIntent {
208+
return new ConversationIntent(ConversationIntentType.RollbackNotice, key);
209+
}
157210
}

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { InteractionMailboxAction } from '../../model/InteractionMailbox';
22
import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteModels';
33
import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract';
4-
import { ConversationIntent, ConversationIntentType, MessageCopyScope } from './ConversationIntent';
4+
import { ConversationIntent, ConversationIntentType, MessageCopyScope, SessionRollbackRequest } from './ConversationIntent';
55
import { toRemoteQuestionAnswer } from '../state/ConversationUiModels';
66
import { FilePreviewRequest } from '../../model/FilePreviewTarget';
77

@@ -26,6 +26,17 @@ export interface ConversationIntentDispatcherHooks {
2626
readonly openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void;
2727
readonly downloadFile: (path: string) => void;
2828
readonly buildPlan: (path: string, name: string) => Promise<void>;
29+
/**
30+
* Run one staged rollback against the host.
31+
*
32+
* It resolves only when the host has answered, because a send-while-staging
33+
* exit has to append its message after the turn the rollback retired.
34+
*/
35+
readonly rollback: (request: SessionRollbackRequest) => Promise<void>;
36+
/** Refetch the transcript when a host outcome left this client unsure. */
37+
readonly rollbackResync: () => Promise<void>;
38+
/** Show one rollback refusal, keyed by i18n key. */
39+
readonly rollbackNotice: (key: string) => void;
2940
readonly send: () => Promise<void>;
3041
readonly voiceInput: () => Promise<void>;
3142
readonly inputChanged: (route: AppRoute, value: string) => void;
@@ -70,6 +81,10 @@ export class ConversationIntentDispatcher {
7081
void this.hooks.buildPlan(intent.planBuildRequest.path, intent.planBuildRequest.name);
7182
}
7283
return;
84+
case ConversationIntentType.RollbackSession:
85+
if (intent.rollbackRequest) void this.hooks.rollback(intent.rollbackRequest); return;
86+
case ConversationIntentType.RollbackResync: void this.hooks.rollbackResync(); return;
87+
case ConversationIntentType.RollbackNotice: this.hooks.rollbackNotice(intent.value); return;
7388
case ConversationIntentType.Send: void this.hooks.send(); return;
7489
case ConversationIntentType.VoiceInput: void this.hooks.voiceInput(); return;
7590
case ConversationIntentType.ChatInputChanged: this.hooks.inputChanged(route, intent.value); return;

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,24 @@ export struct ChatUserMessageRow {
3939
detail: ''
4040
};
4141
@Param showRetryAction: boolean = false;
42+
/** Whether the staged rollback's preview covers this row. See ChatTimeline. */
43+
@Param ghost: boolean = false;
4244
@Event onCopyMessage: (text: string) => void = (_text: string) => {};
43-
@Event onOpenFullText: (text: string) => void = (_text: string) => {};
45+
@Event onOpenFullText: (text: string, turnId: string) => void =
46+
(_text: string, _turnId: string) => {};
4447
@Event onRetryMessage: (text: string) => void = (_text: string) => {};
48+
@Event onRollbackMessage: (turnId: string, text: string) => void =
49+
(_turnId: string, _text: string) => {};
4550

4651
build() {
4752
ChatUserMessageBubble({
4853
item: this.item,
4954
showRetryAction: this.showRetryAction,
55+
ghost: this.ghost,
5056
onCopyMessage: this.onCopyMessage,
5157
onOpenFullText: this.onOpenFullText,
52-
onRetryMessage: this.onRetryMessage
58+
onRetryMessage: this.onRetryMessage,
59+
onRollbackMessage: this.onRollbackMessage
5360
})
5461
}
5562
}
@@ -84,7 +91,18 @@ export struct ChatMessageBubble {
8491
@Event onCopyMessage: (text: string) => void = (_text: string) => {};
8592
@Event onCopyMessageScoped: (text: string, scope: MessageCopyScope) => void =
8693
(_text: string, _scope: MessageCopyScope) => {};
87-
@Event onOpenFullText: (text: string) => void = (_text: string) => {};
94+
@Event onOpenFullText: (text: string, turnId: string) => void =
95+
(_text: string, _turnId: string) => {};
96+
/**
97+
* The rollback entry, forwarded for the user-authored bubbles this row draws.
98+
*
99+
* A steering message is drawn with the user bubble, so it carries the entry
100+
* too. It has no turn of its own, which is why the entry is wired rather than
101+
* hidden: the tap reaches the pane and is answered out loud, instead of a menu
102+
* item that silently does nothing.
103+
*/
104+
@Event onRollbackMessage: (turnId: string, text: string) => void =
105+
(_turnId: string, _text: string) => {};
88106
@Event onRetryMessage: (text: string) => void = (_text: string) => {};
89107
@Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {};
90108
@Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {};
@@ -381,7 +399,8 @@ export struct ChatMessageBubble {
381399
item: this.steeringMessage(entry, path),
382400
showRetryAction: false,
383401
onCopyMessage: this.onCopyMessage,
384-
onOpenFullText: this.onOpenFullText
402+
onOpenFullText: this.onOpenFullText,
403+
onRollbackMessage: this.onRollbackMessage
385404
})
386405
} else if (this.isThinkingEntry(entry)) {
387406
this.Thinking(

0 commit comments

Comments
 (0)