Skip to content

Commit 771b39b

Browse files
committed
fix(harmony): preserve composer state during model selection
1 parent 93798f4 commit 771b39b

3 files changed

Lines changed: 82 additions & 7 deletions

File tree

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

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ export struct ComposerBar {
8080
@Local showModelSelectorSheet: boolean = false;
8181
@Local showModelSelectorPopover: boolean = false;
8282
@Local isPreparingModelSelectorPopover: boolean = false;
83+
@Local keepExpandedForModelSelection: boolean = false;
84+
private restoreInputFocusAfterModelSelection: boolean = false;
85+
private restoreInputFocusTimerId: number = -1;
8386
@Event onPickImages: () => void = () => {};
8487
@Event onRemoveImage: (imageId: string) => void = (_imageId: string) => {};
8588
@Event onSend: () => void = () => {};
@@ -97,6 +100,10 @@ export struct ComposerBar {
97100
clearTimeout(this.sendTransitionTimerId);
98101
this.sendTransitionTimerId = -1;
99102
}
103+
if (this.restoreInputFocusTimerId >= 0) {
104+
clearTimeout(this.restoreInputFocusTimerId);
105+
this.restoreInputFocusTimerId = -1;
106+
}
100107
}
101108

102109
@Monitor('chatInput')
@@ -111,6 +118,13 @@ export struct ComposerBar {
111118
this.closeModelSelector();
112119
}
113120

121+
@Monitor('showModelSelectorSheet')
122+
onModelSelectorSheetChanged(): void {
123+
if (!this.showModelSelectorSheet) {
124+
this.restoreComposerFocusAfterModelSelection();
125+
}
126+
}
127+
114128
build() {
115129
Column() {
116130
this.AdaptiveComposer()
@@ -239,9 +253,17 @@ export struct ComposerBar {
239253
onStateChange: (event) => {
240254
if (!event.isVisible) {
241255
this.showModelSelectorPopover = false;
256+
this.restoreComposerFocusAfterModelSelection();
242257
}
243258
}
244259
})
260+
.onTouch((event: TouchEvent) => {
261+
if (event.type === TouchType.Down) {
262+
// Capture focus before the sheet/popover moves it away. Choosing a
263+
// model is part of the current edit, not the end of that edit.
264+
this.beginModelSelectionFromComposer();
265+
}
266+
})
245267
.onClick(() => {
246268
if (this.presentation === ComposerPresentation.Floating) {
247269
this.toggleModelSelectorPopover();
@@ -270,7 +292,7 @@ export struct ComposerBar {
270292
.height(32)
271293
.accessibilityText(RemoteI18n.t('common.close'))
272294
.onClick(() => {
273-
this.closeModelSelector();
295+
this.closeModelSelector(true);
274296
})
275297
}
276298
.width('100%')
@@ -337,7 +359,7 @@ export struct ComposerBar {
337359
.backgroundColor(this.isSelectedModel(model) ? SOFT : TRANSPARENT)
338360
.borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius)
339361
.onClick(() => {
340-
this.closeModelSelector();
362+
this.closeModelSelector(true);
341363
this.onSelectModel(model.id);
342364
})
343365
}
@@ -387,6 +409,7 @@ export struct ComposerBar {
387409
.defaultFocus(false)
388410
.onFocus(() => {
389411
this.inputFocused = true;
412+
this.cancelModelSelectionEditingContinuation();
390413
})
391414
.onBlur(() => {
392415
this.inputFocused = false;
@@ -668,7 +691,8 @@ export struct ComposerBar {
668691
return ChatComposerPolicy.isExpanded(
669692
this.inputText,
670693
this.inputFocused,
671-
this.isModelSelectorExpanded()
694+
this.isModelSelectorExpanded(),
695+
this.keepExpandedForModelSelection
672696
);
673697
}
674698

@@ -714,15 +738,21 @@ export struct ComposerBar {
714738
return [selected, ...models.filter((model: ConversationUiModel) => model.id !== selected.id)];
715739
}
716740

717-
private closeModelSelector(): void {
741+
private closeModelSelector(restoreInputFocus: boolean = false): void {
742+
const shouldRestore = restoreInputFocus && this.restoreInputFocusAfterModelSelection;
718743
this.showModelSelectorSheet = false;
719744
this.showModelSelectorPopover = false;
720745
this.isPreparingModelSelectorPopover = false;
746+
if (shouldRestore) {
747+
this.restoreComposerFocusAfterModelSelection();
748+
} else if (!restoreInputFocus) {
749+
this.cancelModelSelectionEditingContinuation();
750+
}
721751
}
722752

723753
private async toggleModelSelectorPopover(): Promise<void> {
724754
if (this.showModelSelectorPopover) {
725-
this.closeModelSelector();
755+
this.closeModelSelector(true);
726756
return;
727757
}
728758
if (this.isPreparingModelSelectorPopover) {
@@ -758,6 +788,44 @@ export struct ComposerBar {
758788
this.getUIContext().getFocusController().clearFocus();
759789
}
760790

791+
private beginModelSelectionFromComposer(): void {
792+
if (!this.inputFocused) {
793+
return;
794+
}
795+
this.keepExpandedForModelSelection = true;
796+
this.restoreInputFocusAfterModelSelection = true;
797+
}
798+
799+
private restoreComposerFocusAfterModelSelection(): void {
800+
if (!this.restoreInputFocusAfterModelSelection || this.showModelSelectorSheet ||
801+
this.showModelSelectorPopover || this.isPreparingModelSelectorPopover) {
802+
return;
803+
}
804+
if (this.restoreInputFocusTimerId >= 0) {
805+
clearTimeout(this.restoreInputFocusTimerId);
806+
}
807+
// Let the selector release its focus scope first. Retaining the expanded
808+
// layout during this handoff removes the collapse/re-expand jump.
809+
this.restoreInputFocusTimerId = setTimeout(() => {
810+
this.restoreInputFocusTimerId = -1;
811+
try {
812+
this.getUIContext().getFocusController().requestFocus(this.inputId);
813+
} catch (_error) {
814+
// If the route disappeared during dismissal, component teardown owns
815+
// the continuation and clears its timer.
816+
}
817+
}, MobileDesignMotion.structure);
818+
}
819+
820+
private cancelModelSelectionEditingContinuation(): void {
821+
if (this.restoreInputFocusTimerId >= 0) {
822+
clearTimeout(this.restoreInputFocusTimerId);
823+
this.restoreInputFocusTimerId = -1;
824+
}
825+
this.restoreInputFocusAfterModelSelection = false;
826+
this.keepExpandedForModelSelection = false;
827+
}
828+
761829
private async startVoiceInput(): Promise<void> {
762830
await this.releaseComposerKeyboard();
763831
this.onVoiceInput();

‎src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ export class ChatComposerPolicy {
100100
static isExpanded(
101101
text: string,
102102
inputFocused: boolean,
103-
modelSelectorOpen: boolean
103+
modelSelectorOpen: boolean,
104+
continuingEditAfterModelSelection: boolean = false
104105
): boolean {
105-
return inputFocused || modelSelectorOpen || text.indexOf('\n') >= 0;
106+
return inputFocused || modelSelectorOpen || continuingEditAfterModelSelection || text.indexOf('\n') >= 0;
106107
}
107108

108109
static shouldShowAddButton(showAddButton: boolean, supportsAttachments: boolean): boolean {

‎src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,12 @@ export default function conversationPresentationUnitTest() {
576576
expect(ChatComposerPolicy.canUseSupplementalVoice(true)).assertFalse();
577577
});
578578

579+
it('keeps the composer expanded while model selection returns to the active edit', 0, () => {
580+
expect(ChatComposerPolicy.isExpanded('', false, false, true)).assertTrue();
581+
expect(ChatComposerPolicy.isExpanded('', false, false, false)).assertFalse();
582+
expect(ChatComposerPolicy.isExpanded('', false, true, false)).assertTrue();
583+
});
584+
579585
it('names the primary composer action for assistive labels', 0, () => {
580586
expect(ChatComposerPolicy.primaryActionAccessibilityKey(ComposerPrimaryAction.Send, false))
581587
.assertEqual('chat.send');

0 commit comments

Comments
 (0)