diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index 29b3df897c..d43327549e 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -90,6 +90,14 @@ 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 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 +ChatTimeline handles the jump. It covers success/failure at wide and compact +content widths with a live resize and restores the normal App afterward. +This is native controller UI coverage, not remote transport or physical fold +coverage; exercise those separately when their behavior changes. + For durable transcript projection, run `node --test tools/tests/session-record.test.cjs tools/tests/host-stream.test.cjs tools/tests/streaming-markdown.test.cjs`. The `durable-timeline` native preview uses the 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 55ac397c8e..60351f8e09 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 @@ -13,7 +13,7 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const scenarioId = want.parameters?.['openbitfunDesignPreview']; - if (scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || + if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || scenarioId === 'fold-context' || scenarioId === 'long-reading' || diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index 9f56690f2a..9895965f7e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -352,6 +352,7 @@ export struct ChatTimeline { // Keep short transcripts at the content start. Active turns and explicit // follow-to-bottom calls still place streaming content at the tail. .stackFromEnd(false) + .id('chat-timeline-list') // Keeps the read position when older messages are prepended above. .maintainVisibleContentPosition(false) .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true }) @@ -394,6 +395,7 @@ export struct ChatTimeline { .fontSize(18) .fontColor([INK]) } + .id('chat-timeline-jump') .width(42) .height(42) .backgroundColor(CARD) @@ -574,7 +576,14 @@ export struct ChatTimeline { } private requestFollowToBottom(reason: string): void { - if (this.historyRestoreKey || this.historyLoading || !this.stickToBottom || this.followTimerId !== 0) { + // Explicit navigation supersedes a pending history-position restoration. + // Jump now even if the host response is still pending; ordinary revisions + // resume tail following when historyLoading clears, including on failure. + if (reason === 'jump-button') { + this.cancelHistoryAnchor(); + } + if (this.historyRestoreKey || (this.historyLoading && reason !== 'jump-button') || + !this.stickToBottom || this.followTimerId !== 0) { return; } this.followTimerId = setTimeout(() => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/HistoryScrollPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/HistoryScrollPreview.ets new file mode 100644 index 0000000000..8f4b196aa5 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/HistoryScrollPreview.ets @@ -0,0 +1,59 @@ +import { ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; +import { ChatTimeline } from '../components/ChatTimeline'; +import { INK, PAGE_BG } from '../components/Theme'; + +/** Hold a history response while exercising the production list's jump button. */ +@ComponentV2 +export struct HistoryScrollPreview { + @Local rows: ObservableChatTimelineItem[] = []; + @Local revision: number = 0; + @Local loading: boolean = false; + @Local failed: boolean = false; + @Local compact: boolean = false; + private page: number = 0; + + aboutToAppear(): void { + this.rows = this.pageRows(0); + } + + private pageRows(page: number): ObservableChatTimelineItem[] { + const rows: ObservableChatTimelineItem[] = []; + for (let index = 0; index < 20; index++) { + const id = `page-${page}-row-${index}`; + rows.push(new ObservableChatTimelineItem({ + id, type: 'user_message', isStreaming: false, isFinalizing: false, + message: { id, role: 'user', text: `History probe ${id}`, status: 'completed', timestamp: `${index}` } + })); + } + return rows; + } + + private finish(failed: boolean): void { + if (!this.loading) return; + if (!failed) { + this.page--; + this.rows = this.pageRows(this.page).concat(this.rows); + this.revision++; + } + this.failed = failed; + this.loading = false; + } + + build() { + Column({ space: 8 }) { + Row({ space: 8 }) { + Button('Complete').id('history-complete').onClick(() => { this.finish(false); }) + Button('Fail').id('history-fail').onClick(() => { this.finish(true); }) + Button('Resize').id('history-resize').onClick(() => { this.compact = !this.compact; }) + } + Text(`loading=${this.loading} failed=${this.failed} compact=${this.compact}`) + .id('history-state').fontColor(INK) + ChatTimeline({ + timelineItems: this.rows, timelineRevision: this.revision, + hasMoreMessages: true, historyLoading: this.loading, historyFailed: this.failed, + maxContentWidth: this.compact ? 360 : 0, + onLoadOlder: () => { this.failed = false; this.loading = true; } + }) + }.width('100%').height('100%').backgroundColor(PAGE_BG) + } +} 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 39dff431e7..79f247c729 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 @@ -1,3 +1,4 @@ +import { HistoryScrollPreview } from './HistoryScrollPreview'; import { ComposerSubmissionPreview } from './ComposerSubmissionPreview'; import { DurableTimelinePreview } from './DurableTimelinePreview'; import { DeviceSelectorPreview } from './DeviceSelectorPreview'; @@ -36,7 +37,9 @@ struct MobileDesignGallery { } build() { - if (AppStorage.get('scenarioId') === 'durable-timeline') { + if (AppStorage.get('scenarioId') === 'history-scroll') { + HistoryScrollPreview() + } else if (AppStorage.get('scenarioId') === 'durable-timeline') { DurableTimelinePreview() } else if (AppStorage.get('scenarioId') === 'composer-submit') { ComposerSubmissionPreview() diff --git a/src/apps/mobile/harmonyos/tools/check-history-scroll.py b/src/apps/mobile/harmonyos/tools/check-history-scroll.py new file mode 100644 index 0000000000..eca7e78d39 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/check-history-scroll.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Check history/jump interaction using native rows and a held mock response. + +Install the debug HAP first. Runs compact/wide widths and a live resize, then +returns to the normal app without changing account or remote session data. +""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--hdc', default=os.environ.get('HDC', 'hdc')) + parser.add_argument('--device') + parser.add_argument('--expect-bug', action='store_true') + args = parser.parse_args() + command = [args.hdc] + (['-t', args.device] if args.device else []) + output = Path(tempfile.mkdtemp(prefix='openbitfun-history-scroll-')) + print(f'Evidence: {output}', flush=True) + contract = (Path(__file__).resolve().parent.parent / + 'entry/src/main/ets/services/HarmonyUpgradeIdentityContract.ets') + bundle = re.search(r"APP_BUNDLE:\s*string\s*=\s*'([^']+)'", contract.read_text()).group(1) + + def run(*parts): + return subprocess.check_output(command + list(parts), text=True, timeout=30) + + def start(preview=False): + run('shell', 'aa', 'force-stop', bundle) + params = ['shell', 'aa', 'start', '-a', 'EntryAbility', '-b', bundle] + if preview: + params += ['--ps', 'openbitfunDesignPreview', 'history-scroll'] + run(*params) + time.sleep(1) + + def layout(label): + remote = run('shell', 'uitest', 'dumpLayout').strip().split('saved to:')[-1] + local = output / f'{label}.json' + run('file', 'recv', remote, str(local)) + nodes = [] + + def walk(node): + nodes.append(node.get('attributes', {})) + for child in node.get('children', []): + walk(child) + + walk(json.loads(local.read_text())) + return nodes + + def click(nodes, field, value): + node = next(n for n in nodes if n.get(field) == value) + left, top, right, bottom = map(int, re.findall(r'-?\d+', node['bounds'])) + run('shell', 'uitest', 'uiInput', 'click', str((left + right) // 2), str((top + bottom) // 2)) + time.sleep(0.3) + + def capture(label): + remote = f'/data/local/tmp/{label}.jpeg' + run('shell', 'snapshot_display', '-f', remote) + run('file', 'recv', remote, str(output / f'{label}.jpeg')) + + try: + for compact in [False, True]: + for failure in [False, True]: + label = f'{"compact" if compact else "wide"}-{"failure" if failure else "success"}' + start(True) + nodes = layout(f'{label}-initial') + if compact: + click(nodes, 'id', 'history-resize') + nodes = layout(f'{label}-resized') + # The fixture opens at the start, exposing the real paging entry. + header = next(n for n in nodes if n.get('text') in ['加载更早消息', 'Load older messages']) + click(nodes, 'text', header['text']) + nodes = layout(f'{label}-loading') + assert any('loading=true' in n.get('text', '') for n in nodes) + click(nodes, 'id', 'chat-timeline-jump') + nodes = layout(f'{label}-jump') + click(nodes, 'id', 'history-fail' if failure else 'history-complete') + time.sleep(1) + nodes = layout(f'{label}-settled') + tail = any(n.get('text') == 'History probe page-0-row-19' for n in nodes) + jump = any(n.get('id') == 'chat-timeline-jump' for n in nodes) + capture(label) + print(f'{label}: tail_visible={tail} jump_visible={jump}', flush=True) + assert tail != args.expect_bug, f'{label}: unexpected tail visibility' + if not args.expect_bug: + assert not jump, f'{label}: jump should hide at the tail' + finally: + start() + + +if __name__ == '__main__': + main() diff --git a/src/apps/mobile/harmonyos/tools/tests/host-stream.test.cjs b/src/apps/mobile/harmonyos/tools/tests/host-stream.test.cjs index a72c7789da..58c4be2d61 100644 --- a/src/apps/mobile/harmonyos/tools/tests/host-stream.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/host-stream.test.cjs @@ -6,7 +6,9 @@ const ts = require('typescript'); const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/services/HostSessionStream.ets'), 'utf8'); const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText; const exportsObject = {}; -new Function('require', 'exports', compiled)(() => ({}), exportsObject); +new Function('require', 'exports', compiled)(name => + name.endsWith('RemoteLogger') ? { RemoteLogger: { info() {}, warn() {}, error() {} } } : {}, + exportsObject); const { HostSessionStream, parseHostStreamHint, checkHostStreamPage, HostStreamUnsupportedError, HOST_STREAM_CHANGED_EVENT } = exportsObject; async function settle(predicate, label = 'stream did not settle') {