Skip to content
Closed
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
8 changes: 8 additions & 0 deletions src/apps/mobile/harmonyos/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -394,6 +395,7 @@ export struct ChatTimeline {
.fontSize(18)
.fontColor([INK])
}
.id('chat-timeline-jump')
.width(42)
.height(42)
.backgroundColor(CARD)
Expand Down Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HistoryScrollPreview } from './HistoryScrollPreview';
import { ComposerSubmissionPreview } from './ComposerSubmissionPreview';
import { DurableTimelinePreview } from './DurableTimelinePreview';
import { DeviceSelectorPreview } from './DeviceSelectorPreview';
Expand Down Expand Up @@ -36,7 +37,9 @@ struct MobileDesignGallery {
}

build() {
if (AppStorage.get<string>('scenarioId') === 'durable-timeline') {
if (AppStorage.get<string>('scenarioId') === 'history-scroll') {
HistoryScrollPreview()
} else if (AppStorage.get<string>('scenarioId') === 'durable-timeline') {
DurableTimelinePreview()
} else if (AppStorage.get<string>('scenarioId') === 'composer-submit') {
ComposerSubmissionPreview()
Expand Down
97 changes: 97 additions & 0 deletions src/apps/mobile/harmonyos/tools/check-history-scroll.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 3 additions & 1 deletion src/apps/mobile/harmonyos/tools/tests/host-stream.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Loading