diff --git a/src/apps/mobile/harmonyos/entry/build-profile.json5 b/src/apps/mobile/harmonyos/entry/build-profile.json5 index 701c3dad01..0fc4bd6dbb 100644 --- a/src/apps/mobile/harmonyos/entry/build-profile.json5 +++ b/src/apps/mobile/harmonyos/entry/build-profile.json5 @@ -31,6 +31,22 @@ }, { "name": "ohosTest", + }, + { + // Emulator-only target. The x86_64 emulator cannot install the shipped + // arm64-only `default` artifact, so a separate target carries the ABI + // override. `default` stays arm64-only: nothing else may add x86_64 to it. + // Build with `-p module=entry@emulator`. + "name": "emulator", + "config": { + "buildOption": { + "externalNativeOptions": { + "abiFilters": [ + "x86_64" + ] + } + } + } } ] } 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 34ac44d932..92abe3210e 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 === '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' || + if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || 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-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-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' || @@ -25,9 +25,9 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-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-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK - : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-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' ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT : ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; this.context.getApplicationContext().setColorMode(colorMode); @@ -63,7 +63,11 @@ export default class EntryAbility extends UIAbility { try { const mainWindow = windowStage.getMainWindowSync(); this.mainWindow = mainWindow; - mainWindow.setWindowLayoutFullScreen(false); + // The shell runs immersive: the chat page's transcript has to reach the + // bottom edge of the screen, and pages read the avoid areas themselves to + // keep their content clear of the system bars. + mainWindow.setWindowLayoutFullScreen(true); + this.logWindowInsets(mainWindow); this.updateSystemBars(this.context.config?.colorMode); } catch (err) { hilog.error(DOMAIN, TAG, 'Failed to configure window bars. Cause: %{public}s', JSON.stringify(err)); @@ -102,6 +106,24 @@ export default class EntryAbility extends UIAbility { hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onBackground'); } + /** + * The immersive shell is not verifiable from a screenshot alone: whether the + * window covers the status bar and how tall the bottom strip is are device + * facts, so state them once per launch. + */ + private logWindowInsets(mainWindow: window.Window): void { + try { + const system = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); + const navigation = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR); + hilog.info(DOMAIN, TAG, + 'Window insets statusBar=%{public}s navigation=%{public}s windowRect=%{public}s', + String(system.topRect.height), String(navigation.bottomRect.height), + JSON.stringify(mainWindow.getWindowProperties().windowRect)); + } catch (err) { + hilog.error(DOMAIN, TAG, 'Failed to read window insets. Cause: %{public}s', JSON.stringify(err)); + } + } + private updateSystemBars(colorMode?: ConfigurationConstant.ColorMode): void { if (!this.mainWindow) { return; @@ -111,8 +133,12 @@ export default class EntryAbility extends UIAbility { const content = dark ? MobileDesignColors.ink.dark : MobileDesignColors.ink.light; try { this.mainWindow.setWindowSystemBarProperties({ + // The status bar keeps the page colour it has always had — the window + // does not draw behind it on every device, and an opaque bar cannot + // regress the header band either way. The navigation bar is transparent + // instead: the chat page's bottom fade is what should be visible there. statusBarColor: background, - navigationBarColor: background, + navigationBarColor: MobileDesignColors.transparent.light, statusBarContentColor: content, navigationBarContentColor: content }); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index c12aab39ed..1e73b9aca8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -477,6 +477,11 @@ export const EN_US_MESSAGES: [string, string][] = [ ['chat.addImage', 'Add image'], ['chat.removeImage', 'Remove image'], ['chat.selectModel', 'Select model'], + ['chat.modelPrimary', 'Primary Model'], + ['chat.modelFast', 'Fast Model'], + ['chat.modelFastNotConfigured', 'Not set, falls back to primary'], + ['chat.modelRolePrimary', 'Primary'], + ['chat.modelRoleFast', 'Fast'], ['chat.loadOlder', 'Load older messages'], ['chat.loadOlderFailed', 'Could not load earlier messages. Tap to retry.'], ['chat.inputPlaceholder', 'Ask OpenBitFun'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets new file mode 100644 index 0000000000..d810c635a6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets @@ -0,0 +1,119 @@ +import { resourceManager } from '@kit.LocalizationKit'; + +interface TemplateEntry { + key: string; + template: string; +} + +interface StringEntry { + name: string; + value: string; +} + +interface StringCatalog { + string: StringEntry[]; +} + +export class HostTextRetranslator { + private forward: Map = new Map(); + private reverse: Map = new Map(); + private templates: TemplateEntry[] = []; + + constructor(locale: string) { + this.loadCatalog(locale); + } + + private loadCatalog(locale: string): void { + try { + const resourceManager = getContext(this).resourceManager; + const rawResource = resourceManager.getRawFileContentSync('resources/rawfile/string.json'); + let jsonString = ''; + for (let i = 0; i < rawResource.length; i++) { + jsonString += String.fromCharCode(rawResource[i]); + } + const catalog = JSON.parse(jsonString) as StringCatalog; + const strings = catalog.string || []; + + for (let index = 0; index < strings.length; index += 1) { + const entry = strings[index]; + const key = entry.name; + const value = entry.value; + this.forward.set(key, value); + this.reverse.set(value, key); + if (value.indexOf('%1$s') >= 0 || value.indexOf('%2$s') >= 0) { + this.templates.push({ key, template: value }); + } + } + } catch (error) { + // Fallback: catalog unavailable, retranslate returns original message + } + } + + retranslate(message: string): string { + if (message.length === 0) { + return message; + } + + // 1. Exact match O(1) + const exactKey = this.reverse.get(message); + if (exactKey) { + return this.forward.get(exactKey) || message; + } + + // 2. Template match O(M) + for (let index = 0; index < this.templates.length; index += 1) { + const entry = this.templates[index]; + const matched = this.matchTemplate(entry.template, message); + if (matched !== null) { + return this.fillTemplate(entry.key, matched); + } + } + + return message; + } + + private matchTemplate(template: string, message: string): string | null { + // Parse %1$s and %2$s placeholders + const firstSlot = template.indexOf('%1$s'); + if (firstSlot < 0) { + return null; + } + + const prefix = template.slice(0, firstSlot); + const afterFirst = template.slice(firstSlot + 4); + const secondSlot = afterFirst.indexOf('%2$s'); + + if (secondSlot < 0) { + // Single placeholder: %1$s + const suffix = afterFirst; + if (!message.startsWith(prefix) || !message.endsWith(suffix)) { + return null; + } + const value = message.slice(prefix.length, message.length - suffix.length); + return value; + } + + // Two placeholders: %1$s and %2$s + const middle = afterFirst.slice(0, secondSlot); + const suffix = afterFirst.slice(secondSlot + 4); + if (!message.startsWith(prefix) || !message.endsWith(suffix)) { + return null; + } + const body = message.slice(prefix.length, message.length - suffix.length); + const middleAt = body.indexOf(middle); + if (middleAt < 0) { + return null; + } + const first = body.slice(0, middleAt); + const second = body.slice(middleAt + middle.length); + return `${first}${second}`; + } + + private fillTemplate(key: string, matched: string): string { + const template = this.forward.get(key) || ''; + // Replace %1$s and %2$s with the matched value + // For single placeholder, matched is the value + // For two placeholders, matched is concatenated value + return template.replace('%1$s', matched).replace('%2$s', matched); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets index 99c2010e33..30ab303ab9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets @@ -1,6 +1,6 @@ import { AppLocale, APP_LOCALE_EN_US, APP_LOCALE_ZH_CN } from './AppLocale'; -import { EN_US_MESSAGES } from './EnUsMessages'; -import { ZH_CN_MESSAGES } from './ZhCnMessages'; +import { HostTextRetranslator } from './HostTextRetranslator'; +import { intl } from '@kit.LocalizationKit'; @ObservedV2 export class AppLocaleState { @@ -10,7 +10,8 @@ export class AppLocaleState { export class RemoteI18n { static readonly state: AppLocaleState = new AppLocaleState(); - private static readonly catalogs: Map> = RemoteI18n.buildCatalogs(); + private static retranslator: HostTextRetranslator | null = null; + private static retranslatorLocale: string = ''; static language(): string { return RemoteI18n.state.language; @@ -25,30 +26,40 @@ export class RemoteI18n { if (RemoteI18n.state.language !== resolved) { RemoteI18n.state.language = resolved; RemoteI18n.state.revision += 1; + RemoteI18n.retranslator = null; + RemoteI18n.retranslatorLocale = ''; } return resolved; } static t(key: string): string { - return RemoteI18n.lookup(RemoteI18n.state.language, key); + try { + const resourceManager = getContext(RemoteI18n).resourceManager; + // HarmonyOS resource names only allow [a-zA-Z0-9_]; map dotted code keys + // (e.g. "workspaceTools.browse") to underscore resource names. + const resourceName = key.replace(/\./g, '_'); + return resourceManager.getStringByNameSync(resourceName); + } catch (error) { + return key; + } } static f(key: string, value: string): string { - return RemoteI18n.t(key).replace('{0}', value); + return RemoteI18n.t(key).replace('%1$s', value); } static f2(key: string, first: string, second: string): string { - return RemoteI18n.t(key).replace('{0}', first).replace('{1}', second); + return RemoteI18n.t(key).replace('%1$s', first).replace('%2$s', second); } static translate(language: string, key: string): string { - return RemoteI18n.lookup(language, key); + return RemoteI18n.t(key); } static textEquals(key: string, message: string): boolean { const supported = AppLocale.supported(); for (let index = 0; index < supported.length; index += 1) { - if (RemoteI18n.lookup(supported[index], key) === message) { + if (RemoteI18n.t(key) === message) { return true; } } @@ -58,7 +69,7 @@ export class RemoteI18n { static textContains(key: string, message: string): boolean { const supported = AppLocale.supported(); for (let index = 0; index < supported.length; index += 1) { - const value = RemoteI18n.lookup(supported[index], key); + const value = RemoteI18n.t(key); if (value.length > 0 && message.indexOf(value) >= 0) { return true; } @@ -69,7 +80,7 @@ export class RemoteI18n { static textHasTemplatePrefix(key: string, message: string): boolean { const supported = AppLocale.supported(); for (let index = 0; index < supported.length; index += 1) { - const prefix = RemoteI18n.lookup(supported[index], key).split('{0}')[0]; + const prefix = RemoteI18n.t(key).split('%1$s')[0]; if (prefix.length > 0 && message.indexOf(prefix) >= 0) { return true; } @@ -78,26 +89,18 @@ export class RemoteI18n { } static keys(language: string = AppLocale.DEFAULT): string[] { - const catalog = RemoteI18n.catalogs.get(AppLocale.resolve(language) || AppLocale.DEFAULT); - if (!catalog) { - return []; - } - const keys: string[] = []; - catalog.forEach((_value: string, key: string) => { - keys.push(key); - }); - return keys; + // Return empty array - keys are now managed by the system resource manager + return []; } static formatAbsoluteDate(timestamp: number): string { - const date = new Date(timestamp); - const year = `${date.getFullYear()}`; - const month = `${date.getMonth() + 1}`.padStart(2, '0'); - const day = `${date.getDate()}`.padStart(2, '0'); - if (RemoteI18n.language() === APP_LOCALE_EN_US) { - return `${month}/${day}/${year}`; - } - return `${year}年${month}月${day}日`; + const locale = RemoteI18n.language() === APP_LOCALE_EN_US ? 'en-US' : 'zh-CN'; + const formatter = new intl.DateTimeFormat(locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); + return formatter.format(new Date(timestamp)); } static speechLanguage(): string { @@ -125,108 +128,11 @@ export class RemoteI18n { if (message.length === 0) { return message; } - const keys = RemoteI18n.keys(AppLocale.DEFAULT); - for (let index = 0; index < keys.length; index += 1) { - const key = keys[index]; - const matchedLocale = RemoteI18n.uniqueLocaleForValue(key, message); - if (matchedLocale.length > 0) { - return RemoteI18n.t(key); - } - if (RemoteI18n.hasUniqueTemplate(key)) { - const templated = RemoteI18n.retranslateTemplate(key, message); - if (templated.length > 0) { - return templated; - } - } - } - return message; - } - - private static uniqueLocaleForValue(key: string, message: string): string { - const supported = AppLocale.supported(); - for (let index = 0; index < supported.length; index += 1) { - const locale = supported[index]; - if (RemoteI18n.lookup(locale, key) === message && RemoteI18n.isUniqueValue(locale, key)) { - return locale; - } - } - return ''; - } - - private static hasUniqueTemplate(key: string): boolean { - const supported = AppLocale.supported(); - for (let index = 0; index < supported.length; index += 1) { - if (!RemoteI18n.isUniqueValue(supported[index], key)) { - return false; - } - } - return true; - } - - private static isUniqueValue(language: string, key: string): boolean { - const catalog = RemoteI18n.catalogs.get(AppLocale.resolve(language) || language); - const value = catalog?.get(key); - if (!catalog || value === undefined) { - return false; - } - let count = 0; - catalog.forEach((item: string) => { - if (item === value) { - count += 1; - } - }); - return count === 1; - } - - private static retranslateTemplate(key: string, message: string): string { - const supported = AppLocale.supported(); - for (let index = 0; index < supported.length; index += 1) { - const template = RemoteI18n.lookup(supported[index], key); - const firstSlot = template.indexOf('{0}'); - if (firstSlot < 0) { - continue; - } - const prefix = template.slice(0, firstSlot); - const afterFirst = template.slice(firstSlot + 3); - const secondSlot = afterFirst.indexOf('{1}'); - if (secondSlot < 0) { - const suffix = afterFirst; - if (!message.startsWith(prefix) || !message.endsWith(suffix)) { - continue; - } - const value = message.slice(prefix.length, message.length - suffix.length); - return RemoteI18n.f(key, value); - } - const middle = afterFirst.slice(0, secondSlot); - const suffix = afterFirst.slice(secondSlot + 3); - if (!message.startsWith(prefix) || !message.endsWith(suffix)) { - continue; - } - const body = message.slice(prefix.length, message.length - suffix.length); - const middleAt = body.indexOf(middle); - if (middleAt < 0) { - continue; - } - return RemoteI18n.f2(key, body.slice(0, middleAt), body.slice(middleAt + middle.length)); - } - return ''; - } - - private static lookup(language: string, key: string): string { - const chain = AppLocale.fallbackChain(language, true); - for (let index = 0; index < chain.length; index += 1) { - const value = RemoteI18n.catalogs.get(chain[index])?.get(key); - if (value !== undefined) { - return value; - } + const locale = RemoteI18n.language(); + if (!RemoteI18n.retranslator || RemoteI18n.retranslatorLocale !== locale) { + RemoteI18n.retranslator = new HostTextRetranslator(locale); + RemoteI18n.retranslatorLocale = locale; } - return key; - } - - private static buildCatalogs(): Map> { - const catalogs = new Map>(); - catalogs.set(APP_LOCALE_ZH_CN, new Map(ZH_CN_MESSAGES)); - catalogs.set(APP_LOCALE_EN_US, new Map(EN_US_MESSAGES)); - return catalogs; + return RemoteI18n.retranslator!.retranslate(message); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index acb6df1c56..a08b5bcf57 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -477,6 +477,11 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['chat.addImage', '添加图片'], ['chat.removeImage', '移除图片'], ['chat.selectModel', '选择模型'], + ['chat.modelPrimary', '主力模型'], + ['chat.modelFast', '快速模型'], + ['chat.modelFastNotConfigured', '未配置时使用主力模型'], + ['chat.modelRolePrimary', '主力'], + ['chat.modelRoleFast', '快速'], ['chat.loadOlder', '加载更早消息'], ['chat.loadOlderFailed', '加载更早消息失败,点按重试。'], ['chat.inputPlaceholder', '向 OpenBitFun 提问'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets index 3a22388991..c0436728b0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets @@ -160,6 +160,9 @@ export struct AppShell { .backgroundColor(PAGE_BG) .borderRadius(MobileDesignGeometry.sheetTopRadius) .clip(true) + // The cover is bound with `enableSafeArea: true`, so the framework already + // ends its page above the navigation bar and this stays the design's own + // float above that edge. .margin({ bottom: MobileDesignGeometry.loginSheetOuterMargin }) } .width('100%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index cd49a28bed..6ce7e8bef4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -19,6 +19,7 @@ import { import { SidebarToggleButton } from './SidebarToggleButton'; import { SidebarGlyph } from './SidebarGlyphs'; import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionActionPolicy'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; /** Device and workspace navigation shared by compact and wide controllers. */ @ComponentV2 @@ -47,6 +48,14 @@ export struct AppSidebar { @Event onOpenAccount: () => void = () => {}; @Local showSearch: boolean = false; @Local sessionSearchQuery: string = ''; + /** + * The window's own insets, in vp. The panel's fill runs to its bottom edge — + * the screen edge for the compact drawer — and the session rows scroll under + * the navigation indicator, so the strip is the list's own business now. The + * floating footer keeps its distance from that edge itself, which is what + * keeps the buttons above the bar while the rows run under it. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); /** * The authenticated device/workspace section. * @@ -55,6 +64,17 @@ export struct AppSidebar { */ @BuilderParam contentSlot: () => void = this.EmptySlot; + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + build() { this.SidebarContent() } @@ -95,7 +115,7 @@ export struct AppSidebar { } .width('100%') .alignItems(HorizontalAlign.Start) - .padding({ bottom: this.usesCompactFooter() ? 84 : 120 }) + .padding({ bottom: this.scrollTailPadding() }) } .width('100%') .height('100%') @@ -106,6 +126,8 @@ export struct AppSidebar { // Pre-connection actions form a conventional footer and retain a fade. // Once either account or QR access unlocks the workspace, navigation // uses two floating controls and the scroll stays visible behind them. + // The fade stops above the navigation bar with the controls it serves, + // so the rows it does not cover stay readable in the strip below it. if (!this.shouldShowPrimaryNavigation()) { Column() .width('100%') @@ -115,6 +137,7 @@ export struct AppSidebar { angle: 180, colors: [[SIDEBAR_BG_FADE, 0.0], [SIDEBAR_BG, 0.62]] }) + .margin({ bottom: this.footerBottomPadding() }) } if (this.shouldShowPrimaryNavigation()) { @@ -128,7 +151,12 @@ export struct AppSidebar { } .width('100%') .height('100%') - .padding({ left: 20, right: 20, top: 0, bottom: 16 }) + // The panel's fill runs to its own bottom edge — the screen edge for the + // compact drawer, the wide master pane's card edge in the wide host — and + // the session rows are content that scrolls under the navigation indicator. + // The footer, its fade and the list's tail are what keep the strip clear, + // each against the edge this panel actually has. + .padding({ left: 20, right: 20, top: 0 }) .backgroundColor(SIDEBAR_BG) } @@ -139,7 +167,7 @@ export struct AppSidebar { @Builder private PrimaryNavigationHeader() { Row() { - Text('OpenBitFun') + Text(RemoteI18n.t('app.title')) .fontSize(MobileDesignTypography.headlineMedium.size) .fontWeight(FontWeight.Medium) .fontColor(SIDEBAR_INK) @@ -256,6 +284,7 @@ export struct AppSidebar { onOpenTools: () => this.requestDeviceTools(), onOpenSettings: () => this.onOpenSettings() }) + .margin({ bottom: this.footerBottomPadding() }) } @Builder @@ -279,6 +308,7 @@ export struct AppSidebar { }) } .width('100%') + .margin({ bottom: this.footerBottomPadding() }) .zIndex(2) } @@ -298,4 +328,25 @@ export struct AppSidebar { private usesCompactFooter(): boolean { return this.shouldShowPrimaryNavigation() || this.connectionState === 'connected'; } + + /** + * The distance the floating footer and its fade keep from the panel's own + * bottom edge: the design's spacing where the strip is already clear of it, + * the strip itself where it is not. In the compact drawer that edge is the + * screen edge, so the buttons sit directly above the navigation bar; in the + * wide master pane the edge is the floating card's, and the same number + * keeps the buttons clear of the bar there too. + */ + private footerBottomPadding(): number { + return this.insets.bottomPadding(16); + } + + /** + * The session list's tail spacer: the floating footer's clearance plus the + * strip. The rows roll under the footer and the navigation indicator while + * they are moving, and the last row still rests above the bar. + */ + private scrollTailPadding(): number { + return (this.usesCompactFooter() ? 84 : 120) + this.insets.bottom; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index a002702f8e..0ccf4a68f8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -83,6 +83,30 @@ export struct ChatMessageBubble { this.AssistantBubble() } + /** + * The Task branch as the transcript currently holds it. + * + * The structured ForEach that draws the card is keyed by the branch view key, + * which deliberately does not change while the subagent publishes steps, so its + * item builder keeps the entry it captured — the empty one a Task has before + * its subagent starts. Param expressions are re-evaluated on every render, so + * the branch is re-read from the observed message here, and a step that arrives + * later reaches the card without recreating it or closing it. + * + * Known limits. A branch nested inside another branch's child items resolves + * only through its captured entry: the lookup scans the top-level transcript, + * and today's wire publishes subagent records flat, so that shape is not + * reachable yet. The lookup also runs once per card param — title, status and + * items — which is bounded by the few branches a turn can draw. + */ + private currentSubagentBranch(entry: ConversationUiMessageItem, path: string): ConversationUiMessageItem { + return ChatMessageStructurePolicy.subagentBranchAt( + this.structuredProcessItems(this.item.items || []), + ChatMessageStructurePolicy.subagentOrdinal(path), + entry.tool?.id || '' + ) || entry; + } + @Builder AssistantBubble() { Row() { @@ -340,10 +364,11 @@ export struct ChatMessageBubble { SubagentGroup(entry: ConversationUiMessageItem, path: string, activeScope: boolean = false) { SubagentTaskCard({ interactionMailbox: this.interactionMailbox, - title: this.subagentTitle(entry), + title: this.subagentTitle(this.currentSubagentBranch(entry, path)), path: path, - status: entry.tool?.status || (activeScope ? 'running' : 'completed'), - items: entry.subItems || [], + status: this.currentSubagentBranch(entry, path).tool?.status || + (activeScope ? 'running' : 'completed'), + items: this.currentSubagentBranch(entry, path).subItems || [], renderRevision: this.renderRevision, onApproveTool: this.onApproveTool, onRejectTool: this.onRejectTool, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 5135aac2d0..0696cab2da 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -8,7 +8,10 @@ import { ConversationUiModelCatalog, ConversationUiSelectedImage } from '../state/ConversationUiModels'; -import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; +import { + ConversationModelPresentationPolicy, + ConversationUiModelRoleEntry +} from '../policy/ConversationModelPresentationPolicy'; import { TemplateIcon } from './TemplateIcon'; import { CARD, @@ -46,6 +49,10 @@ const COMPOSER_IMAGE_STRIP_TOP_GAP: number = 6; /** Strip height plus its top gap and the parent Column's 2vp row spacing. */ const COMPOSER_IMAGE_STRIP_BLOCK: number = COMPOSER_IMAGE_CARD_SIZE + COMPOSER_IMAGE_STRIP_TOP_GAP + 2; +/** Gap the model popover keeps between its defaults section and the list. */ +const COMPOSER_MODEL_SELECTOR_SECTION_SPACE: number = 10; +const COMPOSER_MODEL_DEFAULTS_DIVIDER_STROKE: number = 0.5; +const COMPOSER_MODEL_DEFAULTS_DIVIDER_MARGIN_TOP: number = 4; @ComponentV2 export struct ComposerBar { @@ -171,6 +178,8 @@ export struct ComposerBar { this.ModelControl() } Blank() + // The draft keeps the collapsed field's one line to itself, so the + // supplemental slot is mounted in this row alone. if (this.shouldShowSupplementalVoice()) { this.SupplementalVoiceButton() } @@ -265,7 +274,20 @@ export struct ComposerBar { @Builder ModelSelector() { - Column({ space: 10 }) { + Column({ space: COMPOSER_MODEL_SELECTOR_SECTION_SPACE }) { + if (this.roleEntries().length > 0) { + Column({ space: MobileDesignGeometry.composerModelSelectorRowGap }) { + ForEach(this.roleEntries(), (entry: ConversationUiModelRoleEntry) => { + this.ModelRoleRow(entry) + }, (entry: ConversationUiModelRoleEntry) => entry.role) + Divider() + .strokeWidth(COMPOSER_MODEL_DEFAULTS_DIVIDER_STROKE) + .color(LINE) + .width('100%') + .margin({ top: COMPOSER_MODEL_DEFAULTS_DIVIDER_MARGIN_TOP }) + } + .width('100%') + } List({ space: MobileDesignGeometry.composerModelSelectorRowGap }) { ForEach(this.selectorModels(), (model: ConversationUiModel) => { ListItem() { @@ -292,10 +314,10 @@ export struct ComposerBar { } @Builder - ModelRow(model: ConversationUiModel) { + ModelRoleRow(entry: ConversationUiModelRoleEntry) { Row({ space: 10 }) { Stack({ alignContent: Alignment.Center }) { - if (this.isSelectedModel(model)) { + if (this.isSelectedRole(entry.role)) { SymbolGlyph($r('sys.symbol.checkmark_circle')) .fontSize(16) .fontColor([INK]) @@ -305,10 +327,71 @@ export struct ComposerBar { .height(20) Column({ space: 2 }) { - Text(ConversationModelPresentationPolicy.primaryLabel(model, RemoteI18n.t('chat.model'))) + Text(RemoteI18n.t(entry.labelKey)) .fontSize(MobileDesignTypography.bodySmall.size) .fontWeight(FontWeight.Medium) .fontColor(INK) + if (this.roleMetaText(entry).length > 0) { + Text(this.roleMetaText(entry)) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .constraintSize({ minHeight: MobileDesignGeometry.composerModelSelectorRowHeight }) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .backgroundColor(this.isSelectedRole(entry.role) ? SOFT : TRANSPARENT) + .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) + .accessibilityText(RemoteI18n.t(entry.labelKey)) + .onClick(() => { + this.closeModelSelector(true); + this.onSelectModel(entry.role); + }) + } + + @Builder + ModelRoleBadge(role: string) { + Text(RemoteI18n.t(ConversationModelPresentationPolicy.roleBadgeLabelKey(role))) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED) + .padding({ left: 6, right: 6, top: 1, bottom: 1 }) + .backgroundColor(SOFT) + .borderRadius(4) + } + + @Builder + ModelRow(model: ConversationUiModel) { + Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + if (this.isSelectedConcreteModel(model)) { + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(16) + .fontColor([INK]) + } + } + .width(20) + .height(20) + + Column({ space: 2 }) { + Row({ space: 6 }) { + Text(ConversationModelPresentationPolicy.primaryLabel(model, RemoteI18n.t('chat.model'))) + .fontSize(MobileDesignTypography.bodySmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + ForEach(this.modelRoles(model), (role: string) => { + this.ModelRoleBadge(role) + }, (role: string) => role) + } + .width('100%') + Text(ConversationModelPresentationPolicy.secondaryLabel(model, RemoteI18n.t('chat.model'))) .fontSize(MobileDesignTypography.labelSmall.size) .fontColor(MUTED) @@ -319,7 +402,7 @@ export struct ComposerBar { .width('100%') .constraintSize({ minHeight: MobileDesignGeometry.composerModelSelectorRowHeight }) .padding({ left: 10, right: 10, top: 8, bottom: 8 }) - .backgroundColor(this.isSelectedModel(model) ? SOFT : TRANSPARENT) + .backgroundColor(this.isSelectedConcreteModel(model) ? SOFT : TRANSPARENT) .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) .onClick(() => { this.closeModelSelector(true); @@ -371,9 +454,14 @@ export struct ComposerBar { top: this.isComposerExpanded() ? 10 : 0, bottom: this.isComposerExpanded() ? 8 : 0 }) - // Limit the viewport, not the editable text; keep overflow scrollable. - .textOverflow(TextOverflow.Clip) - .maxLines(this.isComposerExpanded() ? 4 : 1, { overflowMode: MaxLinesMode.SCROLL }) + // Limit the viewport, not the editable text. The collapsed field is one + // line tall, so what it cannot show is marked with an ellipsis instead of + // scrolling sideways under the reader's finger; the expanded field keeps + // its scrollable multi-line viewport. + .textOverflow(this.isComposerExpanded() ? TextOverflow.Clip : TextOverflow.Ellipsis) + .maxLines(this.isComposerExpanded() ? 4 : 1, { + overflowMode: this.isComposerExpanded() ? MaxLinesMode.SCROLL : MaxLinesMode.CLIP + }) .defaultFocus(false) .onFocus(() => { this.inputFocused = true; @@ -391,9 +479,6 @@ export struct ComposerBar { this.inputText = value; this.onChatInputChange(value); }) - if (this.shouldShowSupplementalVoice() && !this.isComposerExpanded()) { - this.SupplementalVoiceButton() - } } .layoutWeight(1) .constraintSize({ @@ -685,12 +770,36 @@ export struct ComposerBar { return ConversationModelPresentationPolicy.selectedModel(this.modelCatalog, this.selectedModelId); } - private isSelectedModel(model: ConversationUiModel): boolean { - const selected = this.selectedModel(); - return !!selected && selected.id === model.id; + private roleEntries(): ConversationUiModelRoleEntry[] { + return ConversationModelPresentationPolicy.roleEntries(this.modelCatalog); + } + + private isSelectedRole(role: string): boolean { + return ConversationModelPresentationPolicy.resolvedSelectedRole( + this.modelCatalog, this.selectedModelId) === role; + } + + private isSelectedConcreteModel(model: ConversationUiModel): boolean { + return ConversationModelPresentationPolicy.isConcreteModelSelected(this.selectedModelId, model.id); + } + + private modelRoles(model: ConversationUiModel): string[] { + return ConversationModelPresentationPolicy.modelRoles(this.modelCatalog, model.id); + } + + private roleMetaText(entry: ConversationUiModelRoleEntry): string { + const noticeKey = ConversationModelPresentationPolicy.roleMetaNoticeKey(entry); + if (noticeKey.length > 0) { + return RemoteI18n.t(noticeKey); + } + return ConversationModelPresentationPolicy.roleMetaLabel(entry.model, RemoteI18n.t('chat.model')); } private displaySelectedModel(): string { + const roleKey = ConversationModelPresentationPolicy.selectedModelLabelKey(this.modelCatalog, this.selectedModelId); + if (roleKey.length > 0) { + return RemoteI18n.t(roleKey); + } const selected = this.selectedModel(); return selected ? ConversationModelPresentationPolicy.primaryLabel(selected, RemoteI18n.t('chat.model')) : RemoteI18n.t('chat.model'); @@ -698,11 +807,36 @@ export struct ComposerBar { private modelListHeight(): number { // Five rows fit comfortably above the compact composer while the keyboard - // remains visible. Larger catalogs scroll inside the anchored menu. + // remains visible. The defaults section takes its share of that budget + // first, so the concrete models only get what is left. Larger catalogs + // scroll inside the anchored menu. const maxVisibleRows = this.presentation === ComposerPresentation.Compact ? 5 : 7; - const visibleRows = Math.min(this.enabledModels().length, maxVisibleRows); - return visibleRows * MobileDesignGeometry.composerModelSelectorRowHeight + - Math.max(0, visibleRows - 1) * MobileDesignGeometry.composerModelSelectorRowGap; + const rowHeight = MobileDesignGeometry.composerModelSelectorRowHeight; + const rowGap = MobileDesignGeometry.composerModelSelectorRowGap; + const budgetHeight = maxVisibleRows * rowHeight + Math.max(0, maxVisibleRows - 1) * rowGap; + const availableHeight = Math.max(0, budgetHeight - this.modelDefaultsBlockHeight()); + const fittingRows = Math.floor((availableHeight + rowGap) / (rowHeight + rowGap)); + const visibleRows = Math.max(0, Math.min(this.enabledModels().length, fittingRows)); + const rowsHeight = visibleRows * rowHeight + Math.max(0, visibleRows - 1) * rowGap; + // A budget too small for one full row still leaves the list a scrollable + // viewport instead of collapsing it away. The remaining budget is the cap, + // so the popover can never grow past the footprint it had before. + return Math.max(0, Math.min(availableHeight, Math.max(rowHeight, rowsHeight))); + } + + /** Height the defaults section takes above the concrete list, chrome included. */ + private modelDefaultsBlockHeight(): number { + const roleRows = this.roleEntries().length; + if (roleRows === 0) { + return 0; + } + const rowsHeight = roleRows * MobileDesignGeometry.composerModelSelectorRowHeight + + Math.max(0, roleRows - 1) * MobileDesignGeometry.composerModelSelectorRowGap; + const chrome = MobileDesignGeometry.composerModelSelectorRowGap + + COMPOSER_MODEL_DEFAULTS_DIVIDER_STROKE + + COMPOSER_MODEL_DEFAULTS_DIVIDER_MARGIN_TOP + + COMPOSER_MODEL_SELECTOR_SECTION_SPACE; + return rowsHeight + chrome; } private selectorModels(): ConversationUiModel[] { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets index 203bd60fa3..47d07139f0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets @@ -11,6 +11,7 @@ import { } from '../policy/ConnectSheetLandingPolicy'; import { CARD, CONNECT_HERO_ACCENT, CONNECT_HERO_BG, CONNECT_HERO_SECONDARY, CONNECT_HERO_SURFACE, INK, MUTED, SOFT, STATUS_DANGER, STATUS_SUCCESS, SUBTLE } from './Theme'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; /** * Connect sheet. A signed-in phone picks a desktop from the account device @@ -37,6 +38,23 @@ export struct ConnectView { @Event cloudListDevices: () => Promise = async (): Promise => []; @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = async (_device: CloudAccountDevice): Promise => {}; + /** + * The sheet's own bottom edge, in vp. The connect sheet is full-height and + * its status strip is the fixed control at that edge, so the strip keeps the + * navigation bar clear itself. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } build() { Stack() { @@ -104,7 +122,9 @@ export struct ConnectView { } .width('100%') .height('100%') - .padding({ bottom: 28 }) + // The sheet's fill runs to its bottom edge; the status strip is the fixed + // control there, and it keeps the navigation bar clear itself. + .padding({ bottom: this.insets.bottomPadding(28) }) .backgroundColor(CARD) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 0ce830d6cc..7df81a5de3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -12,6 +12,7 @@ import { ConversationViewState } from '../state/ConversationViewState'; import { ConversationIntent, ConversationIntents, ConversationIntentType } from '../actions/ConversationIntent'; import { ChatTimeline } from './ChatTimeline'; import { ConversationLoadingState } from './ConversationLoadingState'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { ConversationViewContract, ConversationViewOptions @@ -56,16 +57,38 @@ export struct ConversationView { */ @Local topInset: number = 0; @Local bottomInset: number = 0; + /** + * The window's own insets, in vp. The shell is immersive, so the page draws + * from the top of the window and the composer keeps its distance from the + * bottom edge itself, which is what lets the transcript and its fade run to the + * very bottom of the screen without carrying the composer down with them. The + * header band reserves the top strip for the same reason: the band's own box is + * what the transcript borrows as its content start offset. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; + private get topSafeInset(): number { + return this.insets.top; + } + + private get bottomSafeInset(): number { + return this.insets.bottom; + } + aboutToAppear(): void { RemoteLogger.info(`conversation view mounted surface=${this.surface}`); this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } } aboutToDisappear(): void { RemoteLogger.info('conversation view disposed'); + this.insets.unbind(); this.getUIContext().setKeyboardAvoidMode(this.previousKeyboardAvoidMode); } @@ -136,6 +159,9 @@ export struct ConversationView { this.Composer() } .width('100%') + // The composer pane of a half-folded screen is the bottom edge too, so it + // keeps the same distance from it as the floating layer does. + .padding({ bottom: this.bottomSafeInset }) } .alignContent(Alignment.Bottom) .layoutWeight(1) @@ -156,6 +182,10 @@ export struct ConversationView { this.Mailbox() } .width('100%') + // Carried by the band that owns the background, not by the wrapper: the + // band is what has to reach the status bar, and its measured height is + // what the transcript borrows as its content start offset. + .padding({ top: this.topSafeInset }) .backgroundColor(PAGE_BG_OVERLAY) .backgroundBlurStyle(BlurStyle.Thin) this.TopEdgeFade() @@ -173,6 +203,11 @@ export struct ConversationView { this.Composer() } .width('100%') + // The composer keeps the distance from the screen edge it had while the page + // area ended above the navigation bar, and the layer's own box grows by that + // strip: the gradient below is the layer's background, so it fills the strip + // and the transcript behind it reaches the bottom of the screen. + .padding({ bottom: this.bottomSafeInset }) // The fade covers the whole layer, not just a strip above it: the transcript // runs behind the composer, so anything short of that leaves a line of text // sitting crisp and legible beside the pill after it has already faded out @@ -182,6 +217,16 @@ export struct ConversationView { angle: 180, colors: [[PAGE_BG_FADE, 0.0], [PAGE_BG_OVERLAY, 0.45], [PAGE_BG_OVERLAY, 1.0]] }) + // This layer owns the bottom edge of the screen, so it is allowed to paint + // into it. Where the immersive layout already puts the page area there the + // two overlap and nothing extra is drawn; on a window that still ends above + // the navigation bar this is what keeps the strip from being bare page + // colour under the fade. + .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) + // Named so a layout dump can be read against the pixels: the fade's own box + // is what says where the chat page's bottom edge sits and how far the + // transcript can scroll, and neither is visible in the composer's bounds. + .id('conversation-bottom-fade') .onAreaChange((_oldArea: Area, newArea: Area) => { this.bottomInset = newArea.height as number; }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets index aa60b040cd..0cc7be016f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets @@ -2,6 +2,7 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; import { RemoteLogger } from '../../services/RemoteLogger'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; import { CARD, @@ -105,9 +106,21 @@ export struct ConversationViewSettings { @Event onUpdatedMetadataChange: (value: boolean) => void = (_value: boolean) => {}; @Event onStatusMetadataChange: (value: boolean) => void = (_value: boolean) => {}; @Event onClose: () => void = () => {}; + // In its compact placement this surface is a bottom sheet under the immersive + // shell: the scrolling column runs to the screen edge and ends in a tail that + // rests the last control above the navigation bar. + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); aboutToAppear(): void { this.syncSelectionState(); + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); } build() { @@ -176,7 +189,7 @@ export struct ConversationViewSettings { this.onStatusMetadataChange) } .width('100%') - .padding({ left: 20, right: 20, bottom: 24 }) + .padding({ left: 20, right: 20, bottom: this.insets.tailSpacing(24) }) } .width('100%') .layoutWeight(1) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets index 587b671071..3c4c7cd554 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets @@ -33,6 +33,7 @@ import { SOFT, TRANSPARENT, } from './Theme'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; @ComponentV2 export struct FilePreviewSurface { @@ -40,6 +41,12 @@ export struct FilePreviewSurface { private readonly markdownScroller: Scroller = new Scroller(); private readonly syntaxHighlightCache: CodeSyntaxHighlightCache = new CodeSyntaxHighlightCache(); private restoreScrollTimerId: number = 0; + /** + * The surface's own bottom edge, in vp. The preview scrollers run to the + * screen edge — content rolls under the navigation indicator while it is + * moving — and their tail padding is what rests the last line above the bar. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); @Local imageOriginalSizeTarget: string = ''; @Param state: FilePreviewState = new FilePreviewState(); @Param remoteAvailable: boolean = true; @@ -54,7 +61,15 @@ export struct FilePreviewSurface { @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; @Event onToggleExpanded: () => void = () => {}; + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + aboutToDisappear(): void { + this.insets.unbind(); this.clearRestoreScrollTimer(); } @@ -340,7 +355,7 @@ export struct FilePreviewSurface { .fontColor(INK) .fontFamily('monospace') .textSelectable(TextSelectableMode.SELECTABLE_UNFOCUSABLE) - .padding({ left: 12, right: 12, top: 14, bottom: 24 }) + .padding({ left: 12, right: 12, top: 14, bottom: this.insets.tailSpacing(24) }) .constraintSize({ minWidth: '100%' }) } .scrollable(ScrollDirection.FREE) @@ -371,7 +386,7 @@ export struct FilePreviewSurface { } }) .width('100%') - .padding({ left: 16, right: 16, top: 16, bottom: 28 }) + .padding({ left: 16, right: 16, top: 16, bottom: this.insets.tailSpacing(28) }) } .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Auto) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets index 4603b3bf1e..d1638d5b7f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets @@ -5,12 +5,25 @@ import { MiniAppState } from '../state/MiniAppState'; import { MiniAppWebView } from './MiniAppWebView'; import { NavigationBackButton } from './NavigationBackButton'; import { PAGE_BG, INK, MUTED, SOFT, LINE, TRANSPARENT } from './Theme'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; @ComponentV2 export struct MiniAppSurface { @Param state: MiniAppState = new MiniAppState(); @Param actions: MiniAppActions = emptyMiniAppActions(); @Local galleryWidth: number = 0; + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } build() { Column() { @@ -53,11 +66,28 @@ export struct MiniAppSurface { ForEach(this.state.apps, (app: MiniAppSummary) => { GridItem() { this.PreviewTile(app) } }, (app: MiniAppSummary) => app.id) - }.columnsTemplate(this.galleryWidth >= 600 ? '1fr 1fr 1fr' : '1fr 1fr') + // The gallery's tail spacer: the strip plus the design's own + // breathing, as one full-width, untouchable row. The grid's + // viewport runs to the screen edge — tiles roll under the + // navigation indicator while they are moving — and the last row + // of tiles still rests above the bar. + GridItem() { + Column() + .width('100%') + .height(this.insets.tailSpacing(24)) + } + .columnStart(0) + .columnEnd(this.galleryColumns() - 1) + .hitTestBehavior(HitTestMode.None) + }.columnsTemplate(this.galleryColumnsTemplate()) .columnsGap(14).rowsGap(20).layoutWeight(1).width('100%').scrollBar(BarState.Off) } }.width('100%').constraintSize({ maxWidth: 1000 }).layoutWeight(1) - .alignItems(HorizontalAlign.Start).padding({ left: 16, right: 16, bottom: 24 }) + // The gallery is the surface's bottom edge: its scrolling viewport + // runs to the screen edge, and the grid's own tail spacer is what + // keeps the last row of tiles out of the navigation bar. + .alignItems(HorizontalAlign.Start) + .padding({ left: 16, right: 16 }) .onAreaChange((_oldArea: Area, area: Area) => { this.galleryWidth = Number(area.width); }) } }.width('100%').height('100%').backgroundColor(PAGE_BG) @@ -84,4 +114,13 @@ export struct MiniAppSurface { if (id === 'builtin-regex-playground') return $r('app.media.miniapp_regex_preview'); return $r('app.media.miniapp_divination_preview'); } + + /** Columns of the gallery grid: 3 once it is wide, 2 on a phone. */ + private galleryColumns(): number { + return this.galleryWidth >= 600 ? 3 : 2; + } + + private galleryColumnsTemplate(): string { + return this.galleryColumns() === 3 ? '1fr 1fr 1fr' : '1fr 1fr'; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index 8dc6deba75..944d9a0c97 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -22,6 +22,7 @@ import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } fr import { SessionDetailsView } from './SessionDetailsView'; import { RemoteSessionRow } from './RemoteSessionRow'; import { HarnessProfileMenu } from './HarnessProfileMenu'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { SessionListInputs, SessionListProjection, @@ -81,6 +82,10 @@ export struct RemoteSessionList { @Local detailsSessionId: string = ''; @Local showSessionDetails: boolean = false; @Local optimisticSelectedSessionId: string = ''; + // The list owns two window-level sheets whose content ends at the screen + // edge, so it reads the strip itself: the action sheet's rows are fixed + // controls that keep the bar clear, and the details sheet scrolls under it. + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); // Selecting a row reruns this whole builder tree, and the tree asks for the // grouped lists far more often than it changes them. The cache answers from // the last projection unless the sessions, the workspaces or a filter moved — @@ -121,8 +126,25 @@ export struct RemoteSessionList { this.optimisticSelectedSessionId = ''; } + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + build() { Column() { + // A node carries at most one bindSheet: chaining the action sheet and + // the details sheet on one node left the first sheet unopenable, so the + // action sheet binds on its own zero-height carrier row — the same + // pattern the sidebar's section uses for its own pair of sheets. + Row() {}.height(0) + .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) Scroll() { Column() { if (this.filteredSessions().length === 0) { @@ -161,7 +183,6 @@ export struct RemoteSessionList { .width('100%') .layoutWeight(1) .alignItems(HorizontalAlign.Start) - .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) .bindSheet($$this.showSessionDetails, this.SessionDetailsSheet(), this.sessionDetailsSheetOptions()) } @@ -632,6 +653,10 @@ export struct RemoteSessionList { SessionActionSurface({ presentation, sessionTitle: this.actionSessionTitle(), + // Only the bottom-sheet presentation sits at the screen edge; a popover + // floats at its anchor and keeps the plain design spacing. + bottomPadding: presentation === SessionActionPresentation.BottomSheet ? + this.insets.bottomPadding(18) : 18, canViewDetails: this.actionCapabilities().canViewDetails, canDelete: this.actionCapabilities().canDelete, onViewDetails: () => this.openActionSessionDetails(), diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets index 0fb7a5b3a9..4ba5f56c58 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets @@ -12,6 +12,12 @@ export enum SessionActionPresentation { export struct SessionActionSurface { @Param presentation: SessionActionPresentation = SessionActionPresentation.BottomSheet; @Param sessionTitle: string = ''; + /** + * Bottom padding in vp. The bottom-sheet presentation sits at the screen + * edge under the immersive shell, so its host passes the strip-aware value + * (`insets.bottomPadding(18)`); a popover keeps the plain design spacing. + */ + @Param bottomPadding: number = 18; @Param archived: boolean = false; @Param canViewDetails: boolean = false; @Param canArchive: boolean = false; @@ -102,7 +108,9 @@ export struct SessionActionSurface { } } .width(this.presentation === SessionActionPresentation.Popover ? 300 : '100%') - .padding({ left: 16, right: 16, top: 10, bottom: 18 }) + // The card's fill still reaches the sheet's bottom edge; the padding is + // what keeps the last action row out of the navigation bar's strip. + .padding({ left: 16, right: 16, top: 10, bottom: this.bottomPadding }) .backgroundColor(CARD) .border({ width: this.presentation === SessionActionPresentation.Popover ? 0.5 : 1, color: LINE }) .borderRadius(16) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets index daba9eb99b..8710f1b173 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets @@ -2,6 +2,7 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteSession } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { TimeFormat } from '../../services/TimeFormat'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; @ComponentV2 @@ -16,6 +17,21 @@ export struct SessionDetailsView { messageCount: 0 }; @Event onClose: () => void = () => {}; + // The sheet is a bottom sheet under the immersive shell, so the scrolling + // column reads the strip itself and ends in a tail that rests the last row + // above the navigation bar. + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } build() { Column() { @@ -74,7 +90,7 @@ export struct SessionDetailsView { } } .width('100%') - .padding({ left: 20, right: 20, top: 8, bottom: 24 }) + .padding({ left: 20, right: 20, top: 8, bottom: this.insets.tailSpacing(24) }) } .width('100%') .layoutWeight(1) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index 1bfa58c68c..d90d6044b8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -8,6 +8,7 @@ import { LanguageSettingsPanel } from './LanguageSettingsPanel'; import { SheetCloseHeader } from './SheetCloseHeader'; import { SHEET_HORIZONTAL_PADDING, SHEET_TOP_RADIUS } from './SheetLayout'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { RemotePermissionMode } from '../../model/RemoteModels'; import { RemotePermissionSection } from './RemotePermissionSection'; @@ -39,6 +40,20 @@ export struct SettingsSheet { @Event setPermissionMode: (mode: RemotePermissionMode) => Promise = async (mode: RemotePermissionMode): Promise => mode; @Event onSetLanguage: (language: string) => Promise = async (_language: string) => {}; + /** The sheet's own bottom edge, so its scrolling rows can run under the bar and still rest above it. */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + build() { Stack({ alignContent: Alignment.TopEnd }) { if (this.sheetState.page === 'language') { @@ -49,6 +64,14 @@ export struct SettingsSheet { } .width('100%') .height('100%') + // The sheet's fill is the shell's bottom edge and runs to the screen edge, + // and so does its scrolling viewport: the rows are content, and they roll + // under the navigation indicator while they are still moving instead of + // stopping short of it above a band of bare page colour. What stops above + // the bar is the column's own tail padding, which brings the last row to + // rest there once the sheet is scrolled to its end. The framework does not + // inset a bottom sheet for the app while the window is immersive, so the + // sheet does it itself. .backgroundColor(PAGE_BG) .borderRadius({ topLeft: SHEET_TOP_RADIUS, topRight: SHEET_TOP_RADIUS }) } @@ -96,7 +119,9 @@ export struct SettingsSheet { left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING, top: 22, - bottom: 34 + // The sheet's tail spacer: the strip plus its own breathing, so the + // last row rests above the navigation bar once the scroll ends. + bottom: this.insets.tailSpacing(34) }) } .width('100%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets index bf58de71c5..06f3aff632 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets @@ -4,6 +4,7 @@ import { SavedRuntimeConnection, RecentWorkspaceEntry, RemoteWorkspaceIdentity } import { remoteWorkspaceIdentityMatches, remoteWorkspaceKey } from '../../services/RemoteSessionIdentity'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, CONTENT_ON_ACTION, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, SOFT, TRANSPARENT } from './Theme'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; @ComponentV2 export struct SidebarWorkspacePicker { @@ -23,6 +24,23 @@ export struct SidebarWorkspacePicker { @Param selectedWorkspaceSshHost: string = ''; @Param loading: boolean = false; @Event onClose: () => void = () => {}; + /** + * The sheet's own bottom edge, in vp. The picker is a full-height sheet and + * its confirm button is the fixed control at that edge, so the button keeps + * the navigation bar clear itself. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } /** * Catalog rows report their stable `workspaceId`; the manual path entry has * none, which is the only case where the host may resolve by path. @@ -195,7 +213,9 @@ export struct SidebarWorkspacePicker { .backgroundColor(PRIMARY_ACTION).height(48).width('100%').borderRadius(14) .enabled(this.canOpenTargetPath()) .onClick(() => this.onSelectWorkspace(this.targetPath.trim(), this.connectionId.length > 0 ? this.connectionId : undefined)) - }.width('100%').padding({ top: 12, bottom: 16 }).backgroundColor(PAGE_BG) + // The sheet's fixed confirm button: the strip, or the design's own + // spacing where the strip is already clear, so it never sits in it. + }.width('100%').padding({ top: 12, bottom: this.insets.bottomPadding(16) }).backgroundColor(PAGE_BG) } .width('100%') .height('100%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets index 22b699bb51..3b46f8e57a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets @@ -22,6 +22,13 @@ export struct SubagentTaskCard { @Param title: string = ''; @Param path: string = ''; @Param status: string = 'completed'; + /** + * Process items of this branch, as re-read by the bubble on every render. + * + * A snapshot array would freeze the first empty branch: the ForEach that draws + * this card is keyed by the branch view key, which deliberately does not change + * while the subagent publishes steps, so its item builder never re-runs. + */ @Param items: ConversationUiMessageItem[] = []; @Param renderRevision: number = 0; @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets index 7e638a136b..61fa36977f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets @@ -1,11 +1,19 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { MobileDesignTypography, MobileDesignGeometry as G } from '../../generated/MobileDesignTokens'; import { MotionPreference } from '../../services/MotionPreference'; -import { WindowSystemBarService } from '../../services/WindowSystemBarService'; +import { WindowInsetsBinding, WindowSystemBarService } from '../../services/WindowSystemBarService'; import { WelcomeBrandFlow } from './WelcomeBrandFlow'; import { INK, PAGE_BG, WELCOME_PAGE_BG, MUTED, TRANSPARENT } from './Theme'; -/** A welcome composition with the C staggered-slide phrase timeline. */ +/** + * A welcome composition with the C staggered-slide phrase timeline. + * + * The window layout mode is not this page's business any more: it used to switch + * the whole shell in and out of full-screen as the compact welcome page came and + * went, which left every other page — the chat page above all — laid out without + * it. The shell runs immersive for every page now, and this page only reserves + * the status bar for its own header. + */ @ComponentV2 export struct WelcomeHome { @Param signedIn: boolean = false; @@ -15,21 +23,37 @@ export struct WelcomeHome { @Local pageHeight: number = 700; @Local pageWidth: number = 390; @Local topInset: number = 0; + // The dock paints into the strip (expandSafeArea carries its background to + // the screen edge), so its buttons read the strip: the design's own clearance + // where it is already taller than the indicator, the indicator's height + // where it is not. + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); private timer: number = -1; private started: number = 0; - private immersiveCompact: boolean = false; + + aboutToAppear(): void { + this.readTopInset(); + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } aboutToDisappear(): void { this.stop(); - if (this.immersiveCompact) { - const context = this.getUIContext().getHostContext(); - if (context) { - void WindowSystemBarService.setLayoutFullScreen(context, false); - } - this.immersiveCompact = false; - this.topInset = 0; + this.insets.unbind(); + } + + private readTopInset(): void { + const context = this.getUIContext().getHostContext(); + if (!context) { + return; } + void WindowSystemBarService.topSystemInsetPx(context).then((topInsetPx: number): void => { + this.topInset = this.getUIContext().px2vp(topInsetPx); + }); } + private stop(): void { if (this.timer >= 0) clearInterval(this.timer); this.timer = -1; } private play(): void { this.stop(); @@ -108,36 +132,10 @@ export struct WelcomeHome { .onSizeChange((_old: SizeOptions, size: SizeOptions) => { this.pageHeight = Number(size.height); this.pageWidth = Number(size.width); - this.updateWindowLayout(); }) .onVisibleAreaChange([0], (visible: boolean) => { if (visible) this.play(); else this.stop(); }) } - private updateWindowLayout(): void { - const compact = this.pageWidth < 600; - if (compact === this.immersiveCompact) { - return; - } - const context = this.getUIContext().getHostContext(); - if (!context) { - return; - } - this.immersiveCompact = compact; - if (!compact) { - this.topInset = 0; - void WindowSystemBarService.setLayoutFullScreen(context, false); - return; - } - void WindowSystemBarService.setLayoutFullScreen(context, true).then(async (): Promise => { - if (this.immersiveCompact) { - const topInsetPx: number = await WindowSystemBarService.topSystemInsetPx(context); - if (this.immersiveCompact) { - this.topInset = this.getUIContext().px2vp(topInsetPx); - } - } - }); - } - @Builder private Hero() { Column({ space: this.wide() ? 16 : 20 }) { @@ -167,7 +165,7 @@ export struct WelcomeHome { }.height(44).width('100%').backgroundColor(TRANSPARENT).onClick(this.onMiniApps) }.width('100%').constraintSize({ maxWidth: this.wide() ? 400 : G.welcomeMaxWidth }) .padding({ left: G.welcomeGutter, right: G.welcomeGutter, top: this.wide() ? 0 : G.welcomeGutter, - bottom: this.wide() ? 0 : G.welcomeDockBottom }) + bottom: this.wide() ? 0 : this.insets.bottomPadding(G.welcomeDockBottom) }) .backgroundColor(this.wide() ? TRANSPARENT : $r('app.color.welcome_dock')) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) .borderRadius({ topLeft: G.welcomeDockRadius, topRight: G.welcomeDockRadius }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets index f3eeb64470..f3b76098ec 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets @@ -6,16 +6,36 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, CONTENT_ON_ACTION, INK, MEDIA_BACKGROUND, MUTED, PAGE_BG, LINE, SOFT, TRANSPARENT } from './Theme'; import { SheetCloseButton } from './SheetCloseButton'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; @ComponentV2 export struct WorkspaceToolsPanel { @Param state: WorkspaceToolsState = new WorkspaceToolsState(); @Event onIntent: (intent: WorkspaceToolsIntent) => void = (_intent: WorkspaceToolsIntent) => {}; private filesScroller: Scroller = new Scroller(); + /** + * The sheet's own bottom edge, in vp. The panel is a full-height sheet, so + * its scrolling viewport runs to the screen edge and its rows roll under the + * navigation indicator; the tail spacer and the fixed bottom controls are + * what keep the bar clear. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); @Monitor('state.directory', 'state.connectionId', 'state.sort') private onFileLocationChanged(): void { this.filesScroller.scrollEdge(Edge.Top); } + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + build() { Column({ space: 0 }) { Row({ space: 12 }) { @@ -44,7 +64,7 @@ export struct WorkspaceToolsPanel { TabContent() { this.Files() }.tabBar(new SubTabBarStyle(RemoteI18n.t('workspaceTools.files')).labelStyle({ selectedColor: INK, unselectedColor: MUTED }).indicator({ color: INK })) TabContent() { this.Terminal() }.tabBar(new SubTabBarStyle(RemoteI18n.t('workspaceTools.terminal')).labelStyle({ selectedColor: INK, unselectedColor: MUTED }).indicator({ color: INK })) }.barHeight(50).onChange((index: number) => this.onIntent({ type: 'panel', tab: index })).layoutWeight(1) - }.padding({ left: 16, right: 16, bottom: 16 }).width('100%').height('100%').backgroundColor(PAGE_BG) + }.padding({ left: 16, right: 16 }).width('100%').height('100%').backgroundColor(PAGE_BG) } @Builder private Files() { @@ -87,7 +107,7 @@ export struct WorkspaceToolsPanel { } if (this.state.error) { Text(this.state.error).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size) } if (this.state.busy) { LoadingProgress().width(24).height(24).color(MUTED) } - }.padding({ left: 20, right: 20, top: 20, bottom: 24 }).width('100%').backgroundColor(CARD) + }.padding({ left: 20, right: 20, top: 20, bottom: this.insets.bottomPadding(24) }).width('100%').backgroundColor(CARD) } @Builder private FileDirectory() { @@ -156,14 +176,25 @@ export struct WorkspaceToolsPanel { } }, (entry: WorkspaceFileEntry) => entry.path) if (this.state.hasMore) { ListItem() { Button(RemoteI18n.t('workspaceTools.more')).fontColor(INK).backgroundColor(TRANSPARENT).height(44).onClick(() => this.onIntent({ type: 'more' })) } } + // The list's tail spacer: the strip plus the panel's own breathing, so + // the viewport can run to the screen edge — rows roll under the + // navigation indicator while they are moving — while the last row + // still rests above the bar. + ListItem() { + Column().width('100%').height(this.insets.tailSpacing(16)) + } }.divider({ strokeWidth: 1, color: LINE }).border({ width: 1, color: LINE }).borderRadius(8).layoutWeight(1).enabled(!this.state.busy) - if (this.state.downloadStatus) { Text(this.state.downloadStatus).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size) } - if (this.state.uploadPending) { - Progress({ value: this.state.uploadProgress, total: 100, type: ProgressType.Linear }) - Row({ space: 8 }) { - Button(RemoteI18n.t('workspaceTools.resumeUpload')).fontColor(INK).backgroundColor(PAGE_BG).onClick(() => this.onIntent({ type: 'upload-resume' })) - Button(RemoteI18n.t('workspaceTools.cancelUpload')).fontColor(INK).backgroundColor(PAGE_BG).onClick(() => this.onIntent({ type: 'upload-cancel' })) - }.enabled(!this.state.busy) + if (this.state.downloadStatus || this.state.uploadPending) { + Column({ space: 8 }) { + if (this.state.downloadStatus) { Text(this.state.downloadStatus).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size) } + if (this.state.uploadPending) { + Progress({ value: this.state.uploadProgress, total: 100, type: ProgressType.Linear }) + Row({ space: 8 }) { + Button(RemoteI18n.t('workspaceTools.resumeUpload')).fontColor(INK).backgroundColor(PAGE_BG).onClick(() => this.onIntent({ type: 'upload-resume' })) + Button(RemoteI18n.t('workspaceTools.cancelUpload')).fontColor(INK).backgroundColor(PAGE_BG).onClick(() => this.onIntent({ type: 'upload-cancel' })) + }.enabled(!this.state.busy) + } + }.width('100%').margin({ bottom: this.insets.bottomPadding(16) }) } }.padding({ top: 12 }).width('100%').height('100%') .bindSheet(this.state.fileAction.length > 0, this.FileActionForm(), { height: SheetSize.FIT_CONTENT, showClose: false, backgroundColor: PAGE_BG, keyboardAvoidMode: SheetKeyboardAvoidMode.TRANSLATE_AND_RESIZE, @@ -209,9 +240,15 @@ export struct WorkspaceToolsPanel { .onClick(() => this.onIntent({ type: 'editor-edit' })) } }.width('100%').justifyContent(FlexAlign.End) + // The editor is a WebView: its scrolling and its padding live inside the + // web renderer, so the panel cannot end it in an ArkUI tail spacer the + // way the file list does. The card is therefore fixed content, like the + // terminal key row: its bottom edge stops at the navigation bar's top, + // while the sheet's own fill still reaches the screen edge below it. WorkspaceFileEditorView({ content: this.state.content, editing: this.state.editing, lineNumbers: this.state.lineNumbers, onIntent: this.onIntent }).layoutWeight(1) .border({ width: 1, color: LINE }).borderRadius(8).backgroundColor(CARD) + .margin({ bottom: this.insets.bottomPadding(16) }) }.padding({ top: 12 }).width('100%').height('100%') } private terminalOptions(): SelectOption[] { @@ -263,6 +300,9 @@ export struct WorkspaceToolsPanel { .accessibilityText(RemoteI18n.t('workspaceTools.closeTerminal')) .onClick(() => this.onIntent({ type: 'terminal-close' })) }.width('100%').enabled(this.state.terminalId.length > 0) + // The key row is the tab's fixed bottom control; it keeps the strip + // clear so the terminal above it ends above the navigation bar. + .margin({ bottom: this.insets.bottomPadding(16) }) } }.padding({ top: 8 }).width('100%').height('100%') } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets index 4e8d52bbe2..cdc4e02c74 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets @@ -129,22 +129,13 @@ export class ChatMessageStructurePolicy { // Remote snapshots also mark tools executed inside a subagent. Those // are Task details, not sibling subagent cards in the parent timeline. if (ChatMessageStructurePolicy.isSubagentInternalTool(entry)) return; - if (ChatMessageStructurePolicy.shouldFoldIntoActivityGroup(entry)) { - activityItems.push(entry); - activityStatuses.push(ChatMessageStructurePolicy.itemStatus(entry, index, activeItemIndex, - activeScope, messageStatus)); - activityStreaming.push(ChatMessageStructurePolicy.itemShouldStream(entry, index, activeItemIndex, activeScope)); - activityChildScopes.push(ChatMessageStructurePolicy.itemChildActiveScope(entry, index, - activeItemIndex, activeScope)); - activityHasThinking = activityHasThinking || ChatMessageStructurePolicy.isThinkingEntry(entry); - return; - } - - flushActivity(); - // Task tool entries are subagent branches, not ordinary tool rows. Route - // them as structured items before the generic tool buffer so they keep - // their branch title, children, and lifecycle presentation. + // Task tool entries are subagent branches, not ordinary tool rows, and a + // subagent branch is never parent activity. Both checks run before the + // activity fold so a child's reasoning is never summarised as the + // parent's own, and so the branch keeps its title, children, and + // lifecycle presentation. if (ChatMessageStructurePolicy.isSubagentEntry(entry)) { + flushActivity(); flushTools(); const subagentPath = nextPath('subagent'); groups.push({ @@ -159,6 +150,18 @@ export class ChatMessageStructurePolicy { }); return; } + if (ChatMessageStructurePolicy.shouldFoldIntoActivityGroup(entry)) { + activityItems.push(entry); + activityStatuses.push(ChatMessageStructurePolicy.itemStatus(entry, index, activeItemIndex, + activeScope, messageStatus)); + activityStreaming.push(ChatMessageStructurePolicy.itemShouldStream(entry, index, activeItemIndex, activeScope)); + activityChildScopes.push(ChatMessageStructurePolicy.itemChildActiveScope(entry, index, + activeItemIndex, activeScope)); + activityHasThinking = activityHasThinking || ChatMessageStructurePolicy.isThinkingEntry(entry); + return; + } + + flushActivity(); if (entry.tool) { toolBuffer.push(entry.tool); return; @@ -193,6 +196,11 @@ export class ChatMessageStructurePolicy { * A Task's children can arrive nested in its own items and again flat behind * it, so folding the restatement would draw every child twice. A counted * restatement is skipped, and a child that legitimately repeats survives. + * + * A marked record with no owning Task in this list — a partial snapshot, or a + * child that arrived before its Task — keeps its own collapsed branch rather + * than being dropped, because the process it carries is exactly what the + * reader opened the branch to see. */ static scopeSubagentItems(items: ConversationUiMessageItem[]): ConversationUiMessageItem[] { const result: ConversationUiMessageItem[] = []; @@ -211,6 +219,8 @@ export class ChatMessageStructurePolicy { if (entry.is_subagent === true) { if (currentTask) { ChatMessageStructurePolicy.appendScopedChild(currentTask, carried, entry); + } else { + result.push(ChatMessageStructurePolicy.orphanSubagentBranch(entry)); } return; } @@ -225,6 +235,34 @@ export class ChatMessageStructurePolicy { return result; } + /** + * A marked record whose Task is not in this list still owns process the reader + * must be able to open, so it becomes its own collapsed branch. The outer copy + * keeps the marker and the tool identity that title and status come from; the + * inner copy drops the marker so the same record renders as branch content + * instead of nesting a second branch inside the first. + * + * A non-tool record's text is that content: leaving it on the outer copy would + * print it twice, once as the branch label and once as the step it labels, so + * the label falls back to the generic Task name and the body comes only from + * the child. A tool record keeps its text, because its label already resolves + * through the tool identity and its child draws a tool row rather than text. + */ + private static orphanSubagentBranch(entry: ConversationUiMessageItem): ConversationUiMessageItem { + const branch = ChatMessageStructurePolicy.copyItem(entry); + if (branch.subItems && branch.subItems.length > 0) { + return branch; + } + const child = ChatMessageStructurePolicy.copyItem(entry); + child.is_subagent = false; + child.subItems = []; + if (!entry.tool) { + branch.content = ''; + } + branch.subItems = [child]; + return branch; + } + /** Only original nested occurrences are matched; new flat entries consume no quota. */ private static appendScopedChild( task: ConversationUiMessageItem, carried: number[], entry: ConversationUiMessageItem @@ -425,9 +463,17 @@ export class ChatMessageStructurePolicy { (!!entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task'); } + /** + * The Task tool entry that owns a subagent branch. + * + * A Task entry may carry the subagent marker as well: the host records the + * child Session id on the Task tool item, and the remote wire maps that field + * onto `is_subagent`. The marker therefore means "this Task owns a subagent", + * not "this entry is subagent output", so it must not disqualify the owner — + * otherwise no Task ever opens a branch and every flat child record is lost. + */ private static isParentTaskEntry(entry: ConversationUiMessageItem): boolean { - return entry.is_subagent !== true && !!entry.tool && - ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task'; + return !!entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task'; } private static isRunningTaskEntry(entry: ConversationUiMessageItem): boolean { @@ -437,6 +483,43 @@ export class ChatMessageStructurePolicy { status === 'pending' || status === 'queued'; } + /** + * The branch entry a Task card is drawing, read from the transcript as it is + * now. + * + * The structured ForEach is keyed by the branch view key, which deliberately + * does not change while a subagent publishes steps, so its item builder keeps + * the entry it first captured — including the empty child list of a Task whose + * subagent had not started yet. Param expressions are re-evaluated on every + * render, so the card re-reads its branch through here instead of trusting that + * snapshot, and a step published later reaches a card that is already open. + * + * Branches are identified by the Task's tool call when the host sent one, and + * by the ordinal the render path numbered them with otherwise. + */ + static subagentBranchAt( + items: ConversationUiMessageItem[], + ordinal: number, + ownerToolId: string + ): ConversationUiMessageItem | undefined { + const branches = ChatMessageStructurePolicy.scopeSubagentItems(items) + .filter((entry: ConversationUiMessageItem) => ChatMessageStructurePolicy.isSubagentEntry(entry)); + if (ownerToolId.length > 0) { + const owned = branches.find((entry: ConversationUiMessageItem) => + !!entry.tool && entry.tool.id === ownerToolId); + if (owned) return owned; + } + return ordinal >= 0 && ordinal < branches.length ? branches[ordinal] : undefined; + } + + /** The ordinal a branch path was numbered with, e.g. `item-subagent-2` is 2. */ + static subagentOrdinal(path: string): number { + const marker = path.lastIndexOf('-subagent-'); + if (marker < 0) return 0; + const ordinal = Number(path.slice(marker + '-subagent-'.length)); + return Number.isFinite(ordinal) && ordinal >= 0 ? ordinal : 0; + } + private static isLegacySubagentProcessEntry(entry: ConversationUiMessageItem): boolean { return ChatMessageStructurePolicy.isThinkingEntry(entry) || ChatMessageStructurePolicy.isTextEntry(entry) || !!entry.tool; @@ -456,6 +539,9 @@ export class ChatMessageStructurePolicy { } private static isSubagentInternalTool(entry: ConversationUiMessageItem): boolean { + // A marked record that already carries children is a branch of its own, not + // an internal tool of some other branch. + if (!!entry.subItems && entry.subItems.length > 0) return false; return entry.is_subagent === true && !!entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) !== 'task'; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets index aac9661b16..34d62ebf67 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets @@ -3,11 +3,170 @@ import { ConversationUiModelCatalog } from '../state/ConversationUiModels'; +/** One role row of the composer selector's defaults section. */ +export interface ConversationUiModelRoleEntry { + /** Semantic model id sent to the host: `primary` or `fast`. */ + role: string; + labelKey: string; + /** Concrete model the role runs, or undefined when it has no target. */ + model: ConversationUiModel | undefined; + /** True when the role runs the primary model because it has none of its own. */ + fallsBackToPrimary: boolean; +} + export class ConversationModelPresentationPolicy { + /** Semantic selectors the host accepts in place of a concrete model id. */ + static readonly ROLE_PRIMARY: string = 'primary'; + static readonly ROLE_FAST: string = 'fast'; + static enabledModels(catalog: ConversationUiModelCatalog): ConversationUiModel[] { return catalog.models.filter((model: ConversationUiModel) => model.enabled); } + /** + * Resolves a selector to an enabled concrete model. + * + * `primary` resolves the configured primary model; `fast` uses its own model + * and falls back to the primary model when none is configured, exactly like + * the host's own model selection. Empty, `auto`, `default` and unknown + * selectors resolve to nothing so callers keep their existing fallback chain. + */ + static resolveModel( + catalog: ConversationUiModelCatalog, + modelId: string + ): ConversationUiModel | undefined { + const value = (modelId || '').trim(); + if (value.length === 0) { + return undefined; + } + if (value === ConversationModelPresentationPolicy.ROLE_PRIMARY) { + return ConversationModelPresentationPolicy.enabledModel(catalog, catalog.default_models.primary || ''); + } + if (value === ConversationModelPresentationPolicy.ROLE_FAST) { + return ConversationModelPresentationPolicy.enabledModel(catalog, catalog.default_models.fast || '') || + ConversationModelPresentationPolicy.enabledModel(catalog, catalog.default_models.primary || ''); + } + return ConversationModelPresentationPolicy.enabledModel(catalog, value); + } + + static isSemanticModelId(modelId: string): boolean { + const value = (modelId || '').trim(); + return value === ConversationModelPresentationPolicy.ROLE_PRIMARY || + value === ConversationModelPresentationPolicy.ROLE_FAST; + } + + /** The selected role, or an empty string when the selection is not a role. */ + static selectedRole(selectedModelId: string): string { + const value = (selectedModelId || '').trim(); + return ConversationModelPresentationPolicy.isSemanticModelId(value) ? value : ''; + } + + /** + * The selected role only while it still resolves to an enabled concrete + * model. A role the catalog can no longer run is presented as no selection at + * all, so the trigger and the role rows agree. + */ + static resolvedSelectedRole(catalog: ConversationUiModelCatalog, selectedModelId: string): string { + const role = ConversationModelPresentationPolicy.selectedRole(selectedModelId); + if (role.length === 0) { + return ''; + } + return ConversationModelPresentationPolicy.resolveModel(catalog, role) ? role : ''; + } + + static roleLabelKey(role: string): string { + if (role === ConversationModelPresentationPolicy.ROLE_PRIMARY) { + return 'chat.modelPrimary'; + } + if (role === ConversationModelPresentationPolicy.ROLE_FAST) { + return 'chat.modelFast'; + } + return ''; + } + + static roleBadgeLabelKey(role: string): string { + if (role === ConversationModelPresentationPolicy.ROLE_PRIMARY) { + return 'chat.modelRolePrimary'; + } + if (role === ConversationModelPresentationPolicy.ROLE_FAST) { + return 'chat.modelRoleFast'; + } + return ''; + } + + /** Roles a concrete model id serves, in primary-then-fast order. */ + static modelRoles(catalog: ConversationUiModelCatalog, modelId: string): string[] { + const value = (modelId || '').trim(); + const roles: string[] = []; + if (value.length === 0) { + return roles; + } + if (value === (catalog.default_models.primary || '').trim()) { + roles.push(ConversationModelPresentationPolicy.ROLE_PRIMARY); + } + if (value === (catalog.default_models.fast || '').trim()) { + roles.push(ConversationModelPresentationPolicy.ROLE_FAST); + } + return roles; + } + + /** + * Defaults section rows. Empty when neither role resolves to an enabled + * model, so the selector stays a plain model list. + */ + static roleEntries(catalog: ConversationUiModelCatalog): ConversationUiModelRoleEntry[] { + const primary = ConversationModelPresentationPolicy.resolveModel( + catalog, ConversationModelPresentationPolicy.ROLE_PRIMARY); + const fast = ConversationModelPresentationPolicy.resolveModel( + catalog, ConversationModelPresentationPolicy.ROLE_FAST); + if (!primary && !fast) { + return []; + } + const ownFast = ConversationModelPresentationPolicy.enabledModel(catalog, catalog.default_models.fast || ''); + return [ + { + role: ConversationModelPresentationPolicy.ROLE_PRIMARY, + labelKey: ConversationModelPresentationPolicy.roleLabelKey(ConversationModelPresentationPolicy.ROLE_PRIMARY), + model: primary, + fallsBackToPrimary: false + }, + { + role: ConversationModelPresentationPolicy.ROLE_FAST, + labelKey: ConversationModelPresentationPolicy.roleLabelKey(ConversationModelPresentationPolicy.ROLE_FAST), + model: fast, + fallsBackToPrimary: !ownFast + } + ]; + } + + /** + * Trigger label key for a role selection, or an empty string when the + * selection names a concrete model or the role no longer resolves. + */ + static selectedModelLabelKey(catalog: ConversationUiModelCatalog, selectedModelId: string): string { + return ConversationModelPresentationPolicy.roleLabelKey( + ConversationModelPresentationPolicy.resolvedSelectedRole(catalog, selectedModelId)); + } + + /** + * Notice key that replaces a role row's resolved-model metadata, or an empty + * string when the row shows the resolved model instead. + */ + static roleMetaNoticeKey(entry: ConversationUiModelRoleEntry): string { + return entry.fallsBackToPrimary ? 'chat.modelFastNotConfigured' : ''; + } + + /** + * True when a concrete row owns the current selection. A role selection + * highlights its role row instead, so no concrete row is marked. + */ + static isConcreteModelSelected(selectedModelId: string, modelId: string): boolean { + const value = (selectedModelId || '').trim(); + return value.length > 0 && + !ConversationModelPresentationPolicy.isSemanticModelId(value) && + value === (modelId || '').trim(); + } + static selectedModel( catalog: ConversationUiModelCatalog, selectedModelId: string @@ -18,11 +177,7 @@ export class ConversationModelPresentationPolicy { catalog.default_models.primary || '' ]; for (let index = 0; index < candidates.length; index += 1) { - const modelId = candidates[index]; - if (modelId.length === 0) { - continue; - } - const model = catalog.models.find((item: ConversationUiModel) => item.id === modelId && item.enabled); + const model = ConversationModelPresentationPolicy.resolveModel(catalog, candidates[index]); if (model) { return model; } @@ -59,6 +214,48 @@ export class ConversationModelPresentationPolicy { return model.id || primary; } + /** Provider and context-window metadata, e.g. `openai · 128k`. */ + static modelContextLabel(model: ConversationUiModel): string { + const parts: string[] = []; + const provider = ConversationModelPresentationPolicy.cleanLabel(model.provider || ''); + if (provider.length > 0) { + parts.push(provider); + } + const context = ConversationModelPresentationPolicy.contextWindowLabel(model.context_window); + if (context.length > 0) { + parts.push(context); + } + return parts.join(' · '); + } + + /** Resolved model name plus its provider/context metadata for a role row. */ + static roleMetaLabel(model: ConversationUiModel | undefined, fallback: string): string { + if (!model) { + return ''; + } + const name = ConversationModelPresentationPolicy.primaryLabel(model, fallback); + const context = ConversationModelPresentationPolicy.modelContextLabel(model); + return context.length > 0 ? `${name} · ${context}` : name; + } + + private static enabledModel( + catalog: ConversationUiModelCatalog, + modelId: string + ): ConversationUiModel | undefined { + const value = (modelId || '').trim(); + if (value.length === 0) { + return undefined; + } + return catalog.models.find((item: ConversationUiModel) => item.id === value && item.enabled); + } + + private static contextWindowLabel(contextWindow: number | undefined): string { + if (!contextWindow || contextWindow <= 0) { + return ''; + } + return `${Math.round(contextWindow / 1000)}k`; + } + private static cleanLabel(value: string): string { const trimmed = (value || '').trim(); if (trimmed.length === 0) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets index 1a53d8111a..a027149ba9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ToolStatusPresentationPolicy.ets @@ -26,7 +26,8 @@ interface TaskPreviewPayload { /** Pure tool classification and payload projection used by the ArkUI renderer. */ export class ToolStatusPresentationPolicy { static normalizedName(tool: ConversationUiToolStatus): string { - return (tool.name || 'Tool').replace(/[\s-]/g, '_').toLowerCase(); + const raw = (tool.name || '').trim(); + return (raw.length > 0 ? raw : 'Tool').replace(/[\s-]/g, '_').toLowerCase(); } static isQuestionLike(tool: ConversationUiToolStatus): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ComposerOverflowPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ComposerOverflowPreview.ets new file mode 100644 index 0000000000..d0dcf45c7b --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ComposerOverflowPreview.ets @@ -0,0 +1,122 @@ +import { ChatComposerPolicy } from '../../services/ChatComposerPolicy'; +import { ComposerBar, ComposerPresentation } from '../components/ComposerBar'; +import { INK, PAGE_BG } from '../components/Theme'; + +/** + * One line of draft that is far wider than the collapsed field, so the field has + * to mark what it cannot show. + */ +const OVERFLOW_DRAFT: string = + 'Keep the collapsed composer honest about what it cannot show: this draft is ' + + 'much wider than one line of the field, so the reader must see where it stops.'; + +/** + * A newline puts the same production composer into its tall form, which is the + * other half of the contract: the expanded field keeps a scrollable viewport and + * the expanded action row carries the draft's supplemental microphone beside the + * primary action. + */ +const MULTILINE_DRAFT: string = + 'Expanded draft, first line\nsecond line\nthird line'; + +/** + * The composer's draft chrome as the phone draws it: an empty field reaches + * dictation from the primary slot, a collapsed draft keeps its one line to + * itself and marks the overflow with an ellipsis instead of scrolling sideways, + * and an expanded draft gets the supplemental microphone beside Send. + * + * This mounts the production `ComposerBar` with the same capabilities the remote + * chat surface uses and no host, so the pixels are the shipping pixels. The + * `composer-overflow-state` line asks the production `ChatComposerPolicy` the + * two questions that decide the chrome — which primary action the draft earns, + * and whether the supplemental voice slot is wanted — so a screenshot can be read + * without inferring the decision from the glyphs alone. `supplementalVoice` + * reports the slot as mounted, not the bare policy answer: the collapsed field + * gives its one line to the draft, so the slot only reaches the screen from the + * expanded action row. + * + * **Next draft** cycles the three states on one install: the wide single line, + * the empty field, and the multiline draft. **Resize** swaps the compact and + * floating presentations without restarting the app. + */ +@ComponentV2 +export struct ComposerOverflowPreview { + @Local draftIndex: number = 0; + @Local draft: string = OVERFLOW_DRAFT; + @Local floating: boolean = false; + + aboutToAppear(): void { + // The launcher picks the starting state and the posture through the scenario + // id, so each draft can be captured without an extra tap. + const scenarioId = AppStorage.get('scenarioId') ?? ''; + this.draftIndex = this.startingDraftIndex(scenarioId); + this.draft = this.draftFor(this.draftIndex); + this.floating = scenarioId.endsWith('-wide'); + } + + private startingDraftIndex(scenarioId: string): number { + if (scenarioId.startsWith('composer-overflow-empty')) { + return 1; + } + return scenarioId.startsWith('composer-overflow-expanded') ? 2 : 0; + } + + private draftFor(index: number): string { + if (index === 1) { + return ''; + } + return index === 2 ? MULTILINE_DRAFT : OVERFLOW_DRAFT; + } + + private nextDraft(): void { + this.draftIndex = (this.draftIndex + 1) % 3; + this.draft = this.draftFor(this.draftIndex); + } + + /** The same expansion decision the composer makes, read from the policy. */ + private collapsed(): boolean { + return !ChatComposerPolicy.isExpanded(this.draft, false, false); + } + + private stateLine(): string { + // The remote chat capabilities, an idle connection and no running turn: the + // two draft states this fixture is about are decided by the text alone. + const action = ChatComposerPolicy.primaryAction( + this.draft, 0, false, false, false, true, true, 'connected', true); + const collapsed = this.collapsed(); + // The policy owns whether a draft earns the slot; the collapsed field gives + // its one line to the draft, so the slot only reaches the screen from the + // expanded action row. Report what is mounted, which is what a reader can + // check against the pixels. + const supplementalVoice = !collapsed && + ChatComposerPolicy.shouldShowSupplementalVoice(this.draft, 0, false, true); + const draft = this.draft.length === 0 ? 'empty' : `${this.draft.length} chars`; + return `draft=${draft} collapsed=${collapsed} action=${action} ` + + `supplementalVoice=${supplementalVoice}`; + } + + build() { + Column({ space: 8 }) { + Row({ space: 8 }) { + Button('Next draft').id('composer-overflow-draft').onClick(() => { + this.nextDraft(); + }) + Button('Resize').id('composer-overflow-resize').onClick(() => { + this.floating = !this.floating; + }) + } + Text(this.stateLine()).id('composer-overflow-state').fontColor(INK).fontSize(12) + Blank() + ComposerBar({ + presentation: this.floating ? ComposerPresentation.Floating : ComposerPresentation.Compact, + chatInput: this.draft, + onChatInputChange: (value: string) => { + this.draft = value; + } + }) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/FileVisibilityPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/FileVisibilityPreview.ets new file mode 100644 index 0000000000..a30f68eea4 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/FileVisibilityPreview.ets @@ -0,0 +1,453 @@ +import { + ChatMessage, + FileInfo, + ReadFileChunkResult, + ReadFileResult, + RuntimeFileLocation, + SessionSummary +} from '../../model/RemoteModels'; +import { ChatTimelineRowStore, ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; +import { DurableSessionReducer, SessionRecord } from '../../services/DurableSessionReducer'; +import { ChatTimelineStore } from '../../services/ChatTimelineStore'; +import { HostSessionEvent } from '../../services/HostSessionStream'; +import { + RemoteFileDownloadClient, + RemoteFileDownloadController, + RemoteFileDownloadScheduler, + RemoteFileSaver +} from '../../services/RemoteFileDownloadController'; +import { Encoding } from '../../services/Encoding'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; +import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; +import { FilePreviewController } from '../viewmodel/FilePreviewController'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { ChatTimeline } from '../components/ChatTimeline'; +import { FilePreviewSurface } from '../components/FilePreviewSurface'; +import { INK, PAGE_BG } from '../components/Theme'; + +/** One file the fixture host can serve, in the shape the wire would carry it. */ +class PreviewFile { + readonly path: string; + readonly name: string; + readonly mimeType: string; + readonly contentBase64: string; + /** Derived from the bytes, so the reported size can never drift from them. */ + readonly size: number; + + constructor(path: string, name: string, mimeType: string, contentBase64: string) { + this.path = path; + this.name = name; + this.mimeType = mimeType; + this.contentBase64 = contentBase64; + this.size = Encoding.base64ToBytes(contentBase64).length; + } + + static text(path: string, mimeType: string, text: string): PreviewFile { + return new PreviewFile( + path, + PreviewFileHost.basename(path), + mimeType, + Encoding.bytesToBase64(Encoding.utf8ToBytes(text)) + ); + } +} + +/** + * A tiny real PNG so the image branch decodes on device instead of failing on a + * placeholder, plus the text files the fixture message links to. + */ +const PREVIEW_PNG_BASE64: string = + 'iVBORw0KGgoAAAANSUhEUgAAAHgAAABICAIAAACyfKYoAAABG0lEQVR42u3XIQ5CQRRD0WocG2BfbInV4pAfhQFDArlvMr3N09PmuMnp+ni7w7xyv13+dfmE1pqD1pqD1pqD1pqDLrf+J/RxHFpD0Fpz0Fpz0Fpz0Fpz0Fpz0Fpz0Fpz0Fpz0Fpz0OXWKHSzNQ1daz0A3Wk9A11oPQbdZj0JXWU9DN1jPQ9dYr0EdIP1KtD+0b9Pfn9Cawhaaw5aaw5aaw5aaw5aaw5aaw5aaw5aaw5aaw5aaw5aaw5aaw5a65BlzdaB+2qtw1d2WmektdA6U8Vt1hnsrrLObH2PdcYXlFhnhREN1llkx/bWWWfK3tZZas3G1llt0K7WWXDTltY5GyRCCy20EVpooSUQWmgjtNBCG6GFNkILLbQh8gRhd1Ue5saG0wAAAABJRU5ErkJggg=='; + +class PreviewFileHost implements RemoteFileDownloadClient { + private readonly files: Map = new Map(); + + constructor() { + this.add(new PreviewFile('png/openbitfun-wordmark.png', 'openbitfun-wordmark.png', 'image/png', + PREVIEW_PNG_BASE64)); + this.add(PreviewFile.text('artifacts/REPORT.md', 'text/markdown', + '# Brand report\n\nThe wordmark and the bundle are ready.\n')); + this.add(new PreviewFile('artifacts/bundle.zip', 'bundle.zip', 'application/zip', 'UEsDBAoAAAAAAA==')); + this.add(PreviewFile.text('artifacts/run.log', 'text/plain', + 'brand export ok\nbundled 2 files\n')); + } + + private add(file: PreviewFile): void { + this.files.set(file.path, file); + } + + static basename(path: string): string { + const parts = path.split('/'); + return parts[parts.length - 1] || path; + } + + async getFileInfo(path: string, _sessionId?: string): Promise { + const file = this.files.get(path); + if (!file) { + throw new Error(`File not found: ${path}`); + } + return { name: file.name, size: file.size, mimeType: file.mimeType }; + } + + async readFileChunk(path: string, offset: number, limit: number, + _sessionId?: string): Promise { + const file = this.files.get(path); + if (!file) { + throw new Error(`File not found: ${path}`); + } + const bytes = Encoding.base64ToBytes(file.contentBase64); + const end = Math.min(bytes.length, offset + limit); + const slice = bytes.slice(offset, end); + return { + revision: '1:1', + name: file.name, + contentBase64: Encoding.bytesToBase64(slice), + offset, + chunkSize: slice.length, + totalSize: bytes.length, + mimeType: file.mimeType + }; + } + + async streamFile(path: string, sessionId: string | undefined, + onChunk: (bytes: Uint8Array, info: FileInfo) => Promise, + onProgress?: (downloaded: number, total: number) => void, + _location?: RuntimeFileLocation): Promise { + const file = this.files.get(path); + if (!file) { + throw new Error(`File not found: ${path}`); + } + const bytes = Encoding.base64ToBytes(file.contentBase64); + const info = await this.getFileInfo(path, sessionId); + await onChunk(bytes, info); + if (onProgress) { + onProgress(bytes.length, bytes.length); + } + return info; + } + + async readFile(path: string, sessionId?: string, onProgress?: (downloaded: number, total: number) => void, + maxBytes?: number): Promise { + const file = this.files.get(path); + if (!file) { + throw new Error(`File not found: ${path}`); + } + if (maxBytes !== undefined && file.size > maxBytes) { + throw new Error('File too large for preview.'); + } + const info = await this.getFileInfo(path, sessionId); + if (onProgress) { + onProgress(file.size, file.size); + } + return { + name: info.name, + mimeType: info.mimeType, + size: info.size, + contentBase64: file.contentBase64 + }; + } +} + +/** + * Stands in for the system document picker and the file it would write, so a + * download can be proved on a device without a destination dialog in the way. + * The production `RemoteFileDownloadController` owns everything else. + */ +class PreviewFileSaver implements RemoteFileSaver { + destinationName: string = ''; + bytesWritten: number = 0; + committed: boolean = false; + aborted: boolean = false; + + reset(): void { + this.destinationName = ''; + this.bytesWritten = 0; + this.committed = false; + this.aborted = false; + } + + async begin(name: string): Promise { + this.destinationName = name; + } + + async write(bytes: Uint8Array): Promise { + this.bytesWritten += bytes.length; + } + + async finish(): Promise { + this.committed = true; + return 'file://preview/download'; + } + + async abort(): Promise { + this.aborted = true; + } +} + +class PreviewDownloadScheduler implements RemoteFileDownloadScheduler { + setTimeout(_callback: () => void, _delayMs: number): number { + return 1; + } + + clearTimeout(_timerId: number): void {} +} + +/** + * The file-visibility chain as the phone walks it, driven by the production + * components: a durable assistant turn whose prose links workspace files is + * projected through the real reducer, timeline store and rows, and a tap on a + * card routes through the real `FilePreviewController` into the real + * `FilePreviewSurface`. + * + * Only the transport is faked. The fixture host answers `get_file_info`, + * `read_file_chunk` and the streamed read exactly like `remote_connect.rs` + * does, so the image branch decodes a real PNG and the text branches show real + * content. **Next shape** swaps the same links between the final-answer body + * and a composite turn's trailing text, which is the two paths that draw cards. + */ +@ComponentV2 +export struct FileVisibilityPreview { + private reducer: DurableSessionReducer = new DurableSessionReducer(); + private readonly timeline: ChatTimelineStore = new ChatTimelineStore(); + private readonly rowStore: ChatTimelineRowStore = new ChatTimelineRowStore(); + private readonly fileHost: PreviewFileHost = new PreviewFileHost(); + private readonly previewState: FilePreviewState = new FilePreviewState(); + private readonly fileSaver: PreviewFileSaver = new PreviewFileSaver(); + private readonly downloads: RemoteFileDownloadController = new RemoteFileDownloadController( + this.fileHost, + (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string): void => { + this.downloadingFilePath = downloadingFilePath; + this.downloadedFilePath = downloadedFilePath; + this.downloadStatus = fileDownloadStatus; + }, + (): void => { + this.downloadingFilePath = ''; + }, + (statusText: string): void => { + this.downloadStatus = statusText; + this.savedSummary = this.fileSaver.committed ? + `${this.fileSaver.destinationName}:${this.fileSaver.bytesWritten}` : ''; + }, + (_isBusy: boolean): void => {}, + 2500, + new PreviewDownloadScheduler(), + this.fileSaver + ); + @Local rows: ObservableChatTimelineItem[] = []; + @Local revision: number = 0; + @Local shape: number = 0; + @Local routed: string = ''; + @Local previewVisible: boolean = false; + @Local downloadingFilePath: string = ''; + @Local downloadedFilePath: string = ''; + @Local downloadStatus: string = ''; + @Local savedSummary: string = ''; + private previewController?: FilePreviewController = undefined; + + aboutToAppear(): void { + this.previewController = new FilePreviewController( + this.fileHost, + this.previewState, + { + remoteAvailable: (): boolean => true, + activeSession: (): SessionSummary => { + const summary: SessionSummary = { + sessionId: 'preview-file-session', + title: 'File visibility', + workspacePath: '/workspace/project', + agentType: 'code' + }; + return summary; + }, + workspacePath: (): string => '/workspace/project', + openExternalLink: async (_reference: string): Promise => true, + onGeneralStatus: (_statusText: string): void => {}, + onRemoteStatus: (statusText: string): void => { + this.routed = statusText; + } + } + ); + this.timeline.reset('preview-file-session'); + this.publishShape(); + } + + aboutToDisappear(): void { + if (this.previewController) { + this.previewController.close(); + } + } + + private finalAnswer(): string { + return 'Done. The brand export wrote these files:\n\n' + + '- [openbitfun-wordmark.png](computer://png/openbitfun-wordmark.png)\n' + + '- [REPORT.md](computer://artifacts/REPORT.md)\n' + + '- [bundle.zip](computer://artifacts/bundle.zip)\n\n' + + 'The run log is at computer://artifacts/run.log, and the source image is ' + + 'png/openbitfun-wordmark.png.'; + } + + private trailingAnswer(): string { + return 'Checked the workspace files.\n\n' + + '- [openbitfun-wordmark.png](computer://png/openbitfun-wordmark.png)\n' + + '- [REPORT.md](computer://artifacts/REPORT.md)'; + } + + private record(id: string, revision: number, type: string, order: number, content: string, + toolName: string): HostSessionEvent { + const payload: SessionRecord = { + sessionId: 'preview-file-session', + id: `item/${id}`, + revision, + turn: { + turnId: 'turn-file-preview', + turnIndex: 0, + sessionId: 'preview-file-session', + timestamp: 1, + userMessage: { id: 'user-file-preview', content: 'Export the brand assets', timestamp: 1 }, + status: 'completed' + }, + round: { id: 'round-file-preview', turnId: 'turn-file-preview', roundIndex: 0, timestamp: 2, + status: 'completed' }, + item: { + type, + data: { id, orderIndex: order, timestamp: 3, content, toolName, status: 'completed' } + } + }; + return { session_id: 'preview-file-session', event: 'session-record', payload }; + } + + /** Rebuilds the transcript for the selected shape and republishes the rows. */ + private publishShape(): void { + this.reducer = new DurableSessionReducer(); + if (this.shape === 0) { + this.reducer.apply(this.record('final-text', 1, 'text', 0, this.finalAnswer(), '')); + } else { + this.reducer.apply(this.record('think', 1, 'thinking', 0, 'Reading the export output', '')); + this.reducer.apply(this.record('read', 2, 'tool', 1, '', 'Read')); + this.reducer.apply(this.record('trailing-text', 3, 'text', 2, this.trailingAnswer(), '')); + } + this.publish(); + } + + private publish(): void { + const messages = this.reducer.messages(); + this.timeline.applySnapshot({ + sessionId: 'preview-file-session', + cursor: { pollVersion: 0, knownMessageCount: messages.length, knownModelCatalogVersion: 0 }, + changed: true, + title: '', + sessionState: 'idle', + newMessages: [], + messageSnapshot: messages, + activeTurn: undefined, + shouldSyncAfterTurnEnded: false, + historyRewritten: false, + completedTurnId: '' + }); + this.rows = this.rowStore.reconcile(this.timeline.project(false)); + this.revision++; + } + + private openFilePreview(reference: string, label: string): void { + if (!this.previewController) { + return; + } + this.routed = reference; + this.previewController.open(AppRoute.RemoteChat, new FilePreviewRequest(reference, label)); + this.previewVisible = this.previewState.visible; + } + + private routeSummary(): string { + const phase = this.previewState.visible ? this.previewState.phase : FilePreviewPhase.Idle; + const saved = this.savedSummary.length > 0 ? ` saved=${this.savedSummary}` : ''; + return `shape=${this.shape} phase=${phase} kind=${this.previewState.rendererKind} ` + + `bytes=${this.previewState.loadedBytes} last=${this.routed}${saved}`; + } + + private downloadFile(reference: string): void { + this.downloadedFilePath = ''; + this.savedSummary = ''; + this.fileSaver.reset(); + void this.downloads.download( + reference, + 'preview-file-session', + false, + true + ); + } + + private closePreview(): void { + if (this.previewController) { + this.previewController.close(); + } + this.previewVisible = false; + } + + build() { + Stack() { + Column({ space: 8 }) { + Row({ space: 8 }) { + Button('Next shape').id('file-visibility-shape').onClick(() => { + this.shape = this.shape === 0 ? 1 : 0; + this.closePreview(); + this.publishShape(); + }) + } + Text(this.routeSummary()).id('file-visibility-state').fontColor(INK).fontSize(12) + ChatTimeline({ + timelineItems: this.rows, + timelineRevision: this.revision, + connectionState: 'connected', + isBusy: false, + downloadingFilePath: this.downloadingFilePath, + downloadedFilePath: this.downloadedFilePath, + fileDownloadStatus: this.downloadStatus, + activeFilePreviewPath: this.previewState.visible ? this.previewState.target.remotePath : '', + activeFilePreviewLoading: this.previewState.visible && + this.previewState.phase === FilePreviewPhase.Loading, + onOpenFilePreview: (path: string, label: string) => { + this.openFilePreview(path, label); + }, + onDownloadFile: (path: string) => { + this.downloadFile(path); + } + }) + .layoutWeight(1) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + + if (this.previewVisible) { + Column() { + FilePreviewSurface({ + state: this.previewState, + remoteAvailable: true, + downloadPath: this.downloadingFilePath, + downloadedPath: this.downloadedFilePath, + downloadStatus: this.downloadStatus, + onClose: () => { + this.closePreview(); + }, + onRefresh: () => { + if (this.previewController) { + this.previewController.refresh(); + } + }, + onDownload: (path: string) => { + this.downloadFile(path); + }, + onOpenLink: (reference: string, label: string) => { + this.openFilePreview(reference, label); + } + }) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + } + .width('100%') + .height('100%') + } +} \ No newline at end of file diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets new file mode 100644 index 0000000000..ce67b41656 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets @@ -0,0 +1,492 @@ +import { ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; +import { ChatMessage, RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { MiniAppSummary } from '../../model/MiniAppModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ChatSurface } from '../state/ChatSurface'; +import { REMOTE_CHAT_COMPOSER_CAPABILITIES } from '../state/ChatComposerCapabilities'; +import { AppShellState } from '../state/AppShellState'; +import { MiniAppState } from '../state/MiniAppState'; +import { RemotePageState } from '../state/RemotePageState'; +import { + DeviceDirectoryEntry, + DeviceDirectoryState, + emptyDeviceDirectoryEntry +} from '../state/DeviceDirectoryState'; +import { WorkspaceToolsState, WorkspaceFileEntry } from '../state/WorkspaceToolsState'; +import { ConversationView } from '../components/ConversationView'; +import { AppShell } from '../components/AppShell'; +import { MiniAppSurface } from '../components/MiniAppSurface'; +import { AppConnectSurface, AppSettingsSurface, AppSidebarSurface } from '../components/AppRootOverlaySurfaces'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from '../components/remote/RemoteSurfaceHost'; +import { AdaptiveSheetOptions } from '../components/AdaptiveSheetOptions'; +import { + SettingsPlacementPolicy, + SettingsSheetKind +} from '../policy/SettingsPlacementPolicy'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { ComposerPresentation } from '../components/ComposerBar'; +import { ConversationViewOptions } from '../components/ConversationViewContract'; +import { ConversationViewState } from '../state/ConversationViewState'; +import { ConversationIntent } from '../actions/ConversationIntent'; + +/** Turns in the transcript. Long enough that the list has to scroll to reach the end. */ +const TRANSCRIPT_TURNS: number = 16; + +/** Tiles in the mini-app gallery: enough rows that the gallery itself scrolls. */ +const GALLERY_APPS: number = 9; + +/** Devices the account panel lists: enough rows that the settings sheet scrolls. */ +const SETTINGS_DEVICES: number = 9; + +/** Workspaces on the fixture desktop: enough rows that the sidebar's directory scrolls. */ +const SIDEBAR_WORKSPACES: number = 14; + +/** Sessions in the first workspace: the sidebar's own scrollable rows. */ +const SIDEBAR_SESSIONS: number = 36; + +/** Files in the workspace tools sheet: enough rows that the file list scrolls. */ +const TOOLS_FILES: number = 18; + +/** Lines in the seeded editor file: enough that the editor's web view scrolls. */ +const EDITOR_LINES: number = 120; + +/** Prose tall enough to be read while it is already half faded out. */ +const ANSWER_PARAGRAPH: string = + 'The transcript runs the full height of the pane, so the last answer keeps ' + + 'scrolling until it reaches the bottom edge of the screen. The composer ' + + 'floats over it and the fade carries the text into the page colour instead ' + + 'of cutting it off at a hard line.'; + +const FINAL_ANSWER_PARAGRAPH: string = + 'This is the final answer of the transcript, and it decides whether the bottom ' + + 'edge reads as a continuation of the conversation or as a strip of empty page ' + + 'colour: it must be able to scroll above the floating composer, and everything ' + + 'below the composer must still look like the surface the answer is drawn on. A ' + + 'reader who flings the list to the end should see the answer fade into the page ' + + 'rather than stop short of it.'; + +function message(id: string, role: string, text: string): ChatMessage { + return { id, role, text, status: 'completed', timestamp: '0' }; +} + +function row(id: string, role: string, text: string): ObservableChatTimelineItem { + return new ObservableChatTimelineItem({ + id, + type: role === 'user' ? 'user_message' : 'assistant_message', + message: message(id, role, text), + isStreaming: false, + isFinalizing: false + }); +} + +function transcript(): ObservableChatTimelineItem[] { + const rows: ObservableChatTimelineItem[] = []; + for (let index = 0; index < TRANSCRIPT_TURNS; index++) { + const turn = index + 1; + rows.push(row(`turn-${turn}-user`, 'user', `Round ${turn}: how does the bottom of the chat page read?`)); + rows.push(row(`turn-${turn}-assistant`, 'assistant', + turn === TRANSCRIPT_TURNS ? FINAL_ANSWER_PARAGRAPH : ANSWER_PARAGRAPH)); + } + return rows; +} + +function gallery(): MiniAppSummary[] { + const apps: MiniAppSummary[] = []; + for (let index = 0; index < GALLERY_APPS; index++) { + const tile = index + 1; + apps.push({ + id: `gallery-${tile}`, + name: `Mini app ${tile}`, + description: 'Offline sample tile, so the gallery has rows below the fold.' + }); + } + return apps; +} + +function settingsDevices(): CloudAccountDevice[] { + const devices: CloudAccountDevice[] = []; + for (let index = 0; index < SETTINGS_DEVICES; index++) { + const device = index + 1; + devices.push({ + deviceId: `account-desktop-${device}`, + deviceName: `Account desktop ${device}`, + online: device % 3 !== 0, + lastSeenAt: device + }); + } + return devices; +} + +function sidebarWorkspaces(): RecentWorkspaceEntry[] { + const workspaces: RecentWorkspaceEntry[] = []; + for (let index = 0; index < SIDEBAR_WORKSPACES; index++) { + const workspace = index + 1; + workspaces.push({ + path: `/workspace/project-${workspace}`, + name: `Workspace ${workspace}`, + lastOpened: `${workspace}`, + workspaceKind: 'normal', + workspaceId: `ws-${workspace}` + }); + } + return workspaces; +} + +function sidebarSessions(): RemoteSession[] { + const sessions: RemoteSession[] = []; + for (let index = 0; index < SIDEBAR_SESSIONS; index++) { + const session = index + 1; + sessions.push({ + id: `sidebar-session-${session}`, + title: `Sidebar session ${String(session).padStart(2, '0')}`, + agentType: 'code', + status: 'completed', + updatedAt: `${session}`, + createdAt: `${session}`, + messageCount: session * 3, + workspacePath: '/workspace/project-1', + workspaceName: 'Workspace 1', + deviceId: 'fixture-desktop' + }); + } + return sessions; +} + +function toolsFiles(): WorkspaceFileEntry[] { + const entries: WorkspaceFileEntry[] = []; + for (let index = 0; index < TOOLS_FILES; index++) { + const file = index + 1; + const name = `report-${String(file).padStart(2, '0')}.md`; + entries.push({ + path: `/workspace/${name}`, + name, + isDirectory: false, + size: file * 64, + modifiedTime: file + }); + } + return entries; +} + +/** The file the editor tab opens: long enough that its content has to scroll. */ +function toolsEditorContent(): string { + const lines: string[] = []; + for (let index = 0; index < EDITOR_LINES; index++) { + const line = index + 1; + lines.push(`# Section ${line}`); + lines.push(''); + lines.push(`This line is body copy for section ${line}, long enough that the`); + lines.push('editor has to scroll before the last line can come to rest.'); + lines.push(''); + } + return lines.join('\n'); +} + +/** + * The empty actions with one override. The interface's members are readonly, + * so the settings sheet's device catalog is supplied by building the whole + * literal once rather than assigning into it. + */ +function previewActionsWithDevices(devices: CloudAccountDevice[]): AppRootPresentationActions { + const base = emptyAppRootPresentationActions(); + return { + onWorkspaceTools: base.onWorkspaceTools, + onNavigationBack: base.onNavigationBack, + onConversationIntent: base.onConversationIntent, + onCloseSidebar: base.onCloseSidebar, + onCompactLayoutEntered: base.onCompactLayoutEntered, + onLayoutModeChanged: base.onLayoutModeChanged, + onRemoteHome: base.onRemoteHome, + onSidebar: base.onSidebar, + onSettings: { + close: base.onSettings.close, + accountSettled: base.onSettings.accountSettled, + disconnect: base.onSettings.disconnect, + clearPairing: base.onSettings.clearPairing, + reconnect: base.onSettings.reconnect, + openAccount: base.onSettings.openAccount, + cloudCancelLogin: base.onSettings.cloudCancelLogin, + cloudLogin: base.onSettings.cloudLogin, + cloudLogout: base.onSettings.cloudLogout, + cloudListDevices: async (): Promise => devices, + cloudSelectDevice: base.onSettings.cloudSelectDevice, + getPermissionMode: base.onSettings.getPermissionMode, + setPermissionMode: base.onSettings.setPermissionMode, + setLanguage: base.onSettings.setLanguage + }, + onConnect: base.onConnect, + onFilePreview: base.onFilePreview, + generalStatus: base.generalStatus + }; +} + +/** + * The chat page's bottom edge, drawn by the production `ConversationView` over a + * transcript long enough to scroll: the composer and the status line float over + * the list, and the list carries the page colour down to the bottom of the + * screen. + * + * This fixture exists because the bottom edge cannot be judged from the composer + * alone: where the input ends up, how far the last message can scroll, and which + * colour the fade finishes on are three separate readings of one capture, and the + * layout dump has to show the same pane the pixels do. + * + * The scenario id picks the state, because the shell has to be in it before the + * first frame: `-wide` swaps the composer to the wide detail pane's floating + * presentation, `-drawer` mounts the production `AppShell` with its drawer open + * over the same conversation, `-settings` and `-cover` mount the shell with its + * settings sheet and its compact account cover open, `-miniapps` mounts the + * mini-app gallery, `-tools` opens the workspace tools sheet over the drawer + * (with `-tools-editor` opening it on the editor tab and + * `-tools-file-action` opening its file action form), `-view-settings` opens + * the remote view settings sheet over the same conversation, and `-connect` + * opens the connect sheet. The drawer, settings and tools states seed remote + * content — a device with workspaces and sessions, an account with desktops, a + * directory of files — because a surface whose content fits its viewport has no + * bottom edge to read. None of them changes the window posture: every one of + * these surfaces shares the shell's immersive layout, so every one of them is + * where a control can end up under the navigation bar. + */ +@ComponentV2 +export struct ImmersiveBottomPreview { + @Local state: ConversationViewState = this.initialState(); + @Local shellState: AppShellState = this.initialShellState(); + @Local miniAppState: MiniAppState = this.initialMiniAppState(); + @Local remotePageState: RemotePageState = new RemotePageState(); + @Local directoryState: DeviceDirectoryState = new DeviceDirectoryState(); + @Local previewActions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Local floating: boolean = false; + @Local drawer: boolean = false; + @Local settings: boolean = false; + @Local cover: boolean = false; + @Local galleryVisible: boolean = false; + @Local wideShell: boolean = false; + @Local viewSettings: boolean = false; + @Local showViewSettingsSheet: boolean = false; + @Local remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + + aboutToAppear(): void { + const scenarioId = AppStorage.get('scenarioId') ?? ''; + this.floating = scenarioId.endsWith('-wide') && !scenarioId.endsWith('-wide-shell'); + this.drawer = scenarioId.endsWith('-drawer') || scenarioId.startsWith('immersive-bottom-tools'); + this.settings = scenarioId.endsWith('-settings') || scenarioId.endsWith('-settings-dark'); + this.cover = scenarioId.endsWith('-cover'); + this.galleryVisible = scenarioId.endsWith('-miniapps'); + // The wide shell keeps the same session list as a master pane instead of a + // drawer, so its bottom edge is the same bottom edge. + this.wideShell = scenarioId.endsWith('-wide-shell'); + this.viewSettings = scenarioId.endsWith('-view-settings'); + this.showViewSettingsSheet = this.viewSettings; + // The shell's own surfaces have to be open before the first frame, and the + // scenario id is only known once the page is appearing, so the flags are + // applied here rather than in the field initialisers. + this.shellState.showSidebar = this.drawer || this.wideShell; + this.shellState.showSettings = this.settings || this.cover; + this.shellState.showConnectSheet = scenarioId.endsWith('-connect'); + if (this.cover) { + this.shellState.settingsMode = 'account'; + } + this.seedRemoteState(scenarioId); + } private initialState(): ConversationViewState { + const state = new ConversationViewState(); + state.surface = ChatSurface.Remote; + state.connectionState = 'connected'; + state.activeSession = { + sessionId: 'immersive-bottom-preview', + title: 'Bottom edge', + workspacePath: '/workspace', + agentType: 'code' + }; + state.desktopName = 'Studio'; + state.composerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; + state.timelineItems = transcript(); + state.timelineRevision = 1; + return state; + } + + private initialShellState(): AppShellState { + return new AppShellState(); + } + + private initialMiniAppState(): MiniAppState { + const state = new MiniAppState(); + state.visible = true; + state.apps = gallery(); + return state; + } + + /** + * Remote content for the shell's own surfaces. The surfaces under test scroll, + * so their fixture content has to overflow their viewport: the settings sheet + * lists an account's desktops, the drawer carries a device directory with + * workspaces and sessions, and the tools sheet carries a directory of files. + * The connect sheet carries a persisted legacy pairing link, because its + * status strip — the fixed control at its bottom edge — is what that state + * mounts there. The view settings sheet shares the drawer's directory because + * its filters are what scroll in it. + */ + private seedRemoteState(scenarioId: string): void { + if (this.settings) { + this.remotePageState.setAccountUserId('octocat'); + this.remotePageState.setAccountUsername('octocat'); + this.remotePageState.setControlTarget('account_device', 'account-desktop-1', 'Account desktop 1'); + this.remotePageState.setDesktopIdentity('Account desktop 1', 'account-desktop-1'); + this.remotePageState.connectionState = 'connected'; + this.previewActions = previewActionsWithDevices(settingsDevices()); + return; + } + if (scenarioId.endsWith('-connect')) { + this.remotePageState.setPairingInput('https://pair.example/openbitfun/legacy', 'user-1', false); + return; + } + if (this.drawer || this.viewSettings) { + const desktop: DeviceDirectoryEntry = emptyDeviceDirectoryEntry('fixture-desktop', 'Studio', true); + desktop.status = 'ready'; + desktop.workspaces = sidebarWorkspaces(); + this.directoryState.replace([desktop]); + this.directoryState.select('fixture-desktop'); + this.directoryState.setWorkspaceExpanded('fixture-desktop', '/workspace/project-1', true, + undefined, undefined, 'ws-1'); + this.remotePageState.setControlTarget('account_device', 'fixture-desktop', 'Studio'); + this.remotePageState.setDesktopIdentity('Studio', 'fixture-desktop'); + this.remotePageState.connectionState = 'connected'; + this.remotePageState.workspacePath = '/workspace/project-1'; + this.remotePageState.workspaceName = 'Workspace 1'; + this.remotePageState.workspaceId = 'ws-1'; + this.remotePageState.workspaceKind = 'normal'; + this.remotePageState.setSessions(sidebarSessions(), false); + if (scenarioId.startsWith('immersive-bottom-tools')) { + this.remotePageState.workspaceTools.visible = true; + this.remotePageState.workspaceTools.directory = '/workspace'; + this.remotePageState.workspaceTools.entries = toolsFiles(); + if (scenarioId.endsWith('-tools-editor')) { + // The editor tab: the WebView is the tab's bottom edge, so its content + // has to be long enough to scroll inside it. + this.remotePageState.workspaceTools.editorVisible = true; + this.remotePageState.workspaceTools.selectedFile = '/workspace/report-01.md'; + this.remotePageState.workspaceTools.content = toolsEditorContent(); + } + if (scenarioId.endsWith('-tools-file-action')) { + // The file action form: a FIT_CONTENT sheet whose buttons are the + // fixed controls at the screen edge. + this.remotePageState.workspaceTools.fileAction = 'file'; + } + } + } + } + + private options(): ConversationViewOptions { + const options = new ConversationViewOptions(); + options.showSidebarButton = true; + options.showBackButton = false; + options.composerPresentation = + this.floating ? ComposerPresentation.Floating : ComposerPresentation.Compact; + return options; + } + + build() { + if (this.galleryVisible) { + MiniAppSurface({ state: this.miniAppState }) + } else if (this.viewSettings) { + this.ViewSettingsLayout() + } else if (this.drawer || this.settings || this.cover || this.wideShell || + this.shellState.showConnectSheet) { + this.ShellLayout() + } else { + this.Conversation() + } + } + + /** + * The remote view settings sheet, mounted the way the app root mounts it: a + * bindSheet with the compact placement's options over the conversation. The + * sheet is the production `RemoteSurfaceHost` in its settings mode, so the + * surface under test is the production component. + */ + @Builder + private ViewSettingsLayout() { + Column() { + this.Conversation() + } + .width('100%') + .height('100%') + .bindSheet($$this.showViewSettingsSheet, this.ViewSettingsSheet(), AdaptiveSheetOptions.fromPlacement( + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.RemoteViewSettings), true)) + } + + @Builder + private ViewSettingsSheet() { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Settings, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.previewActions, + onCloseSettings: () => { this.showViewSettingsSheet = false; } + }) + } + + /** + * The drawer, the settings sheet, the connect sheet and the compact account + * cover are all shell surfaces rather than pages, so they are mounted through + * the production shell with the production content for their slot. Each of + * them has its own bottom edge, and the shell is what moves them with the + * window. + */ + @Builder + private ShellLayout() { + AppShell({ + shellState: this.shellState, + useWideLayout: this.wideShell, + allowSidebar: true, + compactAccountLogin: this.cover, + content: () => { this.Conversation(); }, + sidebar: () => { this.Sidebar(); }, + settings: () => { this.Settings(); }, + connect: () => { this.Connect(); }, + onCloseSidebar: () => { this.shellState.showSidebar = false; } + }) + } + + @Builder + private Settings() { + AppSettingsSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + actions: this.previewActions + }) + } + + @Builder + private Connect() { + AppConnectSurface({ + remotePageState: this.remotePageState, + actions: this.previewActions + }) + } + + @Builder + private Sidebar() { + AppSidebarSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + deviceDirectoryState: this.directoryState, + actions: emptyAppRootPresentationActions() + }) + } + + @Builder + private Conversation() { + ConversationView({ + state: this.state, + options: this.options(), + onIntent: (_intent: ConversationIntent): void => {} + }) + } +} 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 2047ff7c21..c9faa592dd 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 @@ -4,6 +4,11 @@ import { ComposerSubmissionPreview } from './ComposerSubmissionPreview'; import { DurableTimelinePreview } from './DurableTimelinePreview'; import { DeviceSelectorPreview } from './DeviceSelectorPreview'; import { CatalogRefreshPreview } from './CatalogRefreshPreview'; +import { ModelRoleSelectorPreview } from './ModelRoleSelectorPreview'; +import { SubagentProcessPreview } from './SubagentProcessPreview'; +import { FileVisibilityPreview } from './FileVisibilityPreview'; +import { ComposerOverflowPreview } from './ComposerOverflowPreview'; +import { ImmersiveBottomPreview } from './ImmersiveBottomPreview'; import { WelcomeHome } from '../components/WelcomeHome'; import { InteractionMailboxPreview } from './InteractionMailboxPreview'; import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; @@ -15,6 +20,7 @@ import { import { ComposerBar, ComposerPresentation } from '../components/ComposerBar'; import { ConversationHeader } from '../components/ConversationHeader'; import { INK, LINE, MUTED, PAGE_BG, SOFT, TRANSPARENT } from '../components/Theme'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; @Entry @ComponentV2 @@ -48,6 +54,16 @@ struct MobileDesignGallery { ComposerSubmissionPreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('catalog-refresh')) { CatalogRefreshPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('model-role-selector')) { + ModelRoleSelectorPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('subagent-process')) { + SubagentProcessPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('file-visibility')) { + FileVisibilityPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('composer-overflow')) { + ComposerOverflowPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('immersive-bottom')) { + ImmersiveBottomPreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('device-selector')) { DeviceSelectorPreview() } else if (AppStorage.get('scenarioId') === 'welcome-home') { @@ -98,11 +114,11 @@ struct MobileDesignGallery { @Builder private PlatformLabel() { Row({ space: 8 }) { - Text('HarmonyOS') + Text(RemoteI18n.t('app.title')) .fontSize(MobileDesignTypography.labelMedium.size) .fontWeight(FontWeight.Medium) .fontColor(INK) - Text('BASELINE') + Text(RemoteI18n.t('app.title')) .fontSize(MobileDesignTypography.labelSmall.size) .fontColor(MUTED) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ModelRoleSelectorPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ModelRoleSelectorPreview.ets new file mode 100644 index 0000000000..9f630470a1 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ModelRoleSelectorPreview.ets @@ -0,0 +1,98 @@ +import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { ConversationUiModelCatalog, toConversationUiModelCatalog } from '../state/ConversationUiModels'; +import { ComposerBar, ComposerPresentation } from '../components/ComposerBar'; +import { INK, PAGE_BG } from '../components/Theme'; + +/** + * Drives the production composer with a model catalog fixture so the + * primary/fast role rows, their resolved metadata, and the concrete-model role + * badges can be inspected on a device. + * + * No account, host, or remote request is involved: the catalog is projected + * through the same `toConversationUiModelCatalog` path the app uses, the + * selection starts from the session model id the same way hydration does, and + * the select handler records the payload it received instead of talking to a + * host. The recorded payload is surfaced on screen so a screenshot can prove it. + */ +@ComponentV2 +export struct ModelRoleSelectorPreview { + @Local catalog: ConversationUiModelCatalog = toConversationUiModelCatalog(this.fixtureCatalog()); + @Local selectedModelId: string = RemoteUiState.selectedModelIdForCatalog(this.fixtureCatalog(), ''); + @Local received: string = ''; + @Local wide: boolean = false; + // The model control lives in the expanded action row, and the composer only + // expands for focus, an open selector, or a multiline draft. A two-line draft + // reaches that state without depending on the emulator's IME. + @Local draft: string = 'Model role selector probe\nor the fallback row'; + + aboutToAppear(): void { + // The launcher decides the posture through the scenario id, so both + // presentations can be captured without an extra tap. Resize still toggles + // the presentation live. + this.wide = (AppStorage.get('scenarioId') ?? '').endsWith('-wide'); + } + + private fixtureModel(id: string, name: string, provider: string, modelName: string, + contextWindow: number, enabled: boolean): RemoteModelConfig { + return { + id: id, + name: name, + provider: provider, + base_url: '', + model_name: modelName, + context_window: contextWindow, + enabled: enabled, + capabilities: [] + }; + } + + /** Four enabled models plus one disabled one, with distinct primary and fast targets. */ + private fixtureCatalog(): RemoteModelCatalog { + return { + version: 1, + models: [ + this.fixtureModel('anthropic/claude-sonnet-4.5', 'Claude Sonnet 4.5', 'anthropic', + 'claude-sonnet-4-5', 200000, true), + this.fixtureModel('openbitfun:gpt-5-mini', 'GPT-5 mini', 'openai', + 'openbitfun:gpt-5-mini', 128000, true), + this.fixtureModel('google/gemini-2.5-flash', 'Gemini 2.5 Flash', 'google', + 'gemini-2.5-flash', 1000000, true), + this.fixtureModel('local:qwen3-coder', 'Qwen3 Coder', 'local', 'qwen3-coder', 32768, true), + this.fixtureModel('legacy/disabled-model', 'Legacy model', 'legacy', 'legacy-model', 8192, false) + ], + default_models: { + primary: 'anthropic/claude-sonnet-4.5', + fast: 'openbitfun:gpt-5-mini', + search: '', + image_understanding: '' + }, + session_model_id: 'fast' + }; + } + + build() { + Column({ space: 8 }) { + Row({ space: 8 }) { + Button('Resize').id('model-role-resize').onClick(() => { this.wide = !this.wide; }) + } + Text(`selected=${this.selectedModelId} received=${this.received}`) + .id('model-role-state').fontColor(INK) + Blank() + ComposerBar({ + presentation: this.wide ? ComposerPresentation.Floating : ComposerPresentation.Compact, + chatInput: this.draft, + modelCatalog: this.catalog, + selectedModelId: this.selectedModelId, + onChatInputChange: (value: string) => { this.draft = value; }, + onSelectModel: (modelId: string) => { + this.received = modelId; + this.selectedModelId = modelId; + } + }) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets new file mode 100644 index 0000000000..129cb1e4fc --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets @@ -0,0 +1,168 @@ +import { ChatMessage } from '../../model/RemoteModels'; +import { ChatTimelineRowStore, ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; +import { DurableSessionReducer, SessionRecord } from '../../services/DurableSessionReducer'; +import { ChatTimelineStore } from '../../services/ChatTimelineStore'; +import { HostSessionEvent } from '../../services/HostSessionStream'; +import { ChatMessageStructurePolicy, StructuredRenderGroup } from '../policy/ChatMessageStructurePolicy'; +import { + ConversationUiMessage, + ConversationUiMessageItem, + toConversationUiMessage +} from '../state/ConversationUiModels'; +import { ChatTimeline } from '../components/ChatTimeline'; +import { ComposerBar } from '../components/ComposerBar'; +import { INK, PAGE_BG } from '../components/Theme'; + +/** Typed fixtures for the tool payloads a Task branch is titled and read from. */ +interface PreviewTaskInput { + description: string; + subagent_type: string; +} + +interface PreviewReadInput { + path: string; +} + +/** + * A running Task whose subagent process is published flat behind it. + * + * The fixture is the host's own durable record shape: the owner Task tool item + * carries the child Session id, and every process record the child produces + * repeats it, so the branch owner and its children both arrive marked. The + * preview mounts the production reducer, timeline store and rows, so what it + * draws is what a live session draws. + */ +@ComponentV2 +export struct SubagentProcessPreview { + private reducer: DurableSessionReducer = new DurableSessionReducer(); + private timeline: ChatTimelineStore = new ChatTimelineStore(); + private rowStore: ChatTimelineRowStore = new ChatTimelineRowStore(); + private taskCallId: string = 'task_call_preview'; + private childSessionId: string = 'child-session-preview'; + @Local rows: ObservableChatTimelineItem[] = []; + @Local revision: number = 0; + @Local step: number = 0; + @Local summary: string = ''; + @Local connectionState: string = 'connected'; + @Local active: boolean = true; + @Local stopRequests: number = 0; + + aboutToAppear(): void { + this.timeline.reset('preview'); + this.reducer.apply(this.taskRecord(1, 'running', 'inprogress')); + this.publish(); + } + + private taskRecord(revision: number, status: string, turnStatus: string): HostSessionEvent { + const input: PreviewTaskInput = { description: 'Audit the payment module', subagent_type: 'Explore' }; + const payload: SessionRecord = { + sessionId: 'preview', id: `item/${this.taskCallId}`, revision, + turn: { turnId: 'turn', turnIndex: 0, sessionId: 'preview', timestamp: 1, + userMessage: { id: 'user', content: 'Audit the payment module', timestamp: 1 }, status: turnStatus }, + round: { id: 'round', turnId: 'turn', roundIndex: 0, timestamp: 2, status: turnStatus }, + item: { type: 'tool', data: { id: this.taskCallId, orderIndex: 0, timestamp: 3, + toolName: 'Task', startTime: 3, status, + subagentSessionId: this.childSessionId, + toolCall: { id: this.taskCallId, input } } } + }; + return { session_id: 'preview', event: 'session-record', payload }; + } + + private childRecord(id: string, type: string, order: number, revision: number, + content: string, toolName: string, status: string): HostSessionEvent { + const input: PreviewReadInput = { path: 'src/payments.ts' }; + const payload: SessionRecord = { + sessionId: 'preview', id: `item/${id}`, revision, + turn: { turnId: 'turn', turnIndex: 0, sessionId: 'preview', timestamp: 1, + userMessage: { id: 'user', content: 'Audit the payment module', timestamp: 1 }, status: 'inprogress' }, + round: { id: 'round', turnId: 'turn', roundIndex: 0, timestamp: 2, status: 'inprogress' }, + item: { type, data: { id, orderIndex: order, timestamp: 3, content, + subagentSessionId: this.childSessionId, + toolName, toolCall: { id, input }, status } } + }; + return { session_id: 'preview', event: 'session-record', payload }; + } + + private publish(): void { + const messages = this.reducer.messages(); + const active = messages.find((message: ChatMessage) => message.role === 'assistant' && message.status === 'active'); + this.timeline.applySnapshot({ sessionId: 'preview', cursor: { + pollVersion: 0, knownMessageCount: messages.length, knownModelCatalogVersion: 0 + }, changed: true, title: '', sessionState: active ? 'active' : 'idle', newMessages: [], + messageSnapshot: messages.filter((message: ChatMessage) => message !== active), activeTurn: active, + shouldSyncAfterTurnEnded: false, historyRewritten: false, completedTurnId: '' }); + this.rows = this.rowStore.reconcile(this.timeline.project(false)); + this.revision++; + const state = this.timeline.snapshot(); + this.active = state.activeTurn !== undefined; + this.summary = this.summarize(state.activeTurn || state.persistedMessages + .filter((message: ChatMessage) => message.role === 'assistant') + .pop()); + } + + /** + * The branch the bubble would draw, read through the production grouping + * policy so the line states what is on screen rather than what was published. + */ + private summarize(answer?: ChatMessage): string { + if (!answer) { + return 'step=0 children=0 phase=none'; + } + const message: ConversationUiMessage = toConversationUiMessage(answer); + const groups: StructuredRenderGroup[] = ChatMessageStructurePolicy.structuredGroups( + message.items || [], 'item', (message.status || '') === 'active', message.status || 'done'); + const card = groups + .filter((group: StructuredRenderGroup) => group.type === 'item' && group.items.length === 1) + .map((group: StructuredRenderGroup) => group.items[0]) + .find((entry: ConversationUiMessageItem) => !!entry.tool && entry.tool.name === 'Task'); + const children = card && card.subItems ? card.subItems.length : 0; + return `step=${this.step} children=${children} phase=${message.status || 'done'}`; + } + + private next(): void { + this.step++; + if (this.step === 1) { + this.reducer.apply(this.childRecord('child-think', 'thinking', 1, 2, + 'Reading the payment module', 'Read', 'running')); + } + if (this.step === 2) { + this.reducer.apply(this.childRecord('child-read', 'tool', 2, 3, + '', 'Read', 'completed')); + } + if (this.step === 3) { + this.reducer.apply(this.childRecord('child-text', 'text', 3, 4, + 'Found no hard-coded keys', 'Read', 'completed')); + } + if (this.step === 4) { + this.reducer.apply(this.childRecord('child-grep', 'tool', 4, 5, + '', 'Grep', 'running')); + } + if (this.step === 5) { + this.reducer.apply(this.childRecord('child-grep', 'tool', 4, 6, + '', 'Grep', 'completed')); + } + if (this.step === 6) { + this.reducer.apply(this.taskRecord(7, 'completed', 'completed')); + } + this.publish(); + } + + build() { + Column({ space: 8 }) { + Button('Next step').id('subagent-next').onClick(() => { this.next(); }) + Text(this.summary).id('subagent-summary').fontColor(INK) + Row({ space: 8 }) { + Button('Host offline').id('subagent-offline').onClick(() => { this.connectionState = 'reconnecting'; }) + Button('Host returned').id('subagent-returned').onClick(() => { + this.connectionState = 'connected'; + this.publish(); + }) + } + Text(`${this.connectionState} stops=${this.stopRequests}`).id('subagent-host-state').fontColor(INK) + ChatTimeline({ timelineItems: this.rows, timelineRevision: this.revision, + connectionState: this.connectionState, isBusy: this.active }).layoutWeight(1) + ComposerBar({ canStop: this.active, connectionState: this.connectionState, + onStop: () => { this.stopRequests++; } }) + }.width('100%').height('100%').backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets index 5d4a6c4926..6269eb250f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatComposerPolicy.ets @@ -36,12 +36,16 @@ export class ChatComposerPolicy { } /** - * Whether voice needs its secondary slot inside the input field. + * Whether the draft earns the supplemental voice slot beside the primary one. * - * The primary slot belongs to Send as soon as the draft carries anything, - * but dictation is still an input method at that point. Keeping the inline - * affordance mounted while busy avoids layout movement; the component dims - * it until recording can start again. + * The primary slot belongs to Send as soon as the draft carries anything, but + * dictation is still an input method at that point, so a draft keeps a second + * way into it. That slot is asked for by the expanded action row alone: the + * collapsed field is one line tall and the draft being composed has the + * stronger claim on that room, so `ComposerBar` does not mount it inside the + * field in any state, and a collapsed draft shows the primary action only. + * Keeping the expanded affordance mounted while busy avoids layout movement; + * the component dims it until recording can start again. */ static shouldShowSupplementalVoice( text: string, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteUiState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteUiState.ets index e7649b91e8..08965cabd0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteUiState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteUiState.ets @@ -189,6 +189,13 @@ export class RemoteUiState { return sessionModelId; } if (currentSelectedModelId.length > 0) { + // A semantic selector stays semantic while the host can still resolve it, + // so the composer keeps showing the role that will actually run. The + // canonical value is returned, not the padded one that came in. + const semanticModelId = RemoteUiState.resolveSemanticModelId(catalog, currentSelectedModelId); + if (semanticModelId.length > 0) { + return semanticModelId; + } const exists = catalog.models.some((model: RemoteModelConfig) => { return model.id === currentSelectedModelId && model.enabled; }); @@ -199,6 +206,39 @@ export class RemoteUiState { return catalog.default_models.primary || catalog.default_models.fast || currentSelectedModelId; } + /** + * Keeps a semantic selector (`primary` / `fast`) only while it resolves to an + * enabled model, mirroring the host's own selection semantics including the + * fast-to-primary fallback. Returns an empty string for anything else. + * + * This repeats `ConversationModelPresentationPolicy.resolveModel` because the + * service layer may not import the page policy layer. Both sides trim, so a + * padded selector or default resolves the same way in either one. + */ + private static resolveSemanticModelId(catalog: RemoteModelCatalog, modelId: string): string { + const value = (modelId || '').trim(); + const primary = (catalog.default_models.primary || '').trim(); + if (value === 'primary') { + return RemoteUiState.hasEnabledModel(catalog, primary) ? value : ''; + } + if (value === 'fast') { + const fast = (catalog.default_models.fast || '').trim(); + if (RemoteUiState.hasEnabledModel(catalog, fast)) { + return value; + } + return RemoteUiState.hasEnabledModel(catalog, primary) ? value : ''; + } + return ''; + } + + private static hasEnabledModel(catalog: RemoteModelCatalog, modelId: string): boolean { + const value = (modelId || '').trim(); + if (value.length === 0) { + return false; + } + return catalog.models.some((model: RemoteModelConfig) => model.id === value && model.enabled); + } + static seedMessage(title: string): ChatMessage { return { id: `system-${Date.now()}`, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets index 53dc37dca1..ac9c0c684c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets @@ -1,8 +1,35 @@ import { Context } from '@kit.AbilityKit'; -import { window } from '@kit.ArkUI'; +import { UIContext, window } from '@kit.ArkUI'; +import { RemoteLogger } from './RemoteLogger'; + +/** + * How much of the window the app has to keep clear of its own content, in px. + * + * Both numbers are read rather than assumed. The shell runs in immersive layout, + * so the page draws from the top of the window and the status bar is the app's + * own inset to reserve; the bottom strip is the navigation indicator, until the + * soft keyboard takes its place. + */ +export interface WindowInsets { + top: number; + bottom: number; +} + +/** One read of the insets, plus the listener that keeps them current. */ +export type WindowInsetListener = (insets: WindowInsets) => void; +export type WindowInsetSubscription = () => void; /** Platform window adapter for surfaces that need to draw behind system bars. */ export class WindowSystemBarService { + /** + * Immersive layout for the whole shell, not one page: the transcript has to be + * able to reach the bottom edge of the screen, which only exists in the page + * area once the window stops reserving the navigation-bar strip. + * + * Immersive layout also moves the page's own origin to the top of the window, + * so every page that draws chrome against an edge reads the insets below + * instead of assuming the framework avoided the bars for it. + */ static async setLayoutFullScreen(context: Context, enabled: boolean): Promise { try { const appWindow = await window.getLastWindow(context); @@ -20,4 +47,117 @@ export class WindowSystemBarService { return 0; } } + + /** + * Reads the insets once and re-reads them on every avoid-area change — folding, + * rotation and the soft keyboard all move the avoid areas without recreating + * the page, and a stale number either clips the composer or leaves the + * transcript reserving space nothing is drawn in. + * + * The returned function unregisters the listener. + */ + static async observeInsets(context: Context, onChange: WindowInsetListener): Promise { + try { + const appWindow = await window.getLastWindow(context); + const read: () => void = (): void => { onChange(WindowSystemBarService.insetsOf(appWindow)); }; + const onAvoidAreaChange: (data: window.AvoidAreaOptions) => void = (_data: window.AvoidAreaOptions): void => { + read(); + }; + const subscription: WindowInsetSubscription = (): void => { + appWindow.off('avoidAreaChange', onAvoidAreaChange); + }; + appWindow.on('avoidAreaChange', onAvoidAreaChange); + read(); + return subscription; + } catch (_err) { + onChange({ top: 0, bottom: 0 }); + return (): void => {}; + } + } + + private static insetsOf(appWindow: window.Window): WindowInsets { + const top = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height; + // The keyboard covers the navigation indicator while it is up, and + // `KeyboardAvoidMode.RESIZE` has already lifted the page above it, so the + // bottom strip belongs to the keyboard for as long as it is visible. + const keyboard = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_KEYBOARD).bottomRect.height; + if (keyboard > 0) { + return { top, bottom: 0 }; + } + const navigation = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR).bottomRect.height; + if (navigation > 0) { + return { top, bottom: navigation }; + } + // Some builds fold the navigation bar into the system avoid area instead of + // reporting an indicator of its own. + return { top, bottom: appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).bottomRect.height }; + } +} + +/** + * One component's live view of the window insets, in vp. + * + * The shell is immersive, so every surface that puts chrome against a screen + * edge reads these numbers instead of assuming the framework reserved the strip + * for it: the chat page's fade fills the bottom edge, and the interactive rows + * of every other page — drawers, sheets, covers, galleries — keep clear of it. + * They mount and unmount independently, so the binding owns the subscription + * rather than leaving each page to juggle one, and it holds vp because padding, + * margins and radii in this app are vp. + */ +@ObservedV2 +export class WindowInsetsBinding { + @Trace top: number = 0; + @Trace bottom: number = 0; + private subscription?: WindowInsetSubscription; + private disposed: boolean = false; + + bind(uiContext: UIContext, context: Context): void { + this.disposed = false; + void WindowSystemBarService.observeInsets(context, (insets: WindowInsets): void => { + if (this.disposed) { + return; + } + this.top = uiContext.px2vp(insets.top); + this.bottom = uiContext.px2vp(insets.bottom); + RemoteLogger.info(`window insets top=${this.top} bottom=${this.bottom}`); + }).then((subscription: WindowInsetSubscription): void => { + // The page can disappear before the window call resolves; releasing here + // keeps a listener from outliving the component that asked for it. + if (this.disposed) { + subscription(); + return; + } + this.subscription = subscription; + }); + } + + unbind(): void { + this.disposed = true; + if (this.subscription) { + this.subscription(); + this.subscription = undefined; + } + } + + /** + * The padding a bottom-anchored content layer needs: the design's own spacing + * where it already clears the strip, and the strip itself where it does not. + * The strip is a device fact, so a page never hard-codes it. + */ + bottomPadding(designSpacing: number): number { + return Math.max(designSpacing, this.bottom); + } + + /** + * The tail spacer a scrolling surface appends after its last row: the strip + * itself plus the design's own breathing. The viewport then runs to the + * screen edge — rows roll under the navigation indicator while they are + * still moving — while the last row still comes to rest above the bar. + * Padding on the scrolling container cannot do that: it shrinks the viewport + * and leaves a band of bare page colour under the bar instead. + */ + tailSpacing(designSpacing: number): number { + return this.bottom + designSpacing; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json index 2de8f09c6b..3e30be5306 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json @@ -19,6 +19,2702 @@ { "name": "permission_distributed_datasync_reason", "value": "Used to authorize a watch on the same GitHub account using a separate device credential." + }, + { + "name": "workspaceTools_browse", + "value": "Browse runtime folders" + }, + { + "name": "workspaceTools_selectFolder", + "value": "Use this folder" + }, + { + "name": "workspaceTools_back", + "value": "Back to files" + }, + { + "name": "workspaceTools_edit", + "value": "Edit" + }, + { + "name": "workspaceTools_lineNumbers", + "value": "Line numbers" + }, + { + "name": "workspaceTools_unsaved", + "value": "Unsaved changes" + }, + { + "name": "workspaceTools_fileConflict", + "value": "File changed on the computer. Your draft is preserved. Copy it before reopening the file to load the latest version." + }, + { + "name": "workspaceTools_discard", + "value": "Discard changes" + }, + { + "name": "workspaceTools_nameAsc", + "value": "Name A–Z" + }, + { + "name": "workspaceTools_nameDesc", + "value": "Name Z–A" + }, + { + "name": "workspaceTools_modifiedAsc", + "value": "Oldest modified first" + }, + { + "name": "workspaceTools_modifiedDesc", + "value": "Newest modified first" + }, + { + "name": "workspaceTools_upload", + "value": "Upload file" + }, + { + "name": "workspaceTools_resumeUpload", + "value": "Resume upload" + }, + { + "name": "workspaceTools_cancelUpload", + "value": "Cancel upload" + }, + { + "name": "workspaceTools_selectLocation", + "value": "Select a location" + }, + { + "name": "workspaceTools_runtimeLocal", + "value": "This runtime" + }, + { + "name": "workspaceTools_download", + "value": "Download" + }, + { + "name": "workspaceTools_title", + "value": "Tools" + }, + { + "name": "workspaceTools_files", + "value": "Files" + }, + { + "name": "sidebar_controlledDevice", + "value": "Controlled device" + }, + { + "name": "sidebar_currentPath", + "value": "Current path" + }, + { + "name": "sidebar_recentWorkspaces", + "value": "Recent workspaces" + }, + { + "name": "sidebar_tapToSwitch", + "value": "Tap to switch" + }, + { + "name": "workspaceTools_selectTerminal", + "value": "Select an existing terminal" + }, + { + "name": "workspaceTools_terminal", + "value": "Terminal" + }, + { + "name": "workspaceTools_up", + "value": "Parent" + }, + { + "name": "workspaceTools_refresh", + "value": "Refresh" + }, + { + "name": "workspaceTools_more", + "value": "Load more" + }, + { + "name": "workspaceTools_fileName", + "value": "File name" + }, + { + "name": "workspaceTools_path", + "value": "Absolute path on controlled runtime" + }, + { + "name": "workspaceTools_newFile", + "value": "New file" + }, + { + "name": "workspaceTools_mkdir", + "value": "New folder" + }, + { + "name": "workspaceTools_rename", + "value": "Rename" + }, + { + "name": "workspaceTools_save", + "value": "Save" + }, + { + "name": "workspaceTools_delete", + "value": "Delete file" + }, + { + "name": "workspaceTools_confirmDelete", + "value": "Confirm delete" + }, + { + "name": "workspaceTools_rmdir", + "value": "Remove empty folder" + }, + { + "name": "workspaceTools_createTerminal", + "value": "New terminal" + }, + { + "name": "workspaceTools_command", + "value": "Terminal input" + }, + { + "name": "workspaceTools_send", + "value": "Send" + }, + { + "name": "workspaceTools_closeTerminal", + "value": "Close terminal" + }, + { + "name": "workspaceTools_resize", + "value": "Resize" + }, + { + "name": "home_recent_title", + "value": "What would you like to do today?" + }, + { + "name": "home_recent_recent", + "value": "Recent conversations" + }, + { + "name": "home_recent_browse", + "value": "Browse workspaces" + }, + { + "name": "home_recent_all", + "value": "All conversations" + }, + { + "name": "home_recent_empty", + "value": "No conversations to continue yet" + }, + { + "name": "home_recent_connect", + "value": "Connect a computer to continue your conversations." + }, + { + "name": "account_errorNetwork", + "value": "Unable to connect. Check your connection and try again." + }, + { + "name": "account_errorTimeout", + "value": "Connection timed out. Check your connection and try again." + }, + { + "name": "account_errorSecureConnection", + "value": "Unable to establish a secure connection. Check your network and try again." + }, + { + "name": "account_errorRateLimited", + "value": "Too many attempts. Try again later." + }, + { + "name": "home_welcome_login", + "value": "Sign in" + }, + { + "name": "home_welcome_work", + "value": "Get things done" + }, + { + "name": "home_welcome_play", + "value": "Try something fun" + }, + { + "name": "home_welcome_yours", + "value": "Make it yours" + }, + { + "name": "home_connectComputer", + "value": "Connect computer" + }, + { + "name": "home_connecting", + "value": "Connecting…" + }, + { + "name": "home_exploreMiniApps", + "value": "Explore MiniApps" + }, + { + "name": "home_miniAppsHint", + "value": "Ready to use, even without a computer" + }, + { + "name": "miniapp_allApps", + "value": "All apps" + }, + { + "name": "miniapp_availableOffline", + "value": "Available offline" + }, + { + "name": "miniapp_timeout", + "value": "The local operation timed out. It may still finish; it was not retried." + }, + { + "name": "miniapp_title", + "value": "MiniApps" + }, + { + "name": "miniapp_refresh", + "value": "Refresh" + }, + { + "name": "miniapp_empty", + "value": "No built-in MiniApps are available." + }, + { + "name": "miniapp_unsupported", + "value": "This capability is unavailable in built-in mobile MiniApps." + }, + { + "name": "miniapp_invalidPage", + "value": "The MiniApp page is incomplete. Reopen it." + }, + { + "name": "miniapp_closed", + "value": "The MiniApp was closed or changed." + }, + { + "name": "miniapp_previewHint", + "value": "Built in · Works offline. Data stays on this phone." + }, + { + "name": "notification_onboardingTitle", + "value": "Task completion notifications" + }, + { + "name": "notification_onboardingBody", + "value": "Allow OpenBitFun to notify you when tasks finish. You can change this later in system settings." + }, + { + "name": "notification_onboardingLater", + "value": "Later" + }, + { + "name": "notification_onboardingEnable", + "value": "Enable notifications" + }, + { + "name": "app_title", + "value": "OpenBitFun" + }, + { + "name": "common_cancel", + "value": "Cancel" + }, + { + "name": "common_back", + "value": "Back" + }, + { + "name": "common_close", + "value": "Close" + }, + { + "name": "common_copy", + "value": "Copy" + }, + { + "name": "common_current", + "value": "Current" + }, + { + "name": "common_done", + "value": "Done" + }, + { + "name": "common_delete", + "value": "Delete" + }, + { + "name": "common_failed", + "value": "Failed" + }, + { + "name": "common_keep", + "value": "Keep" + }, + { + "name": "common_loading", + "value": "Loading..." + }, + { + "name": "common_open", + "value": "Open" + }, + { + "name": "common_other", + "value": "Other" + }, + { + "name": "common_ready", + "value": "Ready" + }, + { + "name": "common_refresh", + "value": "Refresh" + }, + { + "name": "common_retry", + "value": "Retry" + }, + { + "name": "common_save", + "value": "Save" + }, + { + "name": "common_search", + "value": "Search" + }, + { + "name": "common_running", + "value": "Running" + }, + { + "name": "common_waiting", + "value": "Waiting" + }, + { + "name": "notification_taskCompletedTitle", + "value": "Task completed" + }, + { + "name": "notification_taskCompletedBody", + "value": "Your OpenBitFun task is ready. Tap to open OpenBitFun and view the result." + }, + { + "name": "chatHome_prompt", + "value": "What do you want to do today?" + }, + { + "name": "chatHome_placeholder", + "value": "Ask OpenBitFun" + }, + { + "name": "chatHome_localHint", + "value": "This chat stays on the phone. Switch to Remote to read and write projects on your computer." + }, + { + "name": "chatHome_research", + "value": "Find sources" + }, + { + "name": "chatHome_researchPrompt", + "value": "Help me find sources and summarize the key points:" + }, + { + "name": "chatHome_image", + "value": "Analyze image" + }, + { + "name": "chatHome_imagePrompt", + "value": "Please analyze this image." + }, + { + "name": "chatHome_file", + "value": "Work with a file" + }, + { + "name": "chatHome_filePrompt", + "value": "Help me summarize the key points in this file." + }, + { + "name": "chatHome_write", + "value": "Help me write" + }, + { + "name": "chatHome_writePrompt", + "value": "Please draft this for me:" + }, + { + "name": "chatHome_organize", + "value": "Organize a problem" + }, + { + "name": "chatHome_organizePrompt", + "value": "Help me organize the key points and approach for this problem:" + }, + { + "name": "chatHome_plan", + "value": "Make an action plan" + }, + { + "name": "chatHome_planPrompt", + "value": "Help me make a clear, actionable plan:" + }, + { + "name": "chatHome_codeHintTitle", + "value": "This task may need Remote" + }, + { + "name": "chatHome_codeHintBody", + "value": "Connect a desktop workspace before OpenBitFun can read project files, run commands, and change code." + }, + { + "name": "chatHome_enterCode", + "value": "Open Remote" + }, + { + "name": "generalChat_ready", + "value": "OpenBitFun is ready" + }, + { + "name": "generalChat_generating", + "value": "OpenBitFun is answering" + }, + { + "name": "generalChat_sending", + "value": "Sending message" + }, + { + "name": "generalChat_offline", + "value": "Network unavailable. Drafts and history stay on this phone." + }, + { + "name": "generalChat_stopped", + "value": "Generation stopped." + }, + { + "name": "generalChat_interrupted", + "value": "The reply was interrupted. Received content was kept." + }, + { + "name": "generalChat_replyInterrupted", + "value": "Reply interrupted" + }, + { + "name": "generalChat_fileDownloadMock", + "value": "General chat cannot download desktop files. Open Remote to work with local files." + }, + { + "name": "generalChat_filePreviewUnavailable", + "value": "General chat cannot preview desktop files. Open Remote to view them." + }, + { + "name": "generalChat_localRestoreFailed", + "value": "General chat history could not be restored. You can still start a new chat." + }, + { + "name": "generalChat_modelNotConfigured", + "value": "Configure a general chat model in Settings first." + }, + { + "name": "generalChat_authenticationFailed", + "value": "The API key is invalid or cannot access this model." + }, + { + "name": "generalChat_rateLimited", + "value": "The model service is rate-limited. Try again later." + }, + { + "name": "generalChat_serviceUnavailable", + "value": "The model service is temporarily unavailable. Try again later." + }, + { + "name": "generalChat_invalidResponse", + "value": "The model service returned an unrecognized response." + }, + { + "name": "generalChat_emptyResponse", + "value": "The model returned no text." + }, + { + "name": "generalChat_requestFailed", + "value": "Model request failed (HTTP %1$s)." + }, + { + "name": "settings_general_section", + "value": "General" + }, + { + "name": "settings_account_section", + "value": "Account" + }, + { + "name": "settings_account_currentIdentity", + "value": "Current identity" + }, + { + "name": "settings_model_section", + "value": "Model" + }, + { + "name": "settings_model_default", + "value": "Default model" + }, + { + "name": "settings_devices_section", + "value": "Devices" + }, + { + "name": "settings_title", + "value": "Settings" + }, + { + "name": "settings_language_title", + "value": "Language" + }, + { + "name": "settings_language_choose", + "value": "Choose language" + }, + { + "name": "settings_language_zhCN", + "value": "简体中文" + }, + { + "name": "settings_language_enUS", + "value": "English" + }, + { + "name": "settings_about_section", + "value": "About" + }, + { + "name": "settings_about_openbitfun", + "value": "About OpenBitFun" + }, + { + "name": "settings_about_product", + "value": "Product" + }, + { + "name": "settings_about_version", + "value": "Version" + }, + { + "name": "settings_modelService_title", + "value": "Model" + }, + { + "name": "settings_modelService_manageTitle", + "value": "General chat model" + }, + { + "name": "settings_modelService_localTitle", + "value": "Custom local model" + }, + { + "name": "settings_modelService_currentModel", + "value": "In use" + }, + { + "name": "settings_modelService_modelSources", + "value": "Model sources" + }, + { + "name": "settings_modelService_chooseAccountModel", + "value": "Choose account model" + }, + { + "name": "settings_modelService_accountModels", + "value": "Account sync" + }, + { + "name": "settings_modelService_accountModelSummary", + "value": "Cloud account models" + }, + { + "name": "settings_modelService_localModel", + "value": "Custom local" + }, + { + "name": "settings_modelService_accountSource", + "value": "Cloud account" + }, + { + "name": "settings_modelService_localSource", + "value": "This device" + }, + { + "name": "settings_modelService_syncedCount", + "value": "%1$s synced" + }, + { + "name": "settings_modelService_accountEmpty", + "value": "No account models available" + }, + { + "name": "settings_modelService_configured", + "value": "Configured" + }, + { + "name": "settings_modelService_notConfigured", + "value": "Not configured" + }, + { + "name": "settings_modelService_apiUrl", + "value": "API URL" + }, + { + "name": "settings_modelService_apiUrlPlaceholder", + "value": "https://api.example.com" + }, + { + "name": "settings_modelService_apiKey", + "value": "API Key" + }, + { + "name": "settings_modelService_apiKeyPlaceholder", + "value": "Enter API Key" + }, + { + "name": "settings_modelService_apiKeyKeepPlaceholder", + "value": "Leave blank to keep the saved key" + }, + { + "name": "settings_modelService_modelName", + "value": "Model name" + }, + { + "name": "settings_modelService_modelPlaceholder", + "value": "For example chat-model" + }, + { + "name": "settings_modelService_keySaved", + "value": "Saved securely" + }, + { + "name": "settings_modelService_clearKey", + "value": "Clear saved key" + }, + { + "name": "settings_modelService_keepKey", + "value": "Keep saved key" + }, + { + "name": "settings_modelService_testConnection", + "value": "Test connection" + }, + { + "name": "settings_modelService_testNeedsKey", + "value": "Keep or enter an API key to test the connection." + }, + { + "name": "settings_modelService_testSuccess", + "value": "Connection test passed." + }, + { + "name": "settings_modelService_invalidUrl", + "value": "API URL must start with http:// or https://." + }, + { + "name": "settings_modelService_modelRequired", + "value": "Enter a model name." + }, + { + "name": "settings_modelService_apiKeyRequired", + "value": "Enter an API key." + }, + { + "name": "settings_modelService_encryptFailed", + "value": "Could not encrypt the API key." + }, + { + "name": "settings_modelService_secureStoreFailed", + "value": "System secure storage is unavailable. Try again later." + }, + { + "name": "sidebar_chat", + "value": "Library" + }, + { + "name": "sidebar_projects", + "value": "Projects" + }, + { + "name": "sidebar_tasks", + "value": "Scheduled" + }, + { + "name": "sidebar_tools", + "value": "Apps" + }, + { + "name": "sidebar_more", + "value": "More" + }, + { + "name": "sidebar_newChat", + "value": "Chat" + }, + { + "name": "sidebar_generalChat", + "value": "General chat" + }, + { + "name": "sidebar_conversations", + "value": "Recent chats" + }, + { + "name": "sidebar_devices", + "value": "Devices" + }, + { + "name": "sidebar_legacyWorkspaceCatalog", + "value": "Older device: showing recent workspaces" + }, + { + "name": "sidebar_workspaces", + "value": "Workspaces" + }, + { + "name": "sidebar_deviceOnline", + "value": "Online" + }, + { + "name": "sidebar_addWorkspace", + "value": "Add workspace" + }, + { + "name": "sidebar_targetWorkspacePath", + "value": "Path on controlled device" + }, + { + "name": "sidebar_openWorkspacePath", + "value": "Open workspace" + }, + { + "name": "sidebar_remoteRootBrowseOnly", + "value": "The remote root is for browsing. Choose a specific workspace folder." + }, + { + "name": "sidebar_browseRemoteRoot", + "value": "Browse remote root" + }, + { + "name": "sidebar_selectWorkspace", + "value": "Select workspace" + }, + { + "name": "sidebar_workspacesOffline", + "value": "Disconnected" + }, + { + "name": "sidebar_connectDesktop", + "value": "Connect a computer" + }, + { + "name": "sidebar_addConnection", + "value": "Add connection" + }, + { + "name": "sidebar_newInWorkspace", + "value": "New in this workspace" + }, + { + "name": "sidebar_emptyWorkspaces", + "value": "This computer has no workspaces yet" + }, + { + "name": "sidebar_moreSessions", + "value": "%1$s more sessions" + }, + { + "name": "sidebar_moreWorkspaces", + "value": "%1$s more workspaces" + }, + { + "name": "sidebar_deviceOffline", + "value": "Offline" + }, + { + "name": "sidebar_deviceLoadFailed", + "value": "This computer cannot be read right now" + }, + { + "name": "sidebar_moreDevices", + "value": "%1$s more devices" + }, + { + "name": "sidebar_pinned", + "value": "Pinned" + }, + { + "name": "sidebar_archived", + "value": "Archived" + }, + { + "name": "sidebar_archive", + "value": "Archive" + }, + { + "name": "sidebar_unarchive", + "value": "Unarchive" + }, + { + "name": "sidebar_exportMarkdown", + "value": "Copy Markdown" + }, + { + "name": "sidebar_untitled", + "value": "Untitled session" + }, + { + "name": "sidebar_emptyRecentTitle", + "value": "No recent sessions" + }, + { + "name": "sidebar_emptyRecentText", + "value": "They will appear here after you start chatting." + }, + { + "name": "sidebar_deleteConfirm", + "value": "This session cannot be recovered after deletion." + }, + { + "name": "sidebar_searchPlaceholder", + "value": "Search chats" + }, + { + "name": "sidebar_noSearchResult", + "value": "No matching sessions" + }, + { + "name": "sidebar_signedOutNewChat", + "value": "New chat" + }, + { + "name": "sidebar_signInOpenBitFunAccount", + "value": "Sign in with email or GitHub" + }, + { + "name": "sidebar_collapse", + "value": "Collapse sidebar" + }, + { + "name": "sidebar_restore", + "value": "Expand sidebar" + }, + { + "name": "session_actions", + "value": "Session actions" + }, + { + "name": "session_details", + "value": "Session details" + }, + { + "name": "session_viewDetails", + "value": "View details" + }, + { + "name": "session_agentType", + "value": "Agent type" + }, + { + "name": "session_workspace", + "value": "Workspace" + }, + { + "name": "session_workspacePath", + "value": "Workspace path" + }, + { + "name": "session_createdAt", + "value": "Created" + }, + { + "name": "session_updatedAt", + "value": "Updated" + }, + { + "name": "session_messageCount", + "value": "Messages" + }, + { + "name": "session_status", + "value": "Status" + }, + { + "name": "viewSettings_title", + "value": "View settings" + }, + { + "name": "viewSettings_subtitle", + "value": "Adjust session grouping and information density" + }, + { + "name": "viewSettings_grouping", + "value": "Grouping" + }, + { + "name": "viewSettings_filters", + "value": "Filters" + }, + { + "name": "viewSettings_metadata", + "value": "Shown details" + }, + { + "name": "viewSettings_workspace", + "value": "Workspace" + }, + { + "name": "viewSettings_agentType", + "value": "Session type" + }, + { + "name": "viewSettings_allWorkspaces", + "value": "All workspaces" + }, + { + "name": "viewSettings_allAgentTypes", + "value": "All types" + }, + { + "name": "viewSettings_allStatuses", + "value": "All statuses" + }, + { + "name": "viewSettings_updated", + "value": "Updated time" + }, + { + "name": "viewSettings_status", + "value": "Run status" + }, + { + "name": "common_unknown", + "value": "Unknown" + }, + { + "name": "code_title", + "value": "OpenBitFun Remote" + }, + { + "name": "code_heroTitle", + "value": "Local development assistant" + }, + { + "name": "code_heroBody", + "value": "After connecting the desktop, OpenBitFun can access your repositories, run commands, and edit files." + }, + { + "name": "code_connectWorkspace", + "value": "Connect a local workspace" + }, + { + "name": "code_noWorkspacePreview", + "value": "Recently opened repositories appear here after you connect the desktop." + }, + { + "name": "code_newTask", + "value": "New remote task" + }, + { + "name": "code_switchWorkspace", + "value": "Switch workspace" + }, + { + "name": "code_disconnect", + "value": "Disconnect" + }, + { + "name": "code_recentSessions", + "value": "Recent remote sessions" + }, + { + "name": "code_emptySessionTitle", + "value": "No remote sessions" + }, + { + "name": "code_emptySessionText", + "value": "After connecting a local workspace, create a remote task to work on the project." + }, + { + "name": "remote_actionUnsupported", + "value": "This action is not supported by the connected device." + }, + { + "name": "remote_title", + "value": "Remote" + }, + { + "name": "remote_chats", + "value": "Chats" + }, + { + "name": "remote_searchChats", + "value": "Search chat history" + }, + { + "name": "remote_emptyTitle", + "value": "No remote chats yet" + }, + { + "name": "remote_emptyText", + "value": "Start a chat to continue desktop tasks from your phone." + }, + { + "name": "remote_pickSession", + "value": "Choose a session" + }, + { + "name": "remote_pickSessionText", + "value": "Open a session from the sidebar, or create one." + }, + { + "name": "remote_startSession", + "value": "New session" + }, + { + "name": "remote_harness_title", + "value": "Choose execution mode" + }, + { + "name": "remote_harness_minimal", + "value": "Minimal" + }, + { + "name": "remote_harness_standard", + "value": "Standard" + }, + { + "name": "remote_harness_ultimate", + "value": "Ultimate" + }, + { + "name": "remote_connectTitle", + "value": "Connect desktop" + }, + { + "name": "remote_actions", + "value": "Remote settings" + }, + { + "name": "remote_device", + "value": "Connected device" + }, + { + "name": "remote_newChat", + "value": "Chat" + }, + { + "name": "remote_workspace", + "value": "Workspace" + }, + { + "name": "remote_assistant", + "value": "Assistant" + }, + { + "name": "remote_noAssistant", + "value": "Default assistant" + }, + { + "name": "remote_loadMore", + "value": "Load more chats" + }, + { + "name": "remote_menu_organize", + "value": "Organize" + }, + { + "name": "remote_menu_byProject", + "value": "By project" + }, + { + "name": "remote_menu_byTime", + "value": "Newest first" + }, + { + "name": "remote_menu_chatFirst", + "value": "Chats first" + }, + { + "name": "remote_menu_manage", + "value": "Manage" + }, + { + "name": "remote_menu_cloudTasks", + "value": "Cloud tasks" + }, + { + "name": "remote_menu_addConnection", + "value": "Add connection" + }, + { + "name": "remote_menu_settings", + "value": "Settings" + }, + { + "name": "remote_menu_refreshConnection", + "value": "Refresh connection" + }, + { + "name": "remote_menu_comingSoon", + "value": "Coming soon" + }, + { + "name": "remote_projects_showMore", + "value": "Show %1$s more projects" + }, + { + "name": "remote_sessions_showMore", + "value": "Show %1$s more sessions" + }, + { + "name": "remote_time_today", + "value": "Today" + }, + { + "name": "remote_time_yesterday", + "value": "Yesterday" + }, + { + "name": "remote_time_earlier", + "value": "Earlier" + }, + { + "name": "remote_settings_relayReauthorizationRequired", + "value": "Relay has been upgraded. Sign in again to reconnect; your saved account and data are retained." + }, + { + "name": "remote_settings_currentControl", + "value": "Current remote control" + }, + { + "name": "remote_settings_accountDevice", + "value": "Account device" + }, + { + "name": "remote_settings_qrPairing", + "value": "QR pairing" + }, + { + "name": "remote_settings_disconnect", + "value": "Disconnect" + }, + { + "name": "remote_settings_disconnectTemporary", + "value": "End temporary access" + }, + { + "name": "remote_settings_reconnect", + "value": "Reconnect" + }, + { + "name": "remote_settings_noDesktop", + "value": "No desktop connected yet" + }, + { + "name": "remote_settings_openbitfunUser", + "value": "OpenBitFun user" + }, + { + "name": "remote_settings_accountSignedIn", + "value": "Signed in" + }, + { + "name": "remote_settings_accountNotSignedIn", + "value": "Signed out" + }, + { + "name": "remote_settings_accountTemporary", + "value": "Temporary access" + }, + { + "name": "remote_settings_accountLoginTitle", + "value": "Sign in with email or GitHub" + }, + { + "name": "remote_settings_accountLoginBody", + "value": "Sign in with email or GitHub to connect to your computers.\\nTasks and model settings stay on the controlled computer." + }, + { + "name": "remote_settings_loginServer", + "value": "Sign-in server" + }, + { + "name": "remote_settings_accountSignIn", + "value": "Sign in with email or GitHub" + }, + { + "name": "remote_settings_accountSigningIn", + "value": "Signing in…" + }, + { + "name": "remote_settings_accountLoginFailed", + "value": "Unable to sign in right now. Try again later." + }, + { + "name": "remote_settings_relayUrlPlaceholder", + "value": "Relay URL, for example https://relay.example.com" + }, + { + "name": "remote_settings_accountLogout", + "value": "Sign out" + }, + { + "name": "remote_settings_accountLoggingOut", + "value": "Signing out…" + }, + { + "name": "remote_settings_deviceCurrent", + "value": "This device" + }, + { + "name": "remote_settings_deviceRefresh", + "value": "Refresh" + }, + { + "name": "remote_settings_deviceLoading", + "value": "Loading devices…" + }, + { + "name": "remote_settings_deviceEmpty", + "value": "No other registered devices on this account." + }, + { + "name": "remote_settings_deviceLoginRequired", + "value": "Sign in to the cloud account to see every device on it." + }, + { + "name": "remote_settings_deviceOnline", + "value": "Online" + }, + { + "name": "remote_settings_deviceOffline", + "value": "Offline" + }, + { + "name": "remote_settings_deviceConnecting", + "value": "Connecting" + }, + { + "name": "remote_settings_deviceControlling", + "value": "Controlling" + }, + { + "name": "remote_settings_deviceSwitching", + "value": "Switching to %1$s…" + }, + { + "name": "remote_settings_deviceSwitchFailed", + "value": "Could not switch the control device. Try again later." + }, + { + "name": "remote_settings_deviceLoadFailed", + "value": "Could not load devices. Try again later." + }, + { + "name": "remote_settings_accountExpired", + "value": "The cloud account expired. Sign in again." + }, + { + "name": "remote_settings_deviceUnavailable", + "value": "The target desktop is unavailable. Refresh the device list and retry." + }, + { + "name": "remote_settings_deviceIncompatible", + "value": "Incompatible client — this device cannot be controlled here" + }, + { + "name": "remote_settings_deviceName", + "value": "Device" + }, + { + "name": "remote_settings_deviceManagementBody", + "value": "This device stores account credentials encrypted with HUKS. Signing out clears them immediately." + }, + { + "name": "remote_settings_githubId", + "value": "Account ID: %1$s" + }, + { + "name": "remote_settings_githubAccount", + "value": "OpenBitFun account" + }, + { + "name": "remote_settings_connectedDesktop", + "value": "Current desktop" + }, + { + "name": "remote_permissions_title", + "value": "Permission mode" + }, + { + "name": "remote_permissions_scope", + "value": "This setting syncs to the connected desktop and applies to every new tool call." + }, + { + "name": "remote_permissions_ask", + "value": "Ask" + }, + { + "name": "remote_permissions_askDescription", + "value": "High-risk actions wait for your confirmation." + }, + { + "name": "remote_permissions_auto", + "value": "Auto approve" + }, + { + "name": "remote_permissions_autoDescription", + "value": "Actions that would ask for confirmation are approved automatically." + }, + { + "name": "remote_permissions_fullAccess", + "value": "Full access" + }, + { + "name": "remote_permissions_fullAccessDescription", + "value": "Every tool action is allowed without asking again." + }, + { + "name": "remote_permissions_loading", + "value": "Reading the desktop’s permission setting…" + }, + { + "name": "remote_permissions_loadFailed", + "value": "Could not read the permission setting. Try again." + }, + { + "name": "remote_permissions_saveFailed", + "value": "Could not save the permission mode. Try again." + }, + { + "name": "remote_permissions_connectionRequired", + "value": "Connect a desktop to change the permission mode." + }, + { + "name": "remote_permissions_fullAccessWarningTitle", + "value": "Turn on full access?" + }, + { + "name": "remote_permissions_fullAccessWarningBody", + "value": "Desktop tools will be able to run commands and change files directly." + }, + { + "name": "remote_permissions_cancel", + "value": "Cancel" + }, + { + "name": "remote_permissions_confirmFullAccess", + "value": "Turn on full access" + }, + { + "name": "connect_localTitle", + "value": "Connect a local workspace" + }, + { + "name": "connect_chooseConnectionTitle", + "value": "Connect a computer" + }, + { + "name": "connect_legacySignInRequired", + "value": "Sign in with email or GitHub to reconnect this device. Previous connection records have been retained." + }, + { + "name": "connect_subtitle", + "value": "After connecting the desktop, OpenBitFun can access repositories, run commands, and handle local development tasks." + }, + { + "name": "connect_accountDevicesTitle", + "value": "Choose a desktop" + }, + { + "name": "connect_accountDevicesSubtitle", + "value": "Remote" + }, + { + "name": "connect_accountDevicesBody", + "value": "Choose an online desktop to continue." + }, + { + "name": "connect_availableDevices", + "value": "Account devices" + }, + { + "name": "connect_deviceLastUsed", + "value": "Last connected" + }, + { + "name": "connect_deviceLinkInvalid", + "value": "The saved connection link cannot be used. Sign in and choose a computer from your device list." + }, + { + "name": "connect_deviceLinkUnavailable", + "value": "This device is offline or does not belong to your OpenBitFun account." + }, + { + "name": "connect_accountPairTitle", + "value": "Account pairing" + }, + { + "name": "connect_accountPairIntro", + "value": "The desktop is signed in to a OpenBitFun account. Verify the same account to continue." + }, + { + "name": "connect_accountPairBody", + "value": "The password is only used for this encrypted pairing and is not saved on the phone." + }, + { + "name": "connect_enterAccountToPair", + "value": "Sign in with email or GitHub to connect this device" + }, + { + "name": "connect_accountUsernamePlaceholder", + "value": "OpenBitFun username" + }, + { + "name": "connect_accountPasswordPlaceholder", + "value": "OpenBitFun password" + }, + { + "name": "connect_stepsTitle", + "value": "How to connect" + }, + { + "name": "connect_or", + "value": "or" + }, + { + "name": "connect_userId", + "value": "Enter user ID" + }, + { + "name": "connect_userPlaceholder", + "value": "For example: DESKTOP-KM3L4UI" + }, + { + "name": "connect_connect", + "value": "Connect" + }, + { + "name": "connect_connecting", + "value": "Connecting..." + }, + { + "name": "connect_remoteUrlShort", + "value": "Remote connection link" + }, + { + "name": "connect_collapse", + "value": "Collapse" + }, + { + "name": "connect_filled", + "value": "Filled" + }, + { + "name": "connect_fill", + "value": "Fill" + }, + { + "name": "connect_clear", + "value": "Clear" + }, + { + "name": "connect_urlPlaceholder", + "value": "openbitfun:// or https://...#/pair?room=...&pk=...&relay=..." + }, + { + "name": "connect_statusTitle", + "value": "Connection status" + }, + { + "name": "connect_targetDesktop", + "value": "Target desktop" + }, + { + "name": "connect_noDesktop", + "value": "No desktop selected" + }, + { + "name": "connect_noDesktopDetail", + "value": "Connection status appears here after you sign in and choose a computer." + }, + { + "name": "connect_desktopId", + "value": "Desktop ID: %1$s" + }, + { + "name": "connect_desktopIdUnavailable", + "value": "Desktop ID unavailable" + }, + { + "name": "connect_waitingDevice", + "value": "Waiting to connect a desktop" + }, + { + "name": "connect_connected", + "value": "Connected" + }, + { + "name": "connect_connectedTo", + "value": "Connected to %1$s · %2$s" + }, + { + "name": "connect_waitingDesktop", + "value": "Waiting for the desktop to confirm" + }, + { + "name": "connect_failed", + "value": "Connection failed" + }, + { + "name": "connect_cannotConnectDesktop", + "value": "Could not connect to the desktop" + }, + { + "name": "connect_disconnected", + "value": "Disconnected" + }, + { + "name": "connect_desktopDisconnected", + "value": "Disconnected from the desktop" + }, + { + "name": "connect_hintExpiredRoom", + "value": "The legacy QR connection expired or the room does not exist. Sign in and choose a computer from your device list." + }, + { + "name": "connect_hintNetwork", + "value": "This phone cannot reach the relay. Check the network, proxy, or desktop relay URL, then retry." + }, + { + "name": "connect_hintProtectedUser", + "value": "This connection is bound to a user ID. Continue with the user ID confirmed on the desktop." + }, + { + "name": "connect_hintInvalidLink", + "value": "The saved connection link is incomplete. Sign in and choose a computer from your device list." + }, + { + "name": "connection_connected", + "value": "Connected" + }, + { + "name": "connection_reconnecting", + "value": "Reconnecting" + }, + { + "name": "connection_connecting", + "value": "Connecting" + }, + { + "name": "connection_error", + "value": "Connection error" + }, + { + "name": "connection_disconnected", + "value": "Disconnected" + }, + { + "name": "connection_waiting", + "value": "Waiting to connect" + }, + { + "name": "connection_user", + "value": "User %1$s" + }, + { + "name": "connection_reconnectingDesktop", + "value": "Reconnecting to the desktop" + }, + { + "name": "connection_unavailableReconnect", + "value": "Connection unavailable. Reconnect to continue." + }, + { + "name": "connection_desktopDisconnected", + "value": "Disconnected from the desktop" + }, + { + "name": "home_reconnect", + "value": "Reconnect" + }, + { + "name": "home_disconnect", + "value": "Disconnect" + }, + { + "name": "home_clearPairing", + "value": "Clear pairing" + }, + { + "name": "home_workbench", + "value": "Workbench" + }, + { + "name": "home_currentAssistant", + "value": "Current assistant" + }, + { + "name": "home_currentWorkspace", + "value": "Current workspace" + }, + { + "name": "home_switchAssistant", + "value": "Switch assistant" + }, + { + "name": "home_switch", + "value": "Switch" + }, + { + "name": "home_recentWorkspaces", + "value": "Recent workspaces" + }, + { + "name": "home_noRecentWorkspaces", + "value": "No recent workspaces. Open one on the desktop first." + }, + { + "name": "home_assistantWorkspaces", + "value": "Assistant workspaces" + }, + { + "name": "home_noAssistants", + "value": "No assistants available. Create or open one on the desktop first." + }, + { + "name": "home_createSession", + "value": "New session" + }, + { + "name": "home_assistantSession", + "value": "Assistant session" + }, + { + "name": "home_assistantSessionDisabled", + "value": "The desktop does not allow creating assistant sessions yet" + }, + { + "name": "home_codeDesc", + "value": "Handle coding tasks" + }, + { + "name": "home_coworkDesc", + "value": "Handle everyday work tasks" + }, + { + "name": "home_recent", + "value": "Recent" + }, + { + "name": "home_viewAll", + "value": "View all" + }, + { + "name": "home_sessionCount", + "value": "%1$s sessions" + }, + { + "name": "home_searchPlaceholder", + "value": "Search session titles" + }, + { + "name": "home_all", + "value": "All" + }, + { + "name": "home_loadMore", + "value": "Load more" + }, + { + "name": "home_deleteSession", + "value": "Delete session" + }, + { + "name": "home_emptyLoadingTitle", + "value": "Loading sessions" + }, + { + "name": "home_emptyLoadingText", + "value": "Syncing the desktop session list." + }, + { + "name": "home_emptySearchTitle", + "value": "No matching sessions" + }, + { + "name": "home_emptySearchText", + "value": "Try another keyword or filter." + }, + { + "name": "home_emptyAssistantTitle", + "value": "No assistant sessions" + }, + { + "name": "home_emptyAssistantText", + "value": "You can start one here after the desktop allows creation." + }, + { + "name": "home_emptyTitle", + "value": "No sessions" + }, + { + "name": "home_emptyText", + "value": "Use Remote for coding tasks, or general chat for everyday questions." + }, + { + "name": "sheet_createTitle", + "value": "Create a %1$s session" + }, + { + "name": "sheet_workspace", + "value": "Workspace" + }, + { + "name": "sheet_sessionTitleOptional", + "value": "Session title (optional)" + }, + { + "name": "sheet_firstInstructionOptional", + "value": "First instruction (optional)" + }, + { + "name": "sheet_instructionPlaceholder", + "value": "Tell OpenBitFun what to do..." + }, + { + "name": "sheet_advancedOptions", + "value": "Advanced options" + }, + { + "name": "sheet_unavailable", + "value": "Unavailable" + }, + { + "name": "sheet_creating", + "value": "Creating..." + }, + { + "name": "sheet_start", + "value": "Start" + }, + { + "name": "chat_remoteSession", + "value": "Remote session" + }, + { + "name": "chat_sessionTitle", + "value": "Session title" + }, + { + "name": "chat_sessionSection", + "value": "Session" + }, + { + "name": "chat_pin", + "value": "Pin" + }, + { + "name": "chat_unpin", + "value": "Unpin" + }, + { + "name": "chat_uploadedFiles", + "value": "Uploaded files" + }, + { + "name": "chat_stop", + "value": "Stop" + }, + { + "name": "chat_send", + "value": "Send" + }, + { + "name": "chat_voiceInput", + "value": "Voice input" + }, + { + "name": "chat_stopListening", + "value": "Stop dictation" + }, + { + "name": "chat_addImage", + "value": "Add image" + }, + { + "name": "chat_removeImage", + "value": "Remove image" + }, + { + "name": "chat_selectModel", + "value": "Select model" + }, + { + "name": "chat_modelPrimary", + "value": "Primary Model" + }, + { + "name": "chat_modelFast", + "value": "Fast Model" + }, + { + "name": "chat_modelFastNotConfigured", + "value": "Not set, falls back to primary" + }, + { + "name": "chat_modelRolePrimary", + "value": "Primary" + }, + { + "name": "chat_modelRoleFast", + "value": "Fast" + }, + { + "name": "chat_loadOlder", + "value": "Load older messages" + }, + { + "name": "chat_loadOlderFailed", + "value": "Could not load earlier messages. Tap to retry." + }, + { + "name": "chat_inputPlaceholder", + "value": "Ask OpenBitFun" + }, + { + "name": "chat_voiceListeningPlaceholder", + "value": "Listening, please speak..." + }, + { + "name": "chat_queuedAfterRunningTurn", + "value": "Queued. It will run after the current task finishes." + }, + { + "name": "chat_image", + "value": "Image" + }, + { + "name": "chat_imageUnavailable", + "value": "Image not synced to this device" + }, + { + "name": "chat_thinkingRunning", + "value": "Running" + }, + { + "name": "chat_thinkingDone", + "value": "Done" + }, + { + "name": "chat_thinkingInProgress", + "value": "Thinking" + }, + { + "name": "chat_thinkingComplete", + "value": "Thought" + }, + { + "name": "chat_toolCalls", + "value": "Tool calls" + }, + { + "name": "chat_toolsRan", + "value": "Ran %1$s tools" + }, + { + "name": "chat_toolOpTodoWrite", + "value": "Update task list" + }, + { + "name": "chat_toolOpTask", + "value": "Start task" + }, + { + "name": "chat_toolOpRead", + "value": "Read file" + }, + { + "name": "chat_toolOpWrite", + "value": "Write file" + }, + { + "name": "chat_toolOpDelete", + "value": "Delete file" + }, + { + "name": "chat_toolOpDiff", + "value": "View changes" + }, + { + "name": "chat_toolOpEdit", + "value": "Edit file" + }, + { + "name": "chat_toolOpCommand", + "value": "Run command" + }, + { + "name": "chat_toolOpWebSearch", + "value": "Web search" + }, + { + "name": "chat_toolOpWebFetch", + "value": "Open web page" + }, + { + "name": "chat_toolOpSearch", + "value": "Search code" + }, + { + "name": "chat_toolOpAskUser", + "value": "Ask for confirmation" + }, + { + "name": "chat_toolTodoFallback", + "value": "Task list" + }, + { + "name": "chat_toolTaskFallback", + "value": "Subtask" + }, + { + "name": "chat_subagentOutput", + "value": "Subtask output" + }, + { + "name": "chat_toolFailed", + "value": "%1$s failed" + }, + { + "name": "chat_toolRunning", + "value": "Running" + }, + { + "name": "chat_showMore", + "value": "Show more" + }, + { + "name": "chat_showLess", + "value": "Show less" + }, + { + "name": "chat_processing", + "value": "Processing" + }, + { + "name": "chat_sendFailed", + "value": "Send failed. Check the connection and retry." + }, + { + "name": "chat_responseFailed", + "value": "This response failed" + }, + { + "name": "chat_error", + "value": "Error" + }, + { + "name": "chat_output", + "value": "Output" + }, + { + "name": "chat_interactionLoadFailed", + "value": "Unable to load pending interactions" + }, + { + "name": "chat_approvalFailed", + "value": "Unable to submit response. Please retry." + }, + { + "name": "chat_approvalRequired", + "value": "Approval required" + }, + { + "name": "chat_editApprovalInput", + "value": "Edit input" + }, + { + "name": "chat_hideApprovalInput", + "value": "Hide input" + }, + { + "name": "chat_approve", + "value": "Approve" + }, + { + "name": "chat_reject", + "value": "Reject" + }, + { + "name": "chat_cancelTool", + "value": "Cancel tool" + }, + { + "name": "chat_planView", + "value": "View plan" + }, + { + "name": "chat_planBuild", + "value": "Build Plan" + }, + { + "name": "chat_planBuildUnavailable", + "value": "Update the desktop app to build plans from mobile." + }, + { + "name": "chat_planBuildWaitForTurn", + "value": "Wait for the current task to finish before building this plan." + }, + { + "name": "chat_toolInput", + "value": "Tool input" + }, + { + "name": "chat_reset", + "value": "Reset" + }, + { + "name": "chat_editJsonInput", + "value": "Edit JSON input" + }, + { + "name": "chat_answerPlaceholder", + "value": "Enter your answer" + }, + { + "name": "chat_submitAnswer", + "value": "Submit answer" + }, + { + "name": "chat_emptySyncing", + "value": "Syncing" + }, + { + "name": "chat_emptyConnectionError", + "value": "Connection error" + }, + { + "name": "chat_emptyReconnecting", + "value": "Restoring connection" + }, + { + "name": "chat_emptyNoMessages", + "value": "No messages yet" + }, + { + "name": "chat_emptyLoadingMessages", + "value": "Loading desktop session messages." + }, + { + "name": "chat_emptyReconnectFromHome", + "value": "Return to the workbench and reconnect the desktop." + }, + { + "name": "chat_emptyReconnectSync", + "value": "Messages will keep syncing after the connection is restored." + }, + { + "name": "chat_emptyFirstMessage", + "value": "After you send the first instruction, the desktop response appears here." + }, + { + "name": "chat_executing", + "value": "OpenBitFun is working..." + }, + { + "name": "chat_model", + "value": "Model" + }, + { + "name": "chat_openbitfunProcessing", + "value": "OpenBitFun is processing..." + }, + { + "name": "chat_updatedSession", + "value": "OpenBitFun updated the session" + }, + { + "name": "chat_desktopFile", + "value": "Desktop file" + }, + { + "name": "chat_fileLink", + "value": "File link" + }, + { + "name": "chat_reading", + "value": "Reading" + }, + { + "name": "chat_download", + "value": "Download" + }, + { + "name": "filePreview_title", + "value": "File preview" + }, + { + "name": "filePreview_loading", + "value": "Reading file" + }, + { + "name": "filePreview_offline", + "value": "Offline. Showing loaded content only." + }, + { + "name": "filePreview_fitImage", + "value": "Fit window" + }, + { + "name": "filePreview_actualImageSize", + "value": "Actual size" + }, + { + "name": "filePreview_expand", + "value": "Expand preview" + }, + { + "name": "filePreview_restoreSize", + "value": "Restore preview" + }, + { + "name": "filePreview_imageDecodeFailed", + "value": "The image could not be decoded. Retry or download the file." + }, + { + "name": "filePreview_loadFailed", + "value": "Could not open the file" + }, + { + "name": "filePreview_notFound", + "value": "The file does not exist or was moved" + }, + { + "name": "filePreview_unavailable", + "value": "The file does not exist or is outside the current workspace" + }, + { + "name": "filePreview_accessDenied", + "value": "Cannot access files outside the workspace" + }, + { + "name": "filePreview_tooLarge", + "value": "The file is too large to preview on mobile" + }, + { + "name": "filePreview_unsupported", + "value": "This file type cannot be previewed yet" + }, + { + "name": "filePreview_truncated", + "value": "Showing the first %1$s. Download the file to see all of it." + }, + { + "name": "chat_pendingConfirmation", + "value": "Pending confirmation" + }, + { + "name": "chat_cancelled", + "value": "Cancelled" + }, + { + "name": "chat_jsonObjectRequired", + "value": "Enter a JSON object" + }, + { + "name": "chat_jsonInvalid", + "value": "Invalid JSON" + }, + { + "name": "chat_questionDefault", + "value": "The desktop needs your answer." + }, + { + "name": "chat_analyzeImageDefault", + "value": "Please analyze this image." + }, + { + "name": "status_waitingConnection", + "value": "Waiting to connect" + }, + { + "name": "status_notConnected", + "value": "Not connected" + }, + { + "name": "status_restoringConnection", + "value": "Restoring connection" + }, + { + "name": "status_parsingUrl", + "value": "Parsing remote URL" + }, + { + "name": "status_pairing", + "value": "Pairing" + }, + { + "name": "status_reconnecting", + "value": "Reconnecting" + }, + { + "name": "status_pairingCleared", + "value": "Pairing cleared" + }, + { + "name": "status_disconnected", + "value": "Disconnected" + }, + { + "name": "status_remoteUnavailable", + "value": "Connection unavailable. Reconnect first." + }, + { + "name": "status_loadingRecentWorkspaces", + "value": "Loading recent workspaces" + }, + { + "name": "status_chooseWorkspace", + "value": "Choose a workspace" + }, + { + "name": "status_noRecentWorkspaces", + "value": "No recent workspaces" + }, + { + "name": "status_switchingWorkspace", + "value": "Switching workspace" + }, + { + "name": "status_workspaceSwitched", + "value": "Workspace switched" + }, + { + "name": "status_loadingAssistants", + "value": "Loading assistant workspaces" + }, + { + "name": "status_chooseAssistant", + "value": "Choose an assistant" + }, + { + "name": "status_noAssistants", + "value": "No assistants available" + }, + { + "name": "status_switchingAssistant", + "value": "Switching assistant" + }, + { + "name": "status_assistantSwitched", + "value": "Assistant switched" + }, + { + "name": "status_refreshSessions", + "value": "Refresh session list" + }, + { + "name": "status_loadMoreSessions", + "value": "Load more sessions" + }, + { + "name": "status_sessionsUpdated", + "value": "Session list updated" + }, + { + "name": "status_createSession", + "value": "Create remote session" + }, + { + "name": "status_sessionCreated", + "value": "Session created" + }, + { + "name": "status_loadMessages", + "value": "Load session messages" + }, + { + "name": "status_deletingSession", + "value": "Deleting session" + }, + { + "name": "status_sessionDeleted", + "value": "Session deleted" + }, + { + "name": "status_sessionArchived", + "value": "Session archived" + }, + { + "name": "status_sessionUnarchived", + "value": "Session unarchived" + }, + { + "name": "status_sessionExported", + "value": "Copied as Markdown" + }, + { + "name": "status_messagesSynced", + "value": "Messages synced" + }, + { + "name": "status_messagesRestored", + "value": "Restored local session history" + }, + { + "name": "status_switchingModel", + "value": "Switching model" + }, + { + "name": "status_modelSwitched", + "value": "Model switched" + }, + { + "name": "status_loadOlderMessages", + "value": "Load older messages" + }, + { + "name": "status_sendImage", + "value": "Send image" + }, + { + "name": "status_sendCommand", + "value": "Send instruction" + }, + { + "name": "status_sentWaiting", + "value": "Sent. Waiting for the desktop." + }, + { + "name": "status_pickImage", + "value": "Choose image" + }, + { + "name": "status_noImageSelected", + "value": "No image selected" + }, + { + "name": "status_imagesSelected", + "value": "Selected %1$s images" + }, + { + "name": "status_voiceStarting", + "value": "Starting voice input" + }, + { + "name": "status_voiceListening", + "value": "Listening" + }, + { + "name": "status_voiceRecognized", + "value": "Filled recognized speech" + }, + { + "name": "status_voiceStopped", + "value": "Voice input stopped" + }, + { + "name": "status_noRunningTask", + "value": "No task is running" + }, + { + "name": "status_stoppingTask", + "value": "Stopping task" + }, + { + "name": "status_stopRequested", + "value": "Stop requested" + }, + { + "name": "status_updatingTitle", + "value": "Updating session title" + }, + { + "name": "status_titleUpdated", + "value": "Session title updated" + }, + { + "name": "status_messageCopied", + "value": "Message copied" + }, + { + "name": "status_fileInfo", + "value": "Read file info" + }, + { + "name": "status_prepareDownload", + "value": "Preparing download %1$s" + }, + { + "name": "status_downloadDone", + "value": "Downloaded %1$s · %2$s" + }, + { + "name": "status_approvingTool", + "value": "Approving tool call" + }, + { + "name": "status_toolApproved", + "value": "Tool call approved" + }, + { + "name": "status_rejectingTool", + "value": "Rejecting tool call" + }, + { + "name": "status_toolRejected", + "value": "Tool call rejected" + }, + { + "name": "status_cancellingTool", + "value": "Cancelling tool call" + }, + { + "name": "status_toolCancelRequested", + "value": "Tool cancellation requested" + }, + { + "name": "status_submittingAnswer", + "value": "Submitting answer" + }, + { + "name": "status_answerSubmitted", + "value": "Answer submitted" + }, + { + "name": "status_desktopProcessing", + "value": "Desktop is working" + }, + { + "name": "errors_tooManyAttempts", + "value": "Too many attempts. Try again in %1$s seconds." + }, + { + "name": "errors_userIdRequired", + "value": "Enter a user ID." + }, + { + "name": "errors_accountUsernameRequired", + "value": "Enter a OpenBitFun username." + }, + { + "name": "errors_accountPasswordRequired", + "value": "Sign in with email or GitHub to continue." + }, + { + "name": "errors_remoteDataInvalid", + "value": "The relay returned data that could not be parsed. Confirm the desktop and mobile versions match." + }, + { + "name": "errors_pairRejected", + "value": "The relay rejected the legacy pairing. Sign in and choose a computer from your device list." + }, + { + "name": "errors_roomNotFound", + "value": "This remote room was not found. The legacy QR connection may have expired; sign in and choose a computer from your device list." + }, + { + "name": "errors_relayTimeout", + "value": "Timed out connecting to the relay. Check the network and retry." + }, + { + "name": "errors_rateLimited", + "value": "Too many requests. Try again later." + }, + { + "name": "errors_relayUnavailable", + "value": "The relay is temporarily unavailable (HTTP %1$s). Try again later." + }, + { + "name": "errors_relayHttp", + "value": "The relay returned HTTP %1$s. Pair again and retry." + }, + { + "name": "errors_relayTimeoutDetail", + "value": "Timed out connecting to the relay. Confirm the phone and desktop can reach the network." + }, + { + "name": "errors_relayNetwork", + "value": "Could not reach the relay. Check the remote link, network, or proxy settings." + }, + { + "name": "errors_remoteConnectFailed", + "value": "Remote connection failed. Check the network and retry." + }, + { + "name": "errors_remoteUrlRequired", + "value": "Enter the desktop remote connection URL." + }, + { + "name": "errors_remoteUrlMissingParams", + "value": "The remote URL is missing the room or pk parameter." + }, + { + "name": "errors_remoteUrlInvalid", + "value": "The remote connection link is invalid. Sign in and choose a computer from your device list." + }, + { + "name": "errors_permissionDenied", + "value": "Required permission was denied. Allow it in system settings and retry." + }, + { + "name": "errors_operationFailed", + "value": "The operation failed. Try again later." + }, + { + "name": "errors_payloadTooLarge", + "value": "This message’s attachments exceed the relay size limit. Send fewer images or smaller ones." + }, + { + "name": "errors_workspaceIdUnsupported", + "value": "The connected desktop does not support workspace ID references. Update the desktop app to open this workspace." + }, + { + "name": "errors_hostStreamUnsupported", + "value": "The connected desktop runs an older OpenBitFun version that does not support on-demand session streaming. Update OpenBitFun on that device to continue." + }, + { + "name": "errors_imageTooLargeAfterCompression", + "value": "The image is still larger than %1$s MB after compression. Choose a smaller image." + }, + { + "name": "errors_imageCompressionFailed", + "value": "Image compression failed. Choose a smaller image." + }, + { + "name": "errors_voicePermissionDenied", + "value": "Microphone permission was denied, so voice input is unavailable." + }, + { + "name": "errors_voiceInputUnavailable", + "value": "Speech recognition is unavailable. Try again later." + }, + { + "name": "errors_voiceInputFailed", + "value": "Speech recognition failed (%1$s). Try again later." + }, + { + "name": "watchProvision_title", + "value": "Sign in with email or GitHub on this watch?" + }, + { + "name": "watchProvision_deviceId", + "value": "Device %1$s" + }, + { + "name": "watchProvision_body", + "value": "If you allow this, the watch will sign in to your account and can connect to a desktop. Confirm the watch is in your hands." + }, + { + "name": "watchProvision_approve", + "value": "Allow" + }, + { + "name": "watchProvision_reject", + "value": "Deny" + }, + { + "name": "watchProvision_gotIt", + "value": "Got it" + }, + { + "name": "watchProvision_working", + "value": "Setting up the watch account..." + }, + { + "name": "watchProvision_passwordBody", + "value": "Confirm your account password once more to create a separate sign-in for this watch. The password is used only for this verification." + }, + { + "name": "watchProvision_passwordPlaceholder", + "value": "Account password" + }, + { + "name": "watchProvision_passwordConfirm", + "value": "Confirm" + }, + { + "name": "watchProvision_doneBody", + "value": "%1$s is signed in and ready to use OpenBitFun." + }, + { + "name": "watchProvision_rejected", + "value": "Denied on the phone." + }, + { + "name": "watchProvision_busy", + "value": "The phone is handling another device request. Try again later." + }, + { + "name": "watchProvision_errors_noDesktop", + "value": "Sign in with email or GitHub on the phone, or connect a desktop signed in to the same account." + }, + { + "name": "watchProvision_errors_accountUnavailable", + "value": "The account could not be verified. Check the phone network and try again." + }, + { + "name": "watchProvision_errors_desktopUnreachable", + "value": "The desktop could not be reached. Make sure it is online and try again." + }, + { + "name": "watchProvision_errors_desktopAuthorizationFailed", + "value": "The desktop could not sign in the watch. Make sure it uses the same OpenBitFun account." + }, + { + "name": "watchProvision_errors_passwordFailed", + "value": "Account verification failed. Check the password or network and retry." + }, + { + "name": "watchProvision_errors_handoffFailed", + "value": "Authorization finished, but the credential could not be sent to the watch. Retry on the watch." + }, + { + "name": "ui_emptyMessage", + "value": "(empty message)" + }, + { + "name": "ui_desktopProcessing", + "value": "Desktop is working..." + }, + { + "name": "ui_seedConnected", + "value": "%1$s is connected. You can keep sending instructions; the desktop will run them in OpenBitFun." + }, + { + "name": "time_justNow", + "value": "Just now" + }, + { + "name": "time_unknown", + "value": "Unknown time" + }, + { + "name": "time_minutesAgo", + "value": "%1$s minutes ago" + }, + { + "name": "time_hoursAgo", + "value": "%1$s hours ago" + }, + { + "name": "time_daysAgo", + "value": "%1$s days ago" } ] } diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json index e85747452a..3fb4ad32dc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json @@ -19,6 +19,2702 @@ { "name": "permission_distributed_datasync_reason", "value": "用于把 GitHub 账号授权给同账号下的手表,并为手表生成独立的设备凭据。" + }, + { + "name": "workspaceTools_browse", + "value": "浏览受控设备文件夹" + }, + { + "name": "workspaceTools_selectFolder", + "value": "选择此文件夹" + }, + { + "name": "workspaceTools_back", + "value": "返回文件列表" + }, + { + "name": "workspaceTools_edit", + "value": "编辑" + }, + { + "name": "workspaceTools_lineNumbers", + "value": "行号" + }, + { + "name": "workspaceTools_unsaved", + "value": "有未保存的更改" + }, + { + "name": "workspaceTools_fileConflict", + "value": "电脑上的文件已被修改,当前草稿仍保留。请先复制草稿,再重新打开文件以读取最新内容。" + }, + { + "name": "workspaceTools_discard", + "value": "放弃更改" + }, + { + "name": "workspaceTools_nameAsc", + "value": "名称升序" + }, + { + "name": "workspaceTools_nameDesc", + "value": "名称降序" + }, + { + "name": "workspaceTools_modifiedAsc", + "value": "修改时间升序" + }, + { + "name": "workspaceTools_modifiedDesc", + "value": "修改时间降序" + }, + { + "name": "workspaceTools_upload", + "value": "上传文件" + }, + { + "name": "workspaceTools_resumeUpload", + "value": "继续上传" + }, + { + "name": "workspaceTools_cancelUpload", + "value": "取消上传" + }, + { + "name": "workspaceTools_selectLocation", + "value": "选择位置" + }, + { + "name": "workspaceTools_runtimeLocal", + "value": "受控设备本机" + }, + { + "name": "workspaceTools_download", + "value": "下载" + }, + { + "name": "workspaceTools_title", + "value": "工具" + }, + { + "name": "workspaceTools_files", + "value": "文件" + }, + { + "name": "sidebar_controlledDevice", + "value": "受控设备" + }, + { + "name": "sidebar_currentPath", + "value": "当前路径" + }, + { + "name": "sidebar_recentWorkspaces", + "value": "最近工作区" + }, + { + "name": "sidebar_tapToSwitch", + "value": "点击切换" + }, + { + "name": "workspaceTools_selectTerminal", + "value": "选择已有终端" + }, + { + "name": "workspaceTools_terminal", + "value": "终端" + }, + { + "name": "workspaceTools_up", + "value": "上级目录" + }, + { + "name": "workspaceTools_refresh", + "value": "刷新" + }, + { + "name": "workspaceTools_more", + "value": "加载更多" + }, + { + "name": "workspaceTools_fileName", + "value": "文件名" + }, + { + "name": "workspaceTools_path", + "value": "被控运行端绝对路径" + }, + { + "name": "workspaceTools_newFile", + "value": "新建文件" + }, + { + "name": "workspaceTools_mkdir", + "value": "新建文件夹" + }, + { + "name": "workspaceTools_rename", + "value": "重命名" + }, + { + "name": "workspaceTools_save", + "value": "保存" + }, + { + "name": "workspaceTools_delete", + "value": "删除文件" + }, + { + "name": "workspaceTools_confirmDelete", + "value": "确认删除" + }, + { + "name": "workspaceTools_rmdir", + "value": "删除空文件夹" + }, + { + "name": "workspaceTools_createTerminal", + "value": "新建终端" + }, + { + "name": "workspaceTools_command", + "value": "终端输入" + }, + { + "name": "workspaceTools_send", + "value": "发送" + }, + { + "name": "workspaceTools_closeTerminal", + "value": "关闭终端" + }, + { + "name": "workspaceTools_resize", + "value": "调整尺寸" + }, + { + "name": "home_recent_title", + "value": "今天,想做点什么?" + }, + { + "name": "home_recent_recent", + "value": "最近会话" + }, + { + "name": "home_recent_browse", + "value": "浏览工作区" + }, + { + "name": "home_recent_all", + "value": "全部会话" + }, + { + "name": "home_recent_empty", + "value": "还没有可继续的对话" + }, + { + "name": "home_recent_connect", + "value": "连接电脑后,继续你的对话。" + }, + { + "name": "account_errorNetwork", + "value": "暂时无法连接,请检查网络后重试。" + }, + { + "name": "account_errorTimeout", + "value": "连接超时,请检查网络后重试。" + }, + { + "name": "account_errorSecureConnection", + "value": "无法建立安全连接,请检查网络后重试。" + }, + { + "name": "account_errorRateLimited", + "value": "操作太频繁,请稍后重试。" + }, + { + "name": "home_welcome_login", + "value": "登录账号" + }, + { + "name": "home_welcome_work", + "value": "干点活儿" + }, + { + "name": "home_welcome_play", + "value": "玩点花样" + }, + { + "name": "home_welcome_yours", + "value": "随你发挥" + }, + { + "name": "home_connectComputer", + "value": "连接电脑" + }, + { + "name": "home_connecting", + "value": "正在连接…" + }, + { + "name": "home_exploreMiniApps", + "value": "探索小应用" + }, + { + "name": "home_miniAppsHint", + "value": "无需连接电脑,也能即刻使用" + }, + { + "name": "miniapp_allApps", + "value": "全部应用" + }, + { + "name": "miniapp_availableOffline", + "value": "离线可用" + }, + { + "name": "miniapp_timeout", + "value": "本地操作超时,操作可能仍会完成,未自动重试。" + }, + { + "name": "miniapp_title", + "value": "小应用" + }, + { + "name": "miniapp_refresh", + "value": "刷新" + }, + { + "name": "miniapp_empty", + "value": "暂无内置小应用。" + }, + { + "name": "miniapp_unsupported", + "value": "手机内置小应用不支持此能力。" + }, + { + "name": "miniapp_invalidPage", + "value": "小应用页面数据不完整,请重新打开。" + }, + { + "name": "miniapp_closed", + "value": "小应用已关闭或切换。" + }, + { + "name": "miniapp_previewHint", + "value": "内置应用 · 离线可用,数据保存在本机。" + }, + { + "name": "notification_onboardingTitle", + "value": "开启任务完成提醒" + }, + { + "name": "notification_onboardingBody", + "value": "允许 OpenBitFun 在任务完成时发送通知。你可以稍后在系统设置中更改。" + }, + { + "name": "notification_onboardingLater", + "value": "稍后" + }, + { + "name": "notification_onboardingEnable", + "value": "开启通知" + }, + { + "name": "app_title", + "value": "OpenBitFun" + }, + { + "name": "common_cancel", + "value": "取消" + }, + { + "name": "common_back", + "value": "返回" + }, + { + "name": "common_close", + "value": "关闭" + }, + { + "name": "common_copy", + "value": "复制" + }, + { + "name": "common_current", + "value": "当前" + }, + { + "name": "common_done", + "value": "已完成" + }, + { + "name": "common_delete", + "value": "删除" + }, + { + "name": "common_failed", + "value": "失败" + }, + { + "name": "common_keep", + "value": "保留" + }, + { + "name": "common_loading", + "value": "加载中..." + }, + { + "name": "common_open", + "value": "打开" + }, + { + "name": "common_other", + "value": "其他" + }, + { + "name": "common_ready", + "value": "就绪" + }, + { + "name": "common_refresh", + "value": "刷新" + }, + { + "name": "common_retry", + "value": "重试" + }, + { + "name": "common_save", + "value": "保存" + }, + { + "name": "common_search", + "value": "搜索" + }, + { + "name": "common_running", + "value": "运行中" + }, + { + "name": "common_waiting", + "value": "等待中" + }, + { + "name": "notification_taskCompletedTitle", + "value": "任务已完成" + }, + { + "name": "notification_taskCompletedBody", + "value": "OpenBitFun 任务已经完成,点击打开 OpenBitFun 查看结果。" + }, + { + "name": "chatHome_prompt", + "value": "今天想做什么?" + }, + { + "name": "chatHome_placeholder", + "value": "问问 OpenBitFun" + }, + { + "name": "chatHome_localHint", + "value": "这是手机上的对话。要读写电脑上的项目,请切换到远程。" + }, + { + "name": "chatHome_research", + "value": "查找资料" + }, + { + "name": "chatHome_researchPrompt", + "value": "帮我查找资料并整理重点:" + }, + { + "name": "chatHome_image", + "value": "分析图片" + }, + { + "name": "chatHome_imagePrompt", + "value": "请分析这张图片。" + }, + { + "name": "chatHome_file", + "value": "处理文件" + }, + { + "name": "chatHome_filePrompt", + "value": "帮我总结这个文件的重点。" + }, + { + "name": "chatHome_write", + "value": "帮我写点内容" + }, + { + "name": "chatHome_writePrompt", + "value": "请帮我起草一段内容:" + }, + { + "name": "chatHome_organize", + "value": "梳理一个问题" + }, + { + "name": "chatHome_organizePrompt", + "value": "请帮我梳理这个问题的重点和思路:" + }, + { + "name": "chatHome_plan", + "value": "制定行动计划" + }, + { + "name": "chatHome_planPrompt", + "value": "请帮我制定一份清晰、可执行的行动计划:" + }, + { + "name": "chatHome_codeHintTitle", + "value": "这个任务可能需要远程能力" + }, + { + "name": "chatHome_codeHintBody", + "value": "连接桌面工作区后,OpenBitFun 才能读取项目文件、运行命令并修改代码。" + }, + { + "name": "chatHome_enterCode", + "value": "进入远程" + }, + { + "name": "generalChat_ready", + "value": "OpenBitFun 已就绪" + }, + { + "name": "generalChat_generating", + "value": "OpenBitFun 正在回复" + }, + { + "name": "generalChat_sending", + "value": "正在发送消息" + }, + { + "name": "generalChat_offline", + "value": "网络不可用,草稿和历史仍保存在本机。" + }, + { + "name": "generalChat_stopped", + "value": "已停止生成。" + }, + { + "name": "generalChat_interrupted", + "value": "回复已中断,已保留收到的内容。" + }, + { + "name": "generalChat_replyInterrupted", + "value": "回复中断" + }, + { + "name": "generalChat_fileDownloadMock", + "value": "普通聊天暂不支持下载桌面端文件,请进入远程后处理本地文件。" + }, + { + "name": "generalChat_filePreviewUnavailable", + "value": "普通聊天暂不支持预览桌面端文件,请进入远程后打开。" + }, + { + "name": "generalChat_localRestoreFailed", + "value": "普通对话历史暂时无法恢复,你仍可新建对话。" + }, + { + "name": "generalChat_modelNotConfigured", + "value": "请先在设置中配置普通对话模型。" + }, + { + "name": "generalChat_authenticationFailed", + "value": "API Key 无效或无权访问该模型。" + }, + { + "name": "generalChat_rateLimited", + "value": "模型服务请求过于频繁,请稍后重试。" + }, + { + "name": "generalChat_serviceUnavailable", + "value": "模型服务暂时不可用,请稍后重试。" + }, + { + "name": "generalChat_invalidResponse", + "value": "模型服务返回了无法识别的响应。" + }, + { + "name": "generalChat_emptyResponse", + "value": "模型没有返回文字内容。" + }, + { + "name": "generalChat_requestFailed", + "value": "模型请求失败(HTTP %1$s)。" + }, + { + "name": "settings_general_section", + "value": "通用" + }, + { + "name": "settings_account_section", + "value": "账号" + }, + { + "name": "settings_account_currentIdentity", + "value": "当前身份" + }, + { + "name": "settings_model_section", + "value": "模型" + }, + { + "name": "settings_model_default", + "value": "默认模型" + }, + { + "name": "settings_devices_section", + "value": "设备" + }, + { + "name": "settings_title", + "value": "设置" + }, + { + "name": "settings_language_title", + "value": "语言" + }, + { + "name": "settings_language_choose", + "value": "选择语言" + }, + { + "name": "settings_language_zhCN", + "value": "简体中文" + }, + { + "name": "settings_language_enUS", + "value": "English" + }, + { + "name": "settings_about_section", + "value": "关于" + }, + { + "name": "settings_about_openbitfun", + "value": "关于 OpenBitFun" + }, + { + "name": "settings_about_product", + "value": "产品" + }, + { + "name": "settings_about_version", + "value": "版本" + }, + { + "name": "settings_modelService_title", + "value": "模型" + }, + { + "name": "settings_modelService_manageTitle", + "value": "普通对话模型" + }, + { + "name": "settings_modelService_localTitle", + "value": "本机自定义模型" + }, + { + "name": "settings_modelService_currentModel", + "value": "当前使用" + }, + { + "name": "settings_modelService_modelSources", + "value": "模型来源" + }, + { + "name": "settings_modelService_chooseAccountModel", + "value": "选择账号模型" + }, + { + "name": "settings_modelService_accountModels", + "value": "账号同步" + }, + { + "name": "settings_modelService_accountModelSummary", + "value": "云端账号模型" + }, + { + "name": "settings_modelService_localModel", + "value": "本机自定义" + }, + { + "name": "settings_modelService_accountSource", + "value": "云端账号" + }, + { + "name": "settings_modelService_localSource", + "value": "本机" + }, + { + "name": "settings_modelService_syncedCount", + "value": "已同步 %1$s 个" + }, + { + "name": "settings_modelService_accountEmpty", + "value": "暂无可用的账号模型" + }, + { + "name": "settings_modelService_configured", + "value": "已配置" + }, + { + "name": "settings_modelService_notConfigured", + "value": "未配置" + }, + { + "name": "settings_modelService_apiUrl", + "value": "API URL" + }, + { + "name": "settings_modelService_apiUrlPlaceholder", + "value": "https://api.example.com" + }, + { + "name": "settings_modelService_apiKey", + "value": "API Key" + }, + { + "name": "settings_modelService_apiKeyPlaceholder", + "value": "输入 API Key" + }, + { + "name": "settings_modelService_apiKeyKeepPlaceholder", + "value": "留空则保留已保存的 Key" + }, + { + "name": "settings_modelService_modelName", + "value": "模型名称" + }, + { + "name": "settings_modelService_modelPlaceholder", + "value": "例如 chat-model" + }, + { + "name": "settings_modelService_keySaved", + "value": "已安全保存" + }, + { + "name": "settings_modelService_clearKey", + "value": "清除已保存的 Key" + }, + { + "name": "settings_modelService_keepKey", + "value": "保留已保存的 Key" + }, + { + "name": "settings_modelService_testConnection", + "value": "测试连接" + }, + { + "name": "settings_modelService_testNeedsKey", + "value": "保留或输入 API Key 后可测试连接。" + }, + { + "name": "settings_modelService_testSuccess", + "value": "连接测试通过。" + }, + { + "name": "settings_modelService_invalidUrl", + "value": "API URL 必须以 http:// 或 https:// 开头。" + }, + { + "name": "settings_modelService_modelRequired", + "value": "请输入模型名称。" + }, + { + "name": "settings_modelService_apiKeyRequired", + "value": "请输入 API Key。" + }, + { + "name": "settings_modelService_encryptFailed", + "value": "API Key 加密失败。" + }, + { + "name": "settings_modelService_secureStoreFailed", + "value": "无法访问系统安全存储,请稍后重试。" + }, + { + "name": "sidebar_chat", + "value": "库" + }, + { + "name": "sidebar_projects", + "value": "项目" + }, + { + "name": "sidebar_tasks", + "value": "已计划" + }, + { + "name": "sidebar_tools", + "value": "应用" + }, + { + "name": "sidebar_more", + "value": "更多" + }, + { + "name": "sidebar_newChat", + "value": "聊天" + }, + { + "name": "sidebar_generalChat", + "value": "普通对话" + }, + { + "name": "sidebar_conversations", + "value": "最近对话" + }, + { + "name": "sidebar_devices", + "value": "设备" + }, + { + "name": "sidebar_legacyWorkspaceCatalog", + "value": "旧版设备:显示最近访问的工作区" + }, + { + "name": "sidebar_workspaces", + "value": "工作区" + }, + { + "name": "sidebar_deviceOnline", + "value": "在线" + }, + { + "name": "sidebar_addWorkspace", + "value": "添加工作区" + }, + { + "name": "sidebar_targetWorkspacePath", + "value": "受控设备上的路径" + }, + { + "name": "sidebar_openWorkspacePath", + "value": "打开工作区" + }, + { + "name": "sidebar_remoteRootBrowseOnly", + "value": "远程根目录仅用于浏览,请选择具体工作区目录" + }, + { + "name": "sidebar_browseRemoteRoot", + "value": "浏览远程根目录" + }, + { + "name": "sidebar_selectWorkspace", + "value": "选择工作区" + }, + { + "name": "sidebar_workspacesOffline", + "value": "未连接" + }, + { + "name": "sidebar_connectDesktop", + "value": "连接电脑" + }, + { + "name": "sidebar_addConnection", + "value": "添加连接" + }, + { + "name": "sidebar_newInWorkspace", + "value": "在此工作区新建" + }, + { + "name": "sidebar_emptyWorkspaces", + "value": "这台电脑还没有工作区" + }, + { + "name": "sidebar_moreSessions", + "value": "还有 %1$s 个会话" + }, + { + "name": "sidebar_moreWorkspaces", + "value": "还有 %1$s 个工作区" + }, + { + "name": "sidebar_deviceOffline", + "value": "离线" + }, + { + "name": "sidebar_deviceLoadFailed", + "value": "这台电脑暂时无法读取" + }, + { + "name": "sidebar_moreDevices", + "value": "还有 %1$s 台设备" + }, + { + "name": "sidebar_pinned", + "value": "置顶" + }, + { + "name": "sidebar_archived", + "value": "已归档" + }, + { + "name": "sidebar_archive", + "value": "归档" + }, + { + "name": "sidebar_unarchive", + "value": "取消归档" + }, + { + "name": "sidebar_exportMarkdown", + "value": "复制 Markdown" + }, + { + "name": "sidebar_untitled", + "value": "未命名会话" + }, + { + "name": "sidebar_emptyRecentTitle", + "value": "暂无最近会话" + }, + { + "name": "sidebar_emptyRecentText", + "value": "开始聊天后会显示在这里。" + }, + { + "name": "sidebar_deleteConfirm", + "value": "删除后将无法恢复这条会话。" + }, + { + "name": "sidebar_searchPlaceholder", + "value": "搜索对话" + }, + { + "name": "sidebar_noSearchResult", + "value": "没有匹配的会话" + }, + { + "name": "sidebar_signedOutNewChat", + "value": "新聊天" + }, + { + "name": "sidebar_signInOpenBitFunAccount", + "value": "使用邮箱或 GitHub 登录" + }, + { + "name": "sidebar_collapse", + "value": "收起侧边栏" + }, + { + "name": "sidebar_restore", + "value": "展开侧边栏" + }, + { + "name": "session_actions", + "value": "会话操作" + }, + { + "name": "session_details", + "value": "会话详情" + }, + { + "name": "session_viewDetails", + "value": "查看详情" + }, + { + "name": "session_agentType", + "value": "智能体类型" + }, + { + "name": "session_workspace", + "value": "工作区" + }, + { + "name": "session_workspacePath", + "value": "工作区路径" + }, + { + "name": "session_createdAt", + "value": "创建时间" + }, + { + "name": "session_updatedAt", + "value": "更新时间" + }, + { + "name": "session_messageCount", + "value": "消息数" + }, + { + "name": "session_status", + "value": "状态" + }, + { + "name": "viewSettings_title", + "value": "视图设置" + }, + { + "name": "viewSettings_subtitle", + "value": "调整会话列表的分组和信息密度" + }, + { + "name": "viewSettings_grouping", + "value": "分组方式" + }, + { + "name": "viewSettings_filters", + "value": "筛选" + }, + { + "name": "viewSettings_metadata", + "value": "显示信息" + }, + { + "name": "viewSettings_workspace", + "value": "工作区" + }, + { + "name": "viewSettings_agentType", + "value": "会话类型" + }, + { + "name": "viewSettings_allWorkspaces", + "value": "全部工作区" + }, + { + "name": "viewSettings_allAgentTypes", + "value": "全部类型" + }, + { + "name": "viewSettings_allStatuses", + "value": "全部状态" + }, + { + "name": "viewSettings_updated", + "value": "更新时间" + }, + { + "name": "viewSettings_status", + "value": "运行状态" + }, + { + "name": "common_unknown", + "value": "未知" + }, + { + "name": "code_title", + "value": "OpenBitFun 远程" + }, + { + "name": "code_heroTitle", + "value": "本地开发助手" + }, + { + "name": "code_heroBody", + "value": "连接桌面端后,OpenBitFun 可以访问你的代码仓库、运行命令和修改文件。" + }, + { + "name": "code_connectWorkspace", + "value": "连接本地工作区" + }, + { + "name": "code_noWorkspacePreview", + "value": "连接桌面端后会显示最近打开的代码仓库。" + }, + { + "name": "code_newTask", + "value": "新建远程任务" + }, + { + "name": "code_switchWorkspace", + "value": "切换工作区" + }, + { + "name": "code_disconnect", + "value": "断开连接" + }, + { + "name": "code_recentSessions", + "value": "最近远程会话" + }, + { + "name": "code_emptySessionTitle", + "value": "暂无远程会话" + }, + { + "name": "code_emptySessionText", + "value": "连接本地工作区后,可以新建远程任务处理项目问题。" + }, + { + "name": "remote_actionUnsupported", + "value": "已连接的设备不支持此操作。" + }, + { + "name": "remote_title", + "value": "远程" + }, + { + "name": "remote_chats", + "value": "聊天" + }, + { + "name": "remote_searchChats", + "value": "搜索聊天记录" + }, + { + "name": "remote_emptyTitle", + "value": "还没有远程对话" + }, + { + "name": "remote_emptyText", + "value": "新建聊天后,可以从手机继续处理桌面端任务。" + }, + { + "name": "remote_pickSession", + "value": "选择一个会话" + }, + { + "name": "remote_pickSessionText", + "value": "从侧边栏打开会话,或新建一个。" + }, + { + "name": "remote_startSession", + "value": "新建会话" + }, + { + "name": "remote_harness_title", + "value": "选择执行模式" + }, + { + "name": "remote_harness_minimal", + "value": "极简" + }, + { + "name": "remote_harness_standard", + "value": "标准" + }, + { + "name": "remote_harness_ultimate", + "value": "极致" + }, + { + "name": "remote_connectTitle", + "value": "连接桌面端" + }, + { + "name": "remote_actions", + "value": "远程设置" + }, + { + "name": "remote_device", + "value": "连接的设备" + }, + { + "name": "remote_newChat", + "value": "聊天" + }, + { + "name": "remote_workspace", + "value": "工作区" + }, + { + "name": "remote_assistant", + "value": "助理" + }, + { + "name": "remote_noAssistant", + "value": "默认助理" + }, + { + "name": "remote_loadMore", + "value": "加载更多聊天" + }, + { + "name": "remote_menu_organize", + "value": "整理" + }, + { + "name": "remote_menu_byProject", + "value": "按项目" + }, + { + "name": "remote_menu_byTime", + "value": "按时间倒序排列" + }, + { + "name": "remote_menu_chatFirst", + "value": "聊天优先" + }, + { + "name": "remote_menu_manage", + "value": "管理" + }, + { + "name": "remote_menu_cloudTasks", + "value": "云端任务" + }, + { + "name": "remote_menu_addConnection", + "value": "添加连接" + }, + { + "name": "remote_menu_settings", + "value": "设置" + }, + { + "name": "remote_menu_refreshConnection", + "value": "刷新连接" + }, + { + "name": "remote_menu_comingSoon", + "value": "待上线" + }, + { + "name": "remote_projects_showMore", + "value": "再显示 %1$s 个项目" + }, + { + "name": "remote_sessions_showMore", + "value": "再显示 %1$s 条会话" + }, + { + "name": "remote_time_today", + "value": "今天" + }, + { + "name": "remote_time_yesterday", + "value": "昨天" + }, + { + "name": "remote_time_earlier", + "value": "更早" + }, + { + "name": "remote_settings_relayReauthorizationRequired", + "value": "中继服务已升级,请重新登录关联新服务;已保存的账户和数据会保留。" + }, + { + "name": "remote_settings_currentControl", + "value": "当前远程控制" + }, + { + "name": "remote_settings_accountDevice", + "value": "账号设备" + }, + { + "name": "remote_settings_qrPairing", + "value": "扫码配对" + }, + { + "name": "remote_settings_disconnect", + "value": "断开" + }, + { + "name": "remote_settings_disconnectTemporary", + "value": "断开临时连接" + }, + { + "name": "remote_settings_reconnect", + "value": "重新连接" + }, + { + "name": "remote_settings_noDesktop", + "value": "尚未连接桌面端" + }, + { + "name": "remote_settings_openbitfunUser", + "value": "OpenBitFun 用户" + }, + { + "name": "remote_settings_accountSignedIn", + "value": "已登录" + }, + { + "name": "remote_settings_accountNotSignedIn", + "value": "未登录" + }, + { + "name": "remote_settings_accountTemporary", + "value": "临时登录" + }, + { + "name": "remote_settings_accountLoginTitle", + "value": "使用邮箱或 GitHub 登录" + }, + { + "name": "remote_settings_accountLoginBody", + "value": "使用邮箱或 GitHub 登录并连接自己的电脑。\\n任务和模型配置保留在被控电脑上。" + }, + { + "name": "remote_settings_loginServer", + "value": "登录服务器" + }, + { + "name": "remote_settings_accountSignIn", + "value": "使用邮箱或 GitHub 登录" + }, + { + "name": "remote_settings_accountSigningIn", + "value": "正在登录…" + }, + { + "name": "remote_settings_accountLoginFailed", + "value": "暂时无法登录,请稍后重试。" + }, + { + "name": "remote_settings_relayUrlPlaceholder", + "value": "Relay 地址,例如 https://relay.example.com" + }, + { + "name": "remote_settings_accountLogout", + "value": "退出登录" + }, + { + "name": "remote_settings_accountLoggingOut", + "value": "正在退出…" + }, + { + "name": "remote_settings_deviceCurrent", + "value": "当前设备" + }, + { + "name": "remote_settings_deviceRefresh", + "value": "刷新" + }, + { + "name": "remote_settings_deviceLoading", + "value": "正在加载设备…" + }, + { + "name": "remote_settings_deviceEmpty", + "value": "账号下还没有其他已注册设备。" + }, + { + "name": "remote_settings_deviceLoginRequired", + "value": "登录云账号后可查看该账号下的所有设备。" + }, + { + "name": "remote_settings_deviceOnline", + "value": "在线" + }, + { + "name": "remote_settings_deviceOffline", + "value": "离线" + }, + { + "name": "remote_settings_deviceConnecting", + "value": "正在连接" + }, + { + "name": "remote_settings_deviceControlling", + "value": "当前控制" + }, + { + "name": "remote_settings_deviceSwitching", + "value": "正在切换到 %1$s…" + }, + { + "name": "remote_settings_deviceSwitchFailed", + "value": "切换控制设备失败,请稍后重试。" + }, + { + "name": "remote_settings_deviceLoadFailed", + "value": "设备列表加载失败,请稍后重试。" + }, + { + "name": "remote_settings_accountExpired", + "value": "云账号已失效,请重新登录。" + }, + { + "name": "remote_settings_deviceUnavailable", + "value": "目标桌面暂时不可用,请刷新设备列表后重试。" + }, + { + "name": "remote_settings_deviceIncompatible", + "value": "客户端版本不兼容,无法互控。" + }, + { + "name": "remote_settings_deviceName", + "value": "设备" + }, + { + "name": "remote_settings_deviceManagementBody", + "value": "此设备的账号凭据已使用 HUKS 加密保存。退出账号后会立即清理。" + }, + { + "name": "remote_settings_githubId", + "value": "Account ID: %1$s" + }, + { + "name": "remote_settings_githubAccount", + "value": "OpenBitFun 账号" + }, + { + "name": "remote_settings_connectedDesktop", + "value": "当前桌面端" + }, + { + "name": "remote_permissions_title", + "value": "权限模式" + }, + { + "name": "remote_permissions_scope", + "value": "此设置会同步到当前桌面,并应用于所有新的工具调用。" + }, + { + "name": "remote_permissions_ask", + "value": "需要确认" + }, + { + "name": "remote_permissions_askDescription", + "value": "高风险操作会等待你确认。" + }, + { + "name": "remote_permissions_auto", + "value": "自动批准" + }, + { + "name": "remote_permissions_autoDescription", + "value": "原本需要确认的操作会自动通过。" + }, + { + "name": "remote_permissions_fullAccess", + "value": "完全访问" + }, + { + "name": "remote_permissions_fullAccessDescription", + "value": "允许所有工具操作,不再请求确认。" + }, + { + "name": "remote_permissions_loading", + "value": "正在读取桌面权限设置…" + }, + { + "name": "remote_permissions_loadFailed", + "value": "权限设置读取失败,请重试。" + }, + { + "name": "remote_permissions_saveFailed", + "value": "权限模式保存失败,请重试。" + }, + { + "name": "remote_permissions_connectionRequired", + "value": "连接桌面后可修改权限模式。" + }, + { + "name": "remote_permissions_fullAccessWarningTitle", + "value": "确认开启完全访问?" + }, + { + "name": "remote_permissions_fullAccessWarningBody", + "value": "开启后,桌面端工具可以直接执行命令和修改文件。" + }, + { + "name": "remote_permissions_cancel", + "value": "取消" + }, + { + "name": "remote_permissions_confirmFullAccess", + "value": "开启完全访问" + }, + { + "name": "connect_localTitle", + "value": "连接本地工作区" + }, + { + "name": "connect_chooseConnectionTitle", + "value": "连接电脑" + }, + { + "name": "connect_legacySignInRequired", + "value": "请使用邮箱或 GitHub 登录后重新连接设备。原有连接记录已保留。" + }, + { + "name": "connect_subtitle", + "value": "连接桌面端后,OpenBitFun 可以访问代码仓库、运行命令和处理本地开发任务。" + }, + { + "name": "connect_accountDevicesTitle", + "value": "选择桌面设备" + }, + { + "name": "connect_accountDevicesSubtitle", + "value": "远程" + }, + { + "name": "connect_accountDevicesBody", + "value": "选择一台在线桌面继续工作。" + }, + { + "name": "connect_availableDevices", + "value": "账号设备" + }, + { + "name": "connect_deviceLastUsed", + "value": "上次连接" + }, + { + "name": "connect_deviceLinkInvalid", + "value": "已保存的连接链接无法使用,请登录账号后从设备列表选择电脑。" + }, + { + "name": "connect_deviceLinkUnavailable", + "value": "该设备已离线,或不属于当前 OpenBitFun 账户。" + }, + { + "name": "connect_accountPairTitle", + "value": "账号配对验证" + }, + { + "name": "connect_accountPairIntro", + "value": "桌面端已使用邮箱或 GitHub 登录,需要验证同一账号后继续连接。" + }, + { + "name": "connect_accountPairBody", + "value": "密码只用于本次加密配对,不会保存到手机。" + }, + { + "name": "connect_enterAccountToPair", + "value": "请使用邮箱或 GitHub 登录后连接设备" + }, + { + "name": "connect_accountUsernamePlaceholder", + "value": "OpenBitFun 用户名" + }, + { + "name": "connect_accountPasswordPlaceholder", + "value": "OpenBitFun 密码" + }, + { + "name": "connect_stepsTitle", + "value": "连接步骤" + }, + { + "name": "connect_or", + "value": "或" + }, + { + "name": "connect_userId", + "value": "输入用户 ID" + }, + { + "name": "connect_userPlaceholder", + "value": "例如:DESKTOP-KM3L4UI" + }, + { + "name": "connect_connect", + "value": "连接" + }, + { + "name": "connect_connecting", + "value": "连接中..." + }, + { + "name": "connect_remoteUrlShort", + "value": "远程连接链接" + }, + { + "name": "connect_collapse", + "value": "收起" + }, + { + "name": "connect_filled", + "value": "已填写" + }, + { + "name": "connect_fill", + "value": "填写" + }, + { + "name": "connect_clear", + "value": "清除" + }, + { + "name": "connect_urlPlaceholder", + "value": "openbitfun:// 或 https://...#/pair?room=...&pk=...&relay=..." + }, + { + "name": "connect_statusTitle", + "value": "连接状态" + }, + { + "name": "connect_targetDesktop", + "value": "目标桌面端" + }, + { + "name": "connect_noDesktop", + "value": "尚未选择桌面端" + }, + { + "name": "connect_noDesktopDetail", + "value": "登录账号并选择一台电脑后,会在这里显示连接状态。" + }, + { + "name": "connect_desktopId", + "value": "桌面 ID:%1$s" + }, + { + "name": "connect_desktopIdUnavailable", + "value": "桌面 ID 暂不可用" + }, + { + "name": "connect_waitingDevice", + "value": "等待连接桌面设备" + }, + { + "name": "connect_connected", + "value": "已连接" + }, + { + "name": "connect_connectedTo", + "value": "已连接到 %1$s · %2$s" + }, + { + "name": "connect_waitingDesktop", + "value": "等待桌面端确认" + }, + { + "name": "connect_failed", + "value": "连接失败" + }, + { + "name": "connect_cannotConnectDesktop", + "value": "无法连接到桌面设备" + }, + { + "name": "connect_disconnected", + "value": "已断开" + }, + { + "name": "connect_desktopDisconnected", + "value": "与桌面断开连接" + }, + { + "name": "connect_hintExpiredRoom", + "value": "旧的扫码连接已过期或房间不存在,请登录账号后从设备列表选择电脑。" + }, + { + "name": "connect_hintNetwork", + "value": "手机无法访问中继服务,请检查网络、代理或桌面端中继地址后重试。" + }, + { + "name": "connect_hintProtectedUser", + "value": "这个连接已绑定用户 ID,请继续使用桌面端确认过的用户 ID。" + }, + { + "name": "connect_hintInvalidLink", + "value": "已保存的连接链接不完整,请登录账号后从设备列表选择电脑。" + }, + { + "name": "connection_connected", + "value": "已连接" + }, + { + "name": "connection_reconnecting", + "value": "正在重连" + }, + { + "name": "connection_connecting", + "value": "连接中" + }, + { + "name": "connection_error", + "value": "连接异常" + }, + { + "name": "connection_disconnected", + "value": "已断开" + }, + { + "name": "connection_waiting", + "value": "等待连接" + }, + { + "name": "connection_user", + "value": "用户 %1$s" + }, + { + "name": "connection_reconnectingDesktop", + "value": "正在重新连接桌面端" + }, + { + "name": "connection_unavailableReconnect", + "value": "连接不可用,请重新连接" + }, + { + "name": "connection_desktopDisconnected", + "value": "与桌面端断开连接" + }, + { + "name": "home_reconnect", + "value": "重连" + }, + { + "name": "home_disconnect", + "value": "断开" + }, + { + "name": "home_clearPairing", + "value": "清除配对" + }, + { + "name": "home_workbench", + "value": "工作台" + }, + { + "name": "home_currentAssistant", + "value": "当前助理" + }, + { + "name": "home_currentWorkspace", + "value": "当前工作区" + }, + { + "name": "home_switchAssistant", + "value": "切换助理" + }, + { + "name": "home_switch", + "value": "切换" + }, + { + "name": "home_recentWorkspaces", + "value": "最近工作区" + }, + { + "name": "home_noRecentWorkspaces", + "value": "没有最近工作区,请先在桌面端打开工作区。" + }, + { + "name": "home_assistantWorkspaces", + "value": "助理工作区" + }, + { + "name": "home_noAssistants", + "value": "没有可用助理,请先在桌面端创建或打开助理。" + }, + { + "name": "home_createSession", + "value": "新建会话" + }, + { + "name": "home_assistantSession", + "value": "助理会话" + }, + { + "name": "home_assistantSessionDisabled", + "value": "桌面端暂未开放创建助理会话" + }, + { + "name": "home_codeDesc", + "value": "处理编码任务" + }, + { + "name": "home_coworkDesc", + "value": "处理日常办公任务" + }, + { + "name": "home_recent", + "value": "最近" + }, + { + "name": "home_viewAll", + "value": "查看全部" + }, + { + "name": "home_sessionCount", + "value": "%1$s 个会话" + }, + { + "name": "home_searchPlaceholder", + "value": "搜索会话标题" + }, + { + "name": "home_all", + "value": "全部" + }, + { + "name": "home_loadMore", + "value": "加载更多" + }, + { + "name": "home_deleteSession", + "value": "删除会话" + }, + { + "name": "home_emptyLoadingTitle", + "value": "加载会话中" + }, + { + "name": "home_emptyLoadingText", + "value": "正在同步桌面端会话列表。" + }, + { + "name": "home_emptySearchTitle", + "value": "没有匹配的会话" + }, + { + "name": "home_emptySearchText", + "value": "换一个关键词或筛选条件后再试。" + }, + { + "name": "home_emptyAssistantTitle", + "value": "暂无助理会话" + }, + { + "name": "home_emptyAssistantText", + "value": "桌面端开放创建后可在这里开始。" + }, + { + "name": "home_emptyTitle", + "value": "暂无会话" + }, + { + "name": "home_emptyText", + "value": "用远程处理编码任务,或用普通对话处理日常问题。" + }, + { + "name": "sheet_createTitle", + "value": "创建 %1$s 会话" + }, + { + "name": "sheet_workspace", + "value": "工作区" + }, + { + "name": "sheet_sessionTitleOptional", + "value": "会话标题(可选)" + }, + { + "name": "sheet_firstInstructionOptional", + "value": "首条指令(可选)" + }, + { + "name": "sheet_instructionPlaceholder", + "value": "告诉 OpenBitFun 要做什么..." + }, + { + "name": "sheet_advancedOptions", + "value": "高级选项" + }, + { + "name": "sheet_unavailable", + "value": "暂不可用" + }, + { + "name": "sheet_creating", + "value": "创建中..." + }, + { + "name": "sheet_start", + "value": "开始" + }, + { + "name": "chat_remoteSession", + "value": "远程会话" + }, + { + "name": "chat_sessionTitle", + "value": "会话标题" + }, + { + "name": "chat_sessionSection", + "value": "会话" + }, + { + "name": "chat_pin", + "value": "置顶" + }, + { + "name": "chat_unpin", + "value": "取消置顶" + }, + { + "name": "chat_uploadedFiles", + "value": "已上传的文件" + }, + { + "name": "chat_stop", + "value": "停止" + }, + { + "name": "chat_send", + "value": "发送" + }, + { + "name": "chat_voiceInput", + "value": "语音输入" + }, + { + "name": "chat_stopListening", + "value": "停止听写" + }, + { + "name": "chat_addImage", + "value": "添加图片" + }, + { + "name": "chat_removeImage", + "value": "移除图片" + }, + { + "name": "chat_selectModel", + "value": "选择模型" + }, + { + "name": "chat_modelPrimary", + "value": "主力模型" + }, + { + "name": "chat_modelFast", + "value": "快速模型" + }, + { + "name": "chat_modelFastNotConfigured", + "value": "未配置时使用主力模型" + }, + { + "name": "chat_modelRolePrimary", + "value": "主力" + }, + { + "name": "chat_modelRoleFast", + "value": "快速" + }, + { + "name": "chat_loadOlder", + "value": "加载更早消息" + }, + { + "name": "chat_loadOlderFailed", + "value": "加载更早消息失败,点按重试。" + }, + { + "name": "chat_inputPlaceholder", + "value": "向 OpenBitFun 提问" + }, + { + "name": "chat_voiceListeningPlaceholder", + "value": "正在听,请说话..." + }, + { + "name": "chat_queuedAfterRunningTurn", + "value": "已排队,当前任务结束后执行" + }, + { + "name": "chat_image", + "value": "图片" + }, + { + "name": "chat_imageUnavailable", + "value": "图片未同步到此设备" + }, + { + "name": "chat_thinkingRunning", + "value": "运行中" + }, + { + "name": "chat_thinkingDone", + "value": "已完成" + }, + { + "name": "chat_thinkingInProgress", + "value": "正在思考" + }, + { + "name": "chat_thinkingComplete", + "value": "已思考" + }, + { + "name": "chat_toolCalls", + "value": "调用工具" + }, + { + "name": "chat_toolsRan", + "value": "已运行 %1$s 个工具" + }, + { + "name": "chat_toolOpTodoWrite", + "value": "更新任务列表" + }, + { + "name": "chat_toolOpTask", + "value": "发起任务" + }, + { + "name": "chat_toolOpRead", + "value": "读取文件" + }, + { + "name": "chat_toolOpWrite", + "value": "写入文件" + }, + { + "name": "chat_toolOpDelete", + "value": "删除文件" + }, + { + "name": "chat_toolOpDiff", + "value": "查看变更" + }, + { + "name": "chat_toolOpEdit", + "value": "修改文件" + }, + { + "name": "chat_toolOpCommand", + "value": "运行命令" + }, + { + "name": "chat_toolOpWebSearch", + "value": "网页搜索" + }, + { + "name": "chat_toolOpWebFetch", + "value": "访问网页" + }, + { + "name": "chat_toolOpSearch", + "value": "搜索代码" + }, + { + "name": "chat_toolOpAskUser", + "value": "请求确认" + }, + { + "name": "chat_toolTodoFallback", + "value": "任务列表" + }, + { + "name": "chat_toolTaskFallback", + "value": "子任务" + }, + { + "name": "chat_subagentOutput", + "value": "子任务输出" + }, + { + "name": "chat_toolFailed", + "value": "%1$s失败" + }, + { + "name": "chat_toolRunning", + "value": "正在运行" + }, + { + "name": "chat_showMore", + "value": "查看更多" + }, + { + "name": "chat_showLess", + "value": "收起" + }, + { + "name": "chat_processing", + "value": "正在处理" + }, + { + "name": "chat_sendFailed", + "value": "发送失败,请检查连接后重试" + }, + { + "name": "chat_responseFailed", + "value": "本次回复失败" + }, + { + "name": "chat_error", + "value": "错误" + }, + { + "name": "chat_output", + "value": "输出" + }, + { + "name": "chat_interactionLoadFailed", + "value": "待处理交互加载失败" + }, + { + "name": "chat_approvalFailed", + "value": "提交失败,请重试" + }, + { + "name": "chat_approvalRequired", + "value": "需要批准" + }, + { + "name": "chat_editApprovalInput", + "value": "编辑参数" + }, + { + "name": "chat_hideApprovalInput", + "value": "收起参数" + }, + { + "name": "chat_approve", + "value": "批准" + }, + { + "name": "chat_reject", + "value": "拒绝" + }, + { + "name": "chat_cancelTool", + "value": "取消工具" + }, + { + "name": "chat_planView", + "value": "查看计划" + }, + { + "name": "chat_planBuild", + "value": "构建 Plan" + }, + { + "name": "chat_planBuildUnavailable", + "value": "请更新桌面端后再从手机构建 Plan。" + }, + { + "name": "chat_planBuildWaitForTurn", + "value": "请等待当前任务结束后再构建这个 Plan。" + }, + { + "name": "chat_toolInput", + "value": "工具输入" + }, + { + "name": "chat_reset", + "value": "重置" + }, + { + "name": "chat_editJsonInput", + "value": "编辑 JSON 输入" + }, + { + "name": "chat_answerPlaceholder", + "value": "输入回答" + }, + { + "name": "chat_submitAnswer", + "value": "提交回答" + }, + { + "name": "chat_emptySyncing", + "value": "正在同步" + }, + { + "name": "chat_emptyConnectionError", + "value": "连接异常" + }, + { + "name": "chat_emptyReconnecting", + "value": "正在恢复连接" + }, + { + "name": "chat_emptyNoMessages", + "value": "暂无消息" + }, + { + "name": "chat_emptyLoadingMessages", + "value": "正在加载桌面端会话消息。" + }, + { + "name": "chat_emptyReconnectFromHome", + "value": "请返回工作台重新连接桌面端。" + }, + { + "name": "chat_emptyReconnectSync", + "value": "恢复后会继续同步桌面端消息。" + }, + { + "name": "chat_emptyFirstMessage", + "value": "发送第一条指令后,桌面端响应会显示在这里。" + }, + { + "name": "chat_executing", + "value": "OpenBitFun 正在执行..." + }, + { + "name": "chat_model", + "value": "模型" + }, + { + "name": "chat_openbitfunProcessing", + "value": "OpenBitFun 正在处理..." + }, + { + "name": "chat_updatedSession", + "value": "OpenBitFun 已更新会话" + }, + { + "name": "chat_desktopFile", + "value": "桌面端文件" + }, + { + "name": "chat_fileLink", + "value": "文件链接" + }, + { + "name": "chat_reading", + "value": "读取中" + }, + { + "name": "chat_download", + "value": "下载" + }, + { + "name": "filePreview_title", + "value": "文件预览" + }, + { + "name": "filePreview_loading", + "value": "正在读取文件" + }, + { + "name": "filePreview_offline", + "value": "离线,仅显示已加载内容" + }, + { + "name": "filePreview_fitImage", + "value": "适应窗口" + }, + { + "name": "filePreview_actualImageSize", + "value": "原始大小" + }, + { + "name": "filePreview_expand", + "value": "放大预览" + }, + { + "name": "filePreview_restoreSize", + "value": "还原预览" + }, + { + "name": "filePreview_imageDecodeFailed", + "value": "图片无法解码,请重试或下载文件。" + }, + { + "name": "filePreview_loadFailed", + "value": "无法打开文件" + }, + { + "name": "filePreview_notFound", + "value": "文件不存在或已被移动" + }, + { + "name": "filePreview_unavailable", + "value": "文件不存在或不在当前工作区" + }, + { + "name": "filePreview_accessDenied", + "value": "无法访问工作区外的文件" + }, + { + "name": "filePreview_tooLarge", + "value": "文件过大,无法在移动端预览" + }, + { + "name": "filePreview_unsupported", + "value": "此文件暂不支持预览" + }, + { + "name": "filePreview_truncated", + "value": "已显示前 %1$s,下载后可查看完整内容" + }, + { + "name": "chat_pendingConfirmation", + "value": "待确认" + }, + { + "name": "chat_cancelled", + "value": "已取消" + }, + { + "name": "chat_jsonObjectRequired", + "value": "请输入 JSON 对象" + }, + { + "name": "chat_jsonInvalid", + "value": "JSON 格式错误" + }, + { + "name": "chat_questionDefault", + "value": "桌面端需要你的回答。" + }, + { + "name": "chat_analyzeImageDefault", + "value": "请分析这张图片。" + }, + { + "name": "status_waitingConnection", + "value": "等待连接" + }, + { + "name": "status_notConnected", + "value": "未连接" + }, + { + "name": "status_restoringConnection", + "value": "正在恢复连接" + }, + { + "name": "status_parsingUrl", + "value": "解析远程 URL" + }, + { + "name": "status_pairing", + "value": "正在配对" + }, + { + "name": "status_reconnecting", + "value": "正在重新连接" + }, + { + "name": "status_pairingCleared", + "value": "已清除配对信息" + }, + { + "name": "status_disconnected", + "value": "已断开连接" + }, + { + "name": "status_remoteUnavailable", + "value": "连接不可用,请先重新连接" + }, + { + "name": "status_loadingRecentWorkspaces", + "value": "加载最近工作区" + }, + { + "name": "status_chooseWorkspace", + "value": "请选择工作区" + }, + { + "name": "status_noRecentWorkspaces", + "value": "没有最近工作区" + }, + { + "name": "status_switchingWorkspace", + "value": "正在切换工作区" + }, + { + "name": "status_workspaceSwitched", + "value": "工作区已切换" + }, + { + "name": "status_loadingAssistants", + "value": "加载助理工作区" + }, + { + "name": "status_chooseAssistant", + "value": "请选择助理" + }, + { + "name": "status_noAssistants", + "value": "没有可用助理" + }, + { + "name": "status_switchingAssistant", + "value": "正在切换助理" + }, + { + "name": "status_assistantSwitched", + "value": "助理已切换" + }, + { + "name": "status_refreshSessions", + "value": "刷新会话列表" + }, + { + "name": "status_loadMoreSessions", + "value": "加载更多会话" + }, + { + "name": "status_sessionsUpdated", + "value": "会话列表已更新" + }, + { + "name": "status_createSession", + "value": "创建远程会话" + }, + { + "name": "status_sessionCreated", + "value": "会话已创建" + }, + { + "name": "status_loadMessages", + "value": "加载会话消息" + }, + { + "name": "status_deletingSession", + "value": "正在删除会话" + }, + { + "name": "status_sessionDeleted", + "value": "会话已删除" + }, + { + "name": "status_sessionArchived", + "value": "会话已归档" + }, + { + "name": "status_sessionUnarchived", + "value": "会话已取消归档" + }, + { + "name": "status_sessionExported", + "value": "已复制为 Markdown" + }, + { + "name": "status_messagesSynced", + "value": "消息已同步" + }, + { + "name": "status_messagesRestored", + "value": "已恢复本地会话记录" + }, + { + "name": "status_switchingModel", + "value": "正在切换模型" + }, + { + "name": "status_modelSwitched", + "value": "模型已切换" + }, + { + "name": "status_loadOlderMessages", + "value": "加载更早消息" + }, + { + "name": "status_sendImage", + "value": "发送图片" + }, + { + "name": "status_sendCommand", + "value": "发送指令" + }, + { + "name": "status_sentWaiting", + "value": "已发送,等待桌面端响应" + }, + { + "name": "status_pickImage", + "value": "选择图片" + }, + { + "name": "status_noImageSelected", + "value": "未选择图片" + }, + { + "name": "status_imagesSelected", + "value": "已选择 %1$s 张图片" + }, + { + "name": "status_voiceStarting", + "value": "启动语音输入" + }, + { + "name": "status_voiceListening", + "value": "正在语音输入" + }, + { + "name": "status_voiceRecognized", + "value": "已填入语音识别文本" + }, + { + "name": "status_voiceStopped", + "value": "已停止语音输入" + }, + { + "name": "status_noRunningTask", + "value": "当前没有正在执行的任务" + }, + { + "name": "status_stoppingTask", + "value": "正在停止任务" + }, + { + "name": "status_stopRequested", + "value": "已请求停止" + }, + { + "name": "status_updatingTitle", + "value": "正在更新会话标题" + }, + { + "name": "status_titleUpdated", + "value": "会话标题已更新" + }, + { + "name": "status_messageCopied", + "value": "消息已复制" + }, + { + "name": "status_fileInfo", + "value": "读取文件信息" + }, + { + "name": "status_prepareDownload", + "value": "准备下载 %1$s" + }, + { + "name": "status_downloadDone", + "value": "下载完成 %1$s · %2$s" + }, + { + "name": "status_approvingTool", + "value": "正在批准工具调用" + }, + { + "name": "status_toolApproved", + "value": "已批准工具调用" + }, + { + "name": "status_rejectingTool", + "value": "正在拒绝工具调用" + }, + { + "name": "status_toolRejected", + "value": "已拒绝工具调用" + }, + { + "name": "status_cancellingTool", + "value": "正在取消工具调用" + }, + { + "name": "status_toolCancelRequested", + "value": "已请求取消工具调用" + }, + { + "name": "status_submittingAnswer", + "value": "正在提交回答" + }, + { + "name": "status_answerSubmitted", + "value": "回答已提交" + }, + { + "name": "status_desktopProcessing", + "value": "桌面端正在处理" + }, + { + "name": "errors_tooManyAttempts", + "value": "尝试次数过多,请 %1$s 秒后再试。" + }, + { + "name": "errors_userIdRequired", + "value": "请输入用户 ID。" + }, + { + "name": "errors_accountUsernameRequired", + "value": "请输入 OpenBitFun 用户名。" + }, + { + "name": "errors_accountPasswordRequired", + "value": "请使用邮箱或 GitHub 登录以继续。" + }, + { + "name": "errors_remoteDataInvalid", + "value": "中继返回了无法解析的数据,请确认桌面端和移动端版本匹配。" + }, + { + "name": "errors_pairRejected", + "value": "中继拒绝了旧的配对连接,请登录账号后从设备列表选择电脑。" + }, + { + "name": "errors_roomNotFound", + "value": "没有找到这个远程房间,旧的扫码连接可能已过期,请登录账号后从设备列表选择电脑。" + }, + { + "name": "errors_relayTimeout", + "value": "连接中继超时,请检查网络后重试。" + }, + { + "name": "errors_rateLimited", + "value": "请求过于频繁,请稍后再试。" + }, + { + "name": "errors_relayUnavailable", + "value": "中继服务暂时不可用(HTTP %1$s),请稍后重试。" + }, + { + "name": "errors_relayHttp", + "value": "中继返回 HTTP %1$s,请重新配对后再试。" + }, + { + "name": "errors_relayTimeoutDetail", + "value": "连接中继超时,请确认手机和桌面端网络可访问。" + }, + { + "name": "errors_relayNetwork", + "value": "无法连接到中继服务,请检查远程链接、网络或代理设置。" + }, + { + "name": "errors_remoteConnectFailed", + "value": "远程连接失败,请检查网络后重试。" + }, + { + "name": "errors_remoteUrlRequired", + "value": "请输入桌面端远程连接 URL。" + }, + { + "name": "errors_remoteUrlMissingParams", + "value": "远程 URL 缺少 room 或 pk 参数。" + }, + { + "name": "errors_remoteUrlInvalid", + "value": "远程连接链接格式不正确,请登录账号后从设备列表选择电脑。" + }, + { + "name": "errors_permissionDenied", + "value": "没有获得所需权限,请在系统设置中允许后重试。" + }, + { + "name": "errors_operationFailed", + "value": "操作失败,请稍后重试。" + }, + { + "name": "errors_payloadTooLarge", + "value": "这条消息带的附件超过了中继允许的大小,请减少图片数量或选择更小的图片。" + }, + { + "name": "errors_workspaceIdUnsupported", + "value": "已连接的桌面端不支持按工作区 ID 引用。请升级桌面端后再打开该工作区。" + }, + { + "name": "errors_hostStreamUnsupported", + "value": "已连接的桌面端 OpenBitFun 版本过旧,不支持按需会话流。请升级该设备上的 OpenBitFun 后继续。" + }, + { + "name": "errors_imageTooLargeAfterCompression", + "value": "图片压缩后仍超过 %1$s MB,请选择较小图片。" + }, + { + "name": "errors_imageCompressionFailed", + "value": "图片压缩失败,请选择较小图片。" + }, + { + "name": "errors_voicePermissionDenied", + "value": "没有获得麦克风权限,无法语音输入。" + }, + { + "name": "errors_voiceInputUnavailable", + "value": "语音识别暂不可用,请稍后重试。" + }, + { + "name": "errors_voiceInputFailed", + "value": "语音识别失败(%1$s),请稍后重试。" + }, + { + "name": "watchProvision_title", + "value": "在这块手表上使用邮箱或 GitHub 登录?" + }, + { + "name": "watchProvision_deviceId", + "value": "设备编号 %1$s" + }, + { + "name": "watchProvision_body", + "value": "同意后,手表会登录你的账号并可以连接桌面端。请确认这块手表就在你手里。" + }, + { + "name": "watchProvision_approve", + "value": "允许" + }, + { + "name": "watchProvision_reject", + "value": "拒绝" + }, + { + "name": "watchProvision_gotIt", + "value": "知道了" + }, + { + "name": "watchProvision_working", + "value": "正在为手表开通账号..." + }, + { + "name": "watchProvision_passwordBody", + "value": "需要再确认一次账号密码,才能给这块手表创建独立的登录凭证。密码只会用于本次验证。" + }, + { + "name": "watchProvision_passwordPlaceholder", + "value": "账号密码" + }, + { + "name": "watchProvision_passwordConfirm", + "value": "确认" + }, + { + "name": "watchProvision_doneBody", + "value": "%1$s 已登录,可以在手表上使用 OpenBitFun 了。" + }, + { + "name": "watchProvision_rejected", + "value": "已在手机上拒绝。" + }, + { + "name": "watchProvision_busy", + "value": "手机正在处理另一台设备的请求,请稍后再试。" + }, + { + "name": "watchProvision_errors_noDesktop", + "value": "请先在手机上使用邮箱或 GitHub 登录,或连接已登录同一账号的桌面端。" + }, + { + "name": "watchProvision_errors_accountUnavailable", + "value": "暂时无法验证账号,请检查手机网络后重试。" + }, + { + "name": "watchProvision_errors_desktopUnreachable", + "value": "暂时无法连接桌面端,请确认桌面端在线后重试。" + }, + { + "name": "watchProvision_errors_desktopAuthorizationFailed", + "value": "桌面端没能完成手表登录,请确认桌面端已登录同一 OpenBitFun 账号。" + }, + { + "name": "watchProvision_errors_passwordFailed", + "value": "账号验证失败,请检查密码或网络后重试。" + }, + { + "name": "watchProvision_errors_handoffFailed", + "value": "授权已完成,但没能把凭证发给手表,请在手表上重试一次。" + }, + { + "name": "ui_emptyMessage", + "value": "(空消息)" + }, + { + "name": "ui_desktopProcessing", + "value": "桌面端正在处理..." + }, + { + "name": "ui_seedConnected", + "value": "%1$s 已连接。你可以继续发送指令,桌面端会在 OpenBitFun 中执行。" + }, + { + "name": "time_justNow", + "value": "刚刚" + }, + { + "name": "time_unknown", + "value": "时间未知" + }, + { + "name": "time_minutesAgo", + "value": "%1$s 分钟前" + }, + { + "name": "time_hoursAgo", + "value": "%1$s 小时前" + }, + { + "name": "time_daysAgo", + "value": "%1$s 天前" } ] } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets index 1acb90f914..7dda25bc74 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -698,12 +698,16 @@ export default function conversationPresentationUnitTest() { expect(REMOTE_CHAT_COMPOSER_CAPABILITIES.supportsAttachments).assertTrue(); }); - it('keeps voice reachable beside Send once a draft has content', 0, () => { + it('keeps voice reachable beside Send while a draft holds the collapsed field', 0, () => { expect(ChatComposerPolicy.shouldShowSupplementalVoice('继续补充', 0, false, true)).assertTrue(); expect(ChatComposerPolicy.shouldShowSupplementalVoice('', 1, false, true)).assertTrue(); expect(ChatComposerPolicy.shouldShowSupplementalVoice('', 0, false, true)).assertFalse(); expect(ChatComposerPolicy.shouldShowSupplementalVoice('继续补充', 0, true, true)).assertFalse(); expect(ChatComposerPolicy.shouldShowSupplementalVoice('继续补充', 0, false, false)).assertFalse(); + // The slot is the draft's second way into dictation, never its only one: + // an empty field still reaches the microphone from the primary slot. + expect(ChatComposerPolicy.primaryAction('', 0, false, false, false, true, true, 'connected', true)) + .assertEqual(ComposerPrimaryAction.Voice); expect(ChatComposerPolicy.canUseSupplementalVoice(false)).assertTrue(); expect(ChatComposerPolicy.canUseSupplementalVoice(true)).assertFalse(); }); diff --git a/src/apps/mobile/harmonyos/tools/tests/composer-content-affordance.test.cjs b/src/apps/mobile/harmonyos/tools/tests/composer-content-affordance.test.cjs new file mode 100644 index 0000000000..11e7cd12f2 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/composer-content-affordance.test.cjs @@ -0,0 +1,118 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +function load(relativePath) { + const source = fs.readFileSync(path.join(__dirname, '../..', relativePath), 'utf8'); + const js = ts.transpileModule(source, { + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS} + }).outputText; + const exported = {}; + new Function('require', 'exports', js)(() => ({}), exported); + return exported; +} + +const {ChatComposerPolicy: Policy, ComposerPrimaryAction} = load('entry/src/main/ets/services/ChatComposerPolicy.ets'); +const composerSource = fs.readFileSync( + path.join(__dirname, '../../entry/src/main/ets/pages/components/ComposerBar.ets'), 'utf8'); + +// The composer input is the TextArea in InputField(). Slicing from its +// constructor to its first handler keeps these assertions off the model rows and +// role rows, which legitimately ellipsize their own labels. +function composerInputField(source) { + const start = source.indexOf('TextArea({ placeholder: this.inputPlaceholder()'); + assert.notEqual(start, -1, 'the composer input must stay a TextArea'); + const end = source.indexOf('.onChange(', start); + assert.notEqual(end, -1, 'the composer input must keep its onChange handler'); + return source.slice(start, end); +} + +// Reads one chained call out of the field so the assertions survive rewrapping. +function call(source, name) { + const start = source.indexOf(`.${name}(`); + assert.notEqual(start, -1, `the composer input must keep its .${name}() call`); + let depth = 0; + for (let index = start + name.length + 1; index < source.length; index++) { + if (source[index] === '(') { + depth += 1; + } else if (source[index] === ')') { + depth -= 1; + if (depth === 0) { + return source.slice(start, index + 1).replace(/\s+/g, ' '); + } + } + } + throw new Error(`unbalanced .${name}() call`); +} + +// Reads one @Builder out of the component, so an assertion can name the row it +// is about instead of counting occurrences in the whole file. +function builderBody(source, name) { + const start = source.indexOf(`\n ${name}() {`); + assert.notEqual(start, -1, `the composer must keep its ${name}() builder`); + const end = source.indexOf('\n @Builder', start); + assert.notEqual(end, -1, `${name}() must be followed by another builder`); + return source.slice(start, end); +} + +function primaryAction(text, attachments, busy, listening, turnRunning) { + return Policy.primaryAction(text, attachments, busy, listening, turnRunning, true, true, 'connected', true); +} + +test('a draft still earns the supplemental microphone beside Send', () => { + // The expanded action row is where a draft keeps its second way into + // dictation: any text or any image asks for the slot, while an empty field + // does not need it and a listening or voice-less surface must not show it. + assert.equal(Policy.shouldShowSupplementalVoice('继续补充', 0, false, true), true); + assert.equal(Policy.shouldShowSupplementalVoice(' ', 0, false, true), false); + assert.equal(Policy.shouldShowSupplementalVoice('', 1, false, true), true); + assert.equal(Policy.shouldShowSupplementalVoice('继续补充', 0, true, true), false); + assert.equal(Policy.shouldShowSupplementalVoice('继续补充', 0, false, false), false); +}); + +test('an empty field keeps reaching dictation from the primary slot', () => { + // The pending slot is never the only way in; that is what the empty field's + // primary action provides. + assert.equal(primaryAction('', 0, false, false, false), ComposerPrimaryAction.Voice); + assert.equal(primaryAction('', 0, true, false, false), ComposerPrimaryAction.VoiceBlocked); + assert.equal(primaryAction('继续补充', 0, false, false, false), ComposerPrimaryAction.Send); + assert.equal(primaryAction('', 1, false, false, false), ComposerPrimaryAction.Send); + assert.equal(primaryAction('', 0, false, false, true), ComposerPrimaryAction.Stop); +}); + +test('the collapsed input truncates with an ellipsis instead of scrolling', () => { + const field = composerInputField(composerSource); + assert.equal(call(field, 'textOverflow'), + '.textOverflow(this.isComposerExpanded() ? TextOverflow.Clip : TextOverflow.Ellipsis)'); + assert.equal(call(field, 'maxLines'), + '.maxLines(this.isComposerExpanded() ? 4 : 1, ' + + '{ overflowMode: this.isComposerExpanded() ? MaxLinesMode.SCROLL : MaxLinesMode.CLIP })'); +}); + +test('a collapsed draft keeps the field to itself', () => { + // The collapsed field is one line tall and the draft has the stronger claim + // on it, so nothing inside the field may mount a second microphone. Without + // this the restored policy would put one back beside the truncated text. + const field = builderBody(composerSource, 'InputField'); + assert.equal(field.indexOf('this.SupplementalVoiceButton()'), -1, + 'the input field must not render the supplemental voice slot'); + + const sites = [...composerSource.matchAll(/this\.SupplementalVoiceButton\(\)/g)]; + assert.equal(sites.length, 1, + 'the expanded action row is the only supplemental voice render site'); +}); + +test('the expanded action row keeps the supplemental microphone behind the policy', () => { + const composer = builderBody(composerSource, 'AdaptiveComposer'); + const expanded = composer.indexOf('if (this.isComposerExpanded()) {'); + assert.notEqual(expanded, -1, 'the composer must keep its expanded action row'); + const row = composer.slice(expanded); + const site = row.indexOf('this.SupplementalVoiceButton()'); + assert.notEqual(site, -1, + 'the expanded action row must render the supplemental voice slot once the draft has content'); + assert.match(row.slice(Math.max(0, site - 200), site), + /if \(this\.shouldShowSupplementalVoice\(\)/, + 'the expanded supplemental voice render site must stay gated by the composer policy'); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/file-visibility.test.cjs b/src/apps/mobile/harmonyos/tools/tests/file-visibility.test.cjs new file mode 100644 index 0000000000..3441073bd4 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/file-visibility.test.cjs @@ -0,0 +1,533 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const ROOT = path.join(__dirname, '../../entry/src/main/ets'); +const cache = new Map(); + +/** + * The two platform primitives the file chain really calls. + * + * `Encoding.ets` detaches bytes with `new Uint8Array(value.buffer)` and expects + * that buffer to hold only the value's own bytes, so the shim hands back an + * exactly sized ArrayBuffer instead of Node's pooled Buffer. A default import of + * a kit is compiled to `kit.default.member`, so each stub answers both shapes. + */ +function platformStub(module) { + return Object.assign({ default: module }, module); +} + +const PLATFORM = { + '@ohos.buffer': platformStub({ + from(value, encoding) { + const bytes = typeof value === 'string' + ? Uint8Array.from(Buffer.from(value, encoding || 'utf8')) + : Uint8Array.from(value); + return { + buffer: bytes.buffer, + toString: (enc) => Buffer.from(bytes).toString(enc || 'utf8') + }; + } + }), + '@kit.PerformanceAnalysisKit': platformStub({ + hilog: { info() {}, warn() {}, error() {}, debug() {} } + }) +}; + +/** Loads one .ets module and resolves its relative imports from the source tree. */ +function load(relative) { + if (cache.has(relative)) return cache.get(relative); + const source = fs.readFileSync(path.join(ROOT, relative + '.ets'), 'utf8') + .replace(/@ObservedV2\s*/g, '').replace(/@Trace\s*/g, ''); + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText; + const exported = {}; + cache.set(relative, exported); + new Function('require', 'exports', js)(name => { + if (!name.startsWith('.')) return PLATFORM[name] || {}; + const target = path.relative(ROOT, path.resolve(path.join(ROOT, relative), '..', name)) + .split(path.sep).join('/'); + return fs.existsSync(path.join(ROOT, `${target}.ets`)) ? load(target) : {}; + }, exported); + return exported; +} + +const { MarkdownParser } = load('services/MarkdownParser'); +const { RemoteUiState } = load('services/RemoteUiState'); +const { FileTargetResolver, FileReferenceKind } = load('pages/policy/FileTargetResolver'); +const { MessageFileReferenceProjector } = load('pages/policy/MessageFileReferenceProjector'); +const { RemoteFilePreviewController } = load('pages/viewmodel/RemoteFilePreviewController'); +const { FilePreviewPolicy } = load('services/FilePreviewPolicy'); +const { FilePreviewPhase, FilePreviewRendererKind, FilePreviewState } = load('pages/state/FilePreviewState'); +const { FilePreviewTarget, FilePreviewTargetContext } = load('model/FilePreviewTarget'); +const { RemoteCommandFactory } = load('services/RemoteCommandFactory'); +const { FilePreviewController } = load('pages/viewmodel/FilePreviewController'); + +const SESSION = 'session-1'; +const WORKSPACE = '/home/user/project'; + +/** + * Writes the link shapes the host actually produces into an assistant answer. + * + * `remote_file_delivery.rs:16` tells the model to write + * `[report.md](computer://artifacts/report.md)`; the bare form is what the model + * writes when it names the file again in prose. Four references is the whole + * card budget of one message, so this answer is exactly at the cap. + */ +const ANSWER = [ + 'Generated the brand asset and the report.', + '', + '- [openbitfun-wordmark.png](computer://png/openbitfun-wordmark.png)', + '- [REPORT.md](computer://artifacts/REPORT.md)', + '- [bundle.zip](computer://artifacts/bundle.zip)', + '', + 'The plain-text log is at computer://artifacts/run.log and the raw export is png/openbitfun-wordmark.png.', + '', + '' +].join('\n'); + +function projections(source, limit) { + return limit === undefined + ? MessageFileReferenceProjector.project(source) + : MessageFileReferenceProjector.project(source, limit); +} + +function resolve(reference, label = '') { + return FileTargetResolver.resolve( + reference, + label, + new FilePreviewTargetContext(SESSION, WORKSPACE, 1) + ); +} + +/* ------------------------------------------------------------------ stage 1 */ + +test('stage 1: the parser reads explicit links and bare file references as links', () => { + const blocks = MarkdownParser.parse(ANSWER); + const links = blocks.flatMap(block => block.inlines.concat( + block.items.flatMap(item => item.inlines) + )).filter(inline => inline.type === 'link'); + + const urls = links.map(link => link.url); + assert.ok(urls.includes('computer://png/openbitfun-wordmark.png'), + 'an explicit markdown link keeps its computer:// url'); + assert.ok(urls.includes('computer://artifacts/run.log'), + 'a bare computer:// reference in prose is an autolink'); + assert.ok(urls.includes('computer://artifacts/diagram.svg'), + 'an angle-bracket autolink keeps its computer:// url'); + assert.ok(!urls.includes('png/photo.jpg'), + 'a bare relative file name carries no scheme, so it is plain text'); +}); + +/* ------------------------------------------------------------------ stage 2 */ + +test('stage 2: a computer:// link becomes a RemoteWorkspaceFile card', () => { + const reference = projections('[openbitfun-wordmark.png](computer://png/openbitfun-wordmark.png)')[0]; + assert.ok(reference, 'the linked png is projected'); + assert.equal(reference.path, 'computer://png/openbitfun-wordmark.png'); + assert.equal(reference.remotePath, 'png/openbitfun-wordmark.png'); + assert.equal(reference.label, 'openbitfun-wordmark.png'); +}); + +test('stage 2: every file type the assistant links earns a card', () => { + const byPath = new Map(projections(ANSWER).map(item => [item.remotePath, item])); + for (const remotePath of [ + 'png/openbitfun-wordmark.png', + 'artifacts/REPORT.md', + 'artifacts/bundle.zip', + 'artifacts/run.log' + ]) { + assert.ok(byPath.has(remotePath), `a card is projected for ${remotePath}`); + } + assert.equal(byPath.get('png/openbitfun-wordmark.png').label, 'openbitfun-wordmark.png', + 'the card is named by the link label'); +}); + +test('stage 2: the card budget is bounded, and the surplus keeps its link', () => { + const source = '- [a.png](computer://artifacts/a.png)\n- [b.md](computer://artifacts/b.md)\n' + + '- [c.zip](computer://artifacts/c.zip)\n- [d.txt](computer://artifacts/d.txt)\n' + + '- [e.png](computer://artifacts/e.png)'; + const references = projections(source); + assert.equal(references.length, 4, 'one message draws at most four cards'); + assert.deepEqual(references.map(item => item.remotePath), [ + 'artifacts/a.png', 'artifacts/b.md', 'artifacts/c.zip', 'artifacts/d.txt' + ]); + assert.equal(projections(source, 1).length, 1, 'the limit is a parameter, not a constant of the answer'); +}); + +test('stage 2: a relative link is a card only for an output-shaped extension', () => { + // A bare path in prose carries no scheme, so it is never resolved (see below). + // A *relative link target* is different: the projector accepts it when its + // extension is one of the turn's output shapes, which is the same rule the + // shared Android/iOS projector applies (MessageFileReferences.kt:38). + assert.equal(projections('[Preview](artifacts/preview.png)')[0].remotePath, 'artifacts/preview.png', + 'a relative image link is a workspace file'); + assert.equal(projections('[Bundle](artifacts/bundle.zip)')[0].remotePath, 'artifacts/bundle.zip'); + assert.equal(projections('[Report](artifacts/REPORT.md)').length, 0, + 'a relative markdown link is not an output shape, so it stays prose'); + assert.equal(projections('[Log](artifacts/run.log)').length, 0, + 'a relative log link is not an output shape either'); +}); + +test('stage 2: the card path and card label stay in step with the resolver', () => { + for (const reference of [ + 'computer://png/openbitfun-wordmark.png', + 'computer://artifacts/REPORT.md', + 'file:///home/user/project/artifacts/notes.txt', + 'computer://D:/work/project/artifacts/notes.txt' + ]) { + const resolution = resolve(reference, 'label.md'); + assert.equal(resolution.kind, FileReferenceKind.RemoteWorkspaceFile, `${reference} resolves`); + assert.equal(resolution.target.rawReference, reference, 'the raw reference is preserved'); + assert.equal(resolution.target.remotePath, RemoteUiState.normalizeRemoteFilePath(reference), + 'the resolved path is the normalized reference'); + } +}); + +test('stage 2: a scheme-less relative path is not a card', () => { + assert.equal(projections('the photo is png/photo.jpg').length, 0, + 'a bare file name cannot be resolved against the host, so it stays plain text'); +}); + +/* ------------------------------------------------------------------ stage 3 */ + +test('stage 3: the read command carries the normalized workspace-relative path', () => { + const target = resolve('computer://png/openbitfun-wordmark.png', 'openbitfun-wordmark.png').target; + const info = RemoteCommandFactory.getFileInfo(target.remotePath, target.sessionId); + assert.equal(info.cmd, 'get_file_info'); + assert.equal(info.path, 'png/openbitfun-wordmark.png'); + assert.equal(info.session_id, SESSION); + + const chunk = RemoteCommandFactory.readFileChunk(target.remotePath, 0, 1024, target.sessionId); + assert.equal(chunk.cmd, 'read_file_chunk'); + assert.equal(chunk.path, 'png/openbitfun-wordmark.png'); + assert.equal(chunk.session_id, SESSION); +}); + +test('stage 3: the preview target keeps the session the file must be read from', () => { + const target = resolve('computer://png/openbitfun-wordmark.png').target; + assert.ok(target.isValid(), 'a target with a session and a path is openable'); + const anonymous = resolve('computer://png/x.png').target; + assert.equal(anonymous.sessionId, SESSION, 'the resolver takes the session from its context'); +}); + +test('stage 3: normalizeRemoteFilePath only strips the schemes the host sends', () => { + assert.equal(RemoteUiState.normalizeRemoteFilePath('computer://png/a.png'), 'png/a.png'); + assert.equal(RemoteUiState.normalizeRemoteFilePath('file:///home/u/a.png'), '/home/u/a.png'); + assert.equal(RemoteUiState.normalizeRemoteFilePath('computer://D:/work/a.png'), 'D:/work/a.png'); + assert.equal(RemoteUiState.normalizeRemoteFilePath('png/a.png'), 'png/a.png'); +}); + +/* ------------------------------------------------------------------ stage 4 */ + +const MIME = { + png: 'image/png', + jpg: 'image/jpeg', + md: 'text/markdown', + txt: 'text/plain', + zip: 'application/zip', + pdf: 'application/pdf' +}; + +function nameOf(remotePath) { + return remotePath.split('/').pop(); +} + +/** A host that answers exactly like `remote_connect.rs` does for a real file. */ +class FakeFileHost { + constructor(files) { + this.files = files; + this.commands = []; + } + async getFileInfo(path, sessionId) { + this.commands.push({ cmd: 'get_file_info', path, sessionId }); + const file = this.files[path]; + if (!file) throw new Error(`File not found: ${path}`); + return { name: nameOf(path), size: file.size, mimeType: MIME[file.kind] }; + } + async readFileChunk(path, offset, limit, sessionId) { + this.commands.push({ cmd: 'read_file_chunk', path, offset, limit, sessionId }); + const file = this.files[path]; + if (!file) throw new Error(`File not found: ${path}`); + const end = Math.min(file.size, offset + limit); + return { + name: nameOf(path), + contentBase64: Buffer.from(file.bytes.slice(offset, end)).toString('base64'), + revision: '1:1', + offset, + chunkSize: end - offset, + totalSize: file.size, + mimeType: MIME[file.kind] + }; + } + async readFile(path, sessionId, onProgress, maxBytes) { + const file = this.files[path]; + if (!file) throw new Error(`File not found: ${path}`); + if (maxBytes !== undefined && file.size > maxBytes) throw new Error('File too large for preview.'); + const info = await this.getFileInfo(path, sessionId); + return { + name: info.name, + mimeType: info.mimeType, + size: info.size, + contentBase64: Buffer.from(file.bytes).toString('base64') + }; + } + async streamFile(path, sessionId, onChunk) { + const file = this.files[path]; + if (!file) throw new Error(`File not found: ${path}`); + const info = await this.getFileInfo(path, sessionId); + await onChunk(new Uint8Array(file.bytes), { name: info.name, size: info.size, mimeType: info.mimeType }); + return { name: info.name, size: info.size, mimeType: info.mimeType }; + } +} + +const PNG_BYTES = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', + 'base64' +); + +function fixtureFiles() { + return { + 'png/openbitfun-wordmark.png': { kind: 'png', size: PNG_BYTES.length, bytes: PNG_BYTES }, + 'png/photo.jpg': { kind: 'jpg', size: PNG_BYTES.length, bytes: PNG_BYTES }, + 'artifacts/REPORT.md': { kind: 'md', size: 21, bytes: Buffer.from('# Report\nhello md\n') }, + 'artifacts/notes.txt': { kind: 'txt', size: 11, bytes: Buffer.from('plain notes') }, + 'artifacts/bundle.zip': { kind: 'zip', size: 8, bytes: Buffer.from([0x50, 0x4b, 3, 4, 0, 0, 0, 0]) }, + 'artifacts/paper.pdf': { kind: 'pdf', size: 8, bytes: Buffer.from('%PDF-1.4') } + }; +} + +async function preview(path, label) { + const host = new FakeFileHost(fixtureFiles()); + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(host, state, () => true, () => 1); + await controller.open(resolve(path, label).target); + return { state, host }; +} + +function dataSource(state) { + return `data:${state.mimeType};base64,${state.contentBase64}`; +} + +test('stage 4: an image file reaches the Ready image renderer with usable pixels', async () => { + for (const file of ['computer://png/openbitfun-wordmark.png', 'computer://png/photo.jpg']) { + const { state } = await preview(file); + assert.equal(state.phase, FilePreviewPhase.Ready, `${file} is ready`); + assert.equal(state.rendererKind, FilePreviewRendererKind.Image, `${file} renders as an image`); + assert.equal(state.fileName, file.split('/').pop()); + assert.equal(state.contentBase64, PNG_BYTES.toString('base64'), 'the pixels survive base64'); + assert.equal(dataSource(state), `data:${state.mimeType};base64,${PNG_BYTES.toString('base64')}`); + assert.match(dataSource(state), /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/=]+$/, + 'the data source is a decodable image url'); + assert.deepEqual(Array.from(Buffer.from(state.contentBase64, 'base64')), Array.from(PNG_BYTES)); + } +}); + +test('stage 4: markdown and plain text reach the text renderers', async () => { + const markdown = await preview('computer://artifacts/REPORT.md'); + assert.equal(markdown.state.phase, FilePreviewPhase.Ready); + assert.equal(markdown.state.rendererKind, FilePreviewRendererKind.Markdown); + assert.equal(markdown.state.textContent, '# Report\nhello md\n'); + + const text = await preview('computer://artifacts/notes.txt'); + assert.equal(text.state.phase, FilePreviewPhase.Ready); + assert.equal(text.state.rendererKind, FilePreviewRendererKind.Text); + assert.equal(text.state.textContent, 'plain notes'); +}); + +test('stage 4: a type with no renderer is Unsupported, not an error', async () => { + for (const file of ['computer://artifacts/bundle.zip', 'computer://artifacts/paper.pdf']) { + const { state } = await preview(file); + assert.equal(state.phase, FilePreviewPhase.Unsupported, `${file} is honestly unsupported`); + assert.equal(state.rendererKind, FilePreviewRendererKind.Unsupported); + assert.notEqual(state.phase, FilePreviewPhase.Error, 'unsupported must not read as a failure'); + } +}); + +test('stage 4: a missing file is an error the ui can explain', async () => { + const { state } = await preview('computer://artifacts/missing.png'); + assert.equal(state.phase, FilePreviewPhase.Error); + assert.ok(state.errorText.length > 0); +}); + +test('stage 4: every common image extension the host can serve renders as an image', () => { + const extensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'ico']; + for (const extension of extensions) { + assert.equal(RemoteFilePreviewController.rendererFor(`preview.${extension}`, `image/${extension}`), + FilePreviewRendererKind.Image, `.${extension} renders as an image`); + } + assert.equal(RemoteFilePreviewController.rendererFor('diagram.svg', 'image/svg+xml'), + FilePreviewRendererKind.Unsupported, 'svg stays on the text path rather than the image decoder'); +}); + +/* ------------------------------------------------------------------ stage 5 */ + +const { RemoteFileDownloadController } = load('services/RemoteFileDownloadController'); + +class FakeSaver { + constructor() { + this.chunks = []; + this.beginCalls = []; + this.finished = false; + this.aborted = false; + } + async begin(name) { this.beginCalls.push(name); } + async write(bytes) { this.chunks.push(Buffer.from(bytes)); } + async finish() { this.finished = true; return 'file://docs/download'; } + async abort() { this.aborted = true; } +} + +function downloader(host, saver) { + const statuses = []; + let text = ''; + const controller = new RemoteFileDownloadController( + host, + (_downloading, _downloaded, status) => statuses.push(status), + () => {}, + (value) => { text = value; }, + () => {}, + 2500, + { setTimeout: () => 1, clearTimeout: () => {} }, + saver + ); + return { controller, statuses, statusText: () => text }; +} + +test('stage 5: downloading an image streams real bytes and names the destination', async () => { + const host = new FakeFileHost(fixtureFiles()); + const saver = new FakeSaver(); + const { controller, statuses, statusText } = downloader(host, saver); + + await controller.download('computer://png/openbitfun-wordmark.png', SESSION, false, true); + + assert.deepEqual(saver.beginCalls, ['openbitfun-wordmark.png'], + 'the destination keeps the host file name'); + assert.equal(Buffer.concat(saver.chunks).toString('base64'), PNG_BYTES.toString('base64'), + 'the downloaded bytes are the real file'); + assert.ok(saver.finished, 'the destination is committed'); + assert.ok(!saver.aborted, 'no failure path ran'); + assert.ok(statuses.length > 0 && statusText().length > 0, 'the ui gets a status to show'); +}); + +test('stage 5: a bare relative path is refused instead of saving an empty file', async () => { + const host = new FakeFileHost(fixtureFiles()); + const saver = new FakeSaver(); + const { controller } = downloader(host, saver); + await controller.download(' ', SESSION, false, true); + assert.equal(saver.beginCalls.length, 0); + assert.equal(saver.finished, false); +}); + +test('stage 5: a host failure aborts the destination and reports it', async () => { + const host = new FakeFileHost(fixtureFiles()); + const saver = new FakeSaver(); + const { controller, statusText } = downloader(host, saver); + await controller.download('computer://artifacts/missing.png', SESSION, false, true); + assert.ok(saver.aborted, 'the partial destination is removed'); + assert.equal(saver.finished, false); + assert.ok(statusText().length > 0, 'the failure is reported to the ui'); +}); + +/* ------------------------------------------------------- surface wiring */ + +const CARD_GAPS = [ + ['pages/components/FileReferenceCard.ets', /this\.onDownload\(this\.path\)/, 'the card download action passes the raw reference'], + ['pages/components/FileReferenceCard.ets', /this\.onPreview\(this\.path, this\.label\)/, 'the card opens the preview with the raw reference'], + ['pages/components/ChatMessageContent.ets', /FileReferenceCard\(\{/, 'the message card list mounts the real card'], + ['pages/components/FilePreviewSurface.ets', /Image\(`data:\$\{this\.state\.mimeType\};base64,\$\{this\.state\.contentBase64\}`\)/, 'the image preview builds its data source from the loaded bytes'] +]; + +test('the surface wiring that carries a card tap to the preview is intact', () => { + for (const [file, pattern, expectation] of CARD_GAPS) { + const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); + assert.match(source, pattern, expectation); + } +}); + +/* --------------------------------------------------- end-to-end coherence */ + +const { ConversationIntentDispatcher } = load('pages/actions/ConversationIntentDispatcher'); +const { ConversationIntents, ConversationIntentType } = load('pages/actions/ConversationIntent'); +const { AppRoute } = load('pages/navigation/AppRouteContract'); + +async function settled(state) { + for (let tick = 0; tick < 20 && state.phase === FilePreviewPhase.Loading; tick++) { + await new Promise(resolve => setImmediate(resolve)); + } +} + +/** The real intent → dispatcher → controller → surface chain, transport faked. */ +function appChain() { + const state = new FilePreviewState(); + const statuses = []; + const controller = new FilePreviewController(new FakeFileHost(fixtureFiles()), state, { + remoteAvailable: () => true, + activeSession: () => ({ sessionId: SESSION, workspacePath: WORKSPACE }), + workspacePath: () => WORKSPACE, + openExternalLink: async () => true, + onGeneralStatus: (value) => statuses.push(value), + onRemoteStatus: (value) => statuses.push(value) + }); + const dispatcher = new ConversationIntentDispatcher({ + mailbox: async () => {}, + openSidebar: () => {}, + back: () => {}, + newRemoteSession: () => {}, + unsupported: () => {}, + stop: async () => {}, + loadOlder: async () => {}, + approve: async () => {}, + reject: async () => {}, + cancel: async () => {}, + answer: async () => {}, + rename: async () => {}, + copy: async () => {}, + retry: async () => {}, + selectModel: async () => {}, + pickImages: async () => {}, + removeImage: () => {}, + openFilePreview: (route, request) => controller.open(route, request), + downloadFile: () => {}, + buildPlan: async () => {}, + send: async () => {}, + voiceInput: async () => {}, + inputChanged: () => {} + }); + return { state, controller, dispatcher, statuses }; +} + +test('the whole chain agrees on one path from message text to a rendered image', async () => { + const reference = projections('[openbitfun-wordmark.png](computer://png/openbitfun-wordmark.png)')[0]; + const { state, dispatcher, statuses } = appChain(); + + dispatcher.dispatch(AppRoute.RemoteChat, ConversationIntents.openFilePreview(reference.path, reference.label)); + await settled(state); + + assert.equal(state.phase, FilePreviewPhase.Ready, 'the card tap reached the preview'); + assert.equal(state.rendererKind, FilePreviewRendererKind.Image); + assert.equal(state.fileName, 'openbitfun-wordmark.png'); + assert.equal(state.mimeType, 'image/png'); + assert.equal(state.contentBase64, PNG_BYTES.toString('base64'), 'the pixels on screen are the file'); + assert.equal(state.target.sessionId, SESSION, 'the read targeted the active session'); + assert.equal(state.target.remotePath, 'png/openbitfun-wordmark.png', 'and the workspace-relative path'); + assert.deepEqual(statuses, [], 'nothing fell back to a status message'); +}); + +test('the same intent outside the remote conversation says so instead of opening nothing', async () => { + const { dispatcher, statuses } = appChain(); + dispatcher.dispatch(AppRoute.RemoteHome, ConversationIntents.openFilePreview('computer://png/a.png', 'a.png')); + assert.equal(statuses.length, 1, 'the user is told the file surface is unavailable here'); +}); + +test('a download started from a card reaches the destination with the file bytes', async () => { + const host = new FakeFileHost(fixtureFiles()); + const saver = new FakeSaver(); + const { controller, statusText } = downloader(host, saver); + await controller.download('computer://artifacts/REPORT.md', SESSION, false, true); + assert.deepEqual(saver.beginCalls, ['REPORT.md']); + assert.equal(Buffer.concat(saver.chunks).toString('utf8'), '# Report\nhello md\n'); + assert.ok(statusText().length > 0); +}); \ No newline at end of file diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs new file mode 100644 index 0000000000..0244883fa1 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -0,0 +1,308 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +// Source-level assertions. The immersive bottom is a contract between the +// window (which runs full-screen), the chat page (whose fade owns the bottom +// edge), every other scrolling surface (whose viewport runs to the screen edge +// and whose content ends in a tail spacer) and each surface's fixed bottom +// controls (which keep the navigation bar clear themselves). None of that is +// visible in a unit test of a policy object, and all of it is exactly what a +// later edit is most likely to undo by accident, so it is asserted against the +// files that carry it. +function source(relativePath) { + return fs.readFileSync(path.join(__dirname, '../..', relativePath), 'utf8'); +} + +const entryAbility = source('entry/src/main/ets/entryability/EntryAbility.ets'); +const conversationView = source('entry/src/main/ets/pages/components/ConversationView.ets'); +const windowService = source('entry/src/main/ets/services/WindowSystemBarService.ets'); +const appSidebar = source('entry/src/main/ets/pages/components/AppSidebar.ets'); +const miniAppSurface = source('entry/src/main/ets/pages/components/MiniAppSurface.ets'); +const settingsSheet = source('entry/src/main/ets/pages/components/SettingsSheet.ets'); +const workspaceToolsPanel = source('entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets'); +const workspacePicker = source('entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets'); +const connectView = source('entry/src/main/ets/pages/components/ConnectView.ets'); +const filePreviewSurface = source('entry/src/main/ets/pages/components/FilePreviewSurface.ets'); +const remoteSessionList = source('entry/src/main/ets/pages/components/RemoteSessionList.ets'); +const sessionActionSurface = source('entry/src/main/ets/pages/components/SessionActionSurface.ets'); +const sessionDetailsView = source('entry/src/main/ets/pages/components/SessionDetailsView.ets'); +const conversationViewSettings = source('entry/src/main/ets/pages/components/ConversationViewSettings.ets'); +const welcomeHome = source('entry/src/main/ets/pages/components/WelcomeHome.ets'); + +// Reads one @Builder out of a component, so an assertion can name the layer it +// is about instead of counting matches in the whole file. +function builderBody(sourceText, name) { + const start = sourceText.indexOf(`\n private ${name}() {`); + assert.notEqual(start, -1, `${name}() must stay a builder of the component`); + const end = sourceText.indexOf('\n @Builder', start); + assert.notEqual(end, -1, `${name}() must be followed by another builder`); + return sourceText.slice(start, end); +} + +// Collapses a slice to one line so an assertion is about the call, not about +// where the formatter happened to wrap it. Comments drop out first: an +// explanation between a call's arguments is part of the source, not of the call. +function normalize(sourceText) { + return sourceText.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ').replace(/\s+/g, ' '); +} + +// Reads one chained call out of a slice so the assertions survive rewrapping. +function call(sourceText, name) { + const start = sourceText.indexOf(`.${name}(`); + assert.notEqual(start, -1, `the slice must keep its .${name}() call`); + let depth = 0; + for (let index = start + name.length + 1; index < sourceText.length; index++) { + if (sourceText[index] === '(') { + depth += 1; + } else if (sourceText[index] === ')') { + depth -= 1; + if (depth === 0) { + return sourceText.slice(start, index + 1).replace(/\s+/g, ' '); + } + } + } + throw new Error(`unbalanced .${name}() call`); +} + +test('the shell runs the window full-screen for every page', () => { + // Not the welcome page's private trick any more: the transcript can only reach + // the bottom edge of the screen if the window stops reserving the strip above + // it, and that has to hold while the chat page is the mounted page. + assert.match(entryAbility, /mainWindow\.setWindowLayoutFullScreen\(true\)/, + 'the ability must lay the window out full-screen'); + assert.equal(entryAbility.includes('setWindowLayoutFullScreen(false)'), false, + 'no code path may put the window back into a non-immersive layout'); + assert.equal(entryAbility.includes('updateWindowLayout'), false, + 'the welcome page must not switch the window layout any more'); +}); + +test('the navigation bar is transparent and the status bar keeps the page colour', () => { + // The fade is what should be visible where the navigation bar sits, and the + // header band keeps its opaque page colour so the top does not regress. + assert.match(entryAbility, /navigationBarColor: MobileDesignColors\.transparent\.light/, + 'the navigation bar must be transparent so the page bottom shows through'); + assert.match(entryAbility, /statusBarColor: background/, + 'the status bar must keep the page colour'); +}); + +test('the window inset service is the only place the strip is measured', () => { + assert.match(windowService, /export class WindowInsetsBinding/, + 'the insets must be shared through one binding'); + assert.match(windowService, /bottomPadding\(designSpacing: number\): number \{[\s\S]*?Math\.max\(designSpacing, this\.bottom\)/, + 'the binding must keep the design spacing wherever it already clears the strip'); + assert.match(windowService, /appWindow\.on\('avoidAreaChange'/, 'the binding must follow avoid-area changes'); + assert.match(windowService, /appWindow\.off\('avoidAreaChange'/, 'the binding must release its listener'); +}); + +test('the binding owns both strip numbers a surface needs', () => { + // bottomPadding is for a fixed control: the design's spacing, or the strip if + // the strip is larger. tailSpacing is for the end of a scrolling surface: the + // strip plus the design's own breathing, because the last row has to rest + // above the bar once the surface that runs under it stops scrolling. + assert.match(windowService, + /tailSpacing\(designSpacing: number\): number \{[\s\S]*?return this\.bottom \+ designSpacing;/, + 'the binding must expose the tail spacer a scrolling surface ends with'); +}); + +test('the chat page bottom layer reaches the screen edge and its composer does not move', () => { + const bottom = builderBody(conversationView, 'BottomOverlay'); + // The layer's box grows by the strip, so its own gradient fills it... + assert.equal(call(bottom, 'padding'), '.padding({ bottom: this.bottomSafeInset })'); + // ...it may paint into the strip even where the page area still stops above + // it, and it is named so a layout dump can be read against the pixels. + assert.equal(call(bottom, 'expandSafeArea'), + '.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])'); + assert.equal(call(bottom, 'id'), ".id('conversation-bottom-fade')"); + // The transcript borrows the layer's measured height, which is what lets the + // last message scroll above the fade instead of under the composer. + assert.match(bottom, /this\.bottomInset = newArea\.height as number;/, + 'the transcript inset must keep following the layer it is measured from'); +}); + +test('the chat page header band reserves the status bar itself', () => { + // The shell is immersive, so the page draws from the top of the window and the + // band is what has to keep the header out of the status bar. Its own box is + // the transcript's content start offset, so the two stay in step. + const top = builderBody(conversationView, 'TopOverlay'); + assert.equal(call(top, 'padding'), '.padding({ top: this.topSafeInset })'); + assert.match(top, /\.backgroundColor\(PAGE_BG_OVERLAY\)/, + 'the band that carries the inset must stay the one that paints the header'); +}); + +test('the settings sheet viewport reaches the screen edge and its rows end in a tail spacer', () => { + // The sheet is a bindSheet, which the framework does not inset while the + // window is immersive, so its own box decides where content can scroll. The + // root must not carry a bottom padding any more: that shrinks the viewport + // and leaves a band of bare page colour under the navigation bar. + const build = normalize(settingsSheet.slice( + settingsSheet.indexOf('build() {'), + settingsSheet.indexOf('@Builder', settingsSheet.indexOf('build() {')))); + assert.equal(/\.padding\(\{[^}]*bottom/.test(build), false, + 'the sheet root must not shrink its own viewport away from the screen edge'); + assert.match(normalize(settingsSheet), + /\.padding\(\{ left: SHEET_HORIZONTAL_PADDING, right: SHEET_HORIZONTAL_PADDING, top: 22, bottom: this\.insets\.tailSpacing\(34\) \}\)/, + "the sheet's scrolling column must end in the strip plus its own breathing"); +}); + +test('the mini-app gallery viewport reaches the screen edge and its grid ends in a tail spacer', () => { + // The gallery container must not carry a bottom padding: the grid is the + // surface's bottom edge and its tiles have to roll under the navigation bar. + assert.match(normalize(miniAppSurface), + /\.padding\(\{ left: 16, right: 16 \}\)/, + 'the gallery container must keep only its horizontal padding'); + assert.equal(/\.padding\(\{[^}]*bottomPadding/.test(miniAppSurface), false, + 'the gallery must not shrink its viewport away from the screen edge'); + const grid = normalize(miniAppSurface.slice(miniAppSurface.indexOf('Grid() {'))); + assert.match(grid, /GridItem\(\) \{ Column\(\) \.width\('100%'\) \.height\(this\.insets\.tailSpacing\(24\)\) \}/, + 'the grid must end with a tail spacer row'); + assert.match(grid, /\.columnStart\(0\)/, + 'the tail spacer must start at the first column'); + assert.match(grid, /\.columnEnd\(this\.galleryColumns\(\) - 1\)/, + 'the tail spacer must span to the last column'); + assert.match(grid, /\.columnsTemplate\(this\.galleryColumnsTemplate\(\)\)/, + 'the grid template and the spacer span must stay one decision'); +}); + +test('the sidebar list scrolls under the bar and its floating footer keeps the strip clear', () => { + // The sidebar is a scrolling session list with a floating footer, which is + // the chat page's shape: the viewport runs to the panel's own bottom edge, + // the footer keeps its distance from that edge itself, and the list ends in + // a tail spacer so its last row rests above the navigation bar. + const content = normalize(builderBody(appSidebar, 'SidebarContent')); + assert.match(content, /\.padding\(\{ left: 20, right: 20, top: 0 \}\)/, + 'the panel root must not shrink its scrolling viewport'); + assert.match(content, /\.padding\(\{ bottom: this\.scrollTailPadding\(\) \}\)/, + 'the session list must end in a tail spacer'); + assert.match(content, /\.margin\(\{ bottom: this\.footerBottomPadding\(\) \}\)/, + 'the footer and its fade must keep the strip clear themselves'); + assert.match(normalize(appSidebar), + /private scrollTailPadding\(\): number \{[\s\S]*?\(this\.usesCompactFooter\(\) \? 84 : 120\) \+ this\.insets\.bottom;/, + 'the tail must clear the floating footer and the strip'); + assert.match(normalize(appSidebar), + /private footerBottomPadding\(\): number \{[\s\S]*?return this\.insets\.bottomPadding\(16\);/, + 'the footer must keep the design spacing wherever the strip is already clear'); +}); + +test('the workspace tools sheet scrolls under the bar and its fixed controls do not', () => { + // The tools sheet is a full-height bindSheet: its file list runs to the screen + // edge and ends in a tail spacer, while the terminal key row and the upload + // controls are fixed bottom controls that keep the strip clear themselves. + const build = normalize(workspaceToolsPanel.slice( + workspaceToolsPanel.indexOf('build() {'), + workspaceToolsPanel.indexOf('@Builder', workspaceToolsPanel.indexOf('build() {')))); + assert.equal(build.includes('.padding({ left: 16, right: 16, bottom'), false, + 'the panel root must not shrink its scrolling viewport'); + assert.match(normalize(workspaceToolsPanel), + /ListItem\(\) \{ Column\(\)\.width\('100%'\)\.height\(this\.insets\.tailSpacing\(16\)\) \}/, + 'the file list must end in a tail spacer'); + assert.match(normalize(workspaceToolsPanel), + /\.margin\(\{ bottom: this\.insets\.bottomPadding\(16\) \}\)/, + 'the fixed bottom controls must keep the strip clear'); + // The file action form is a FIT_CONTENT sheet: its buttons are the fixed + // controls at the screen edge, so the form's own bottom padding is what keeps + // them out of the strip. + assert.match(normalize(workspaceToolsPanel), + /\.padding\(\{ left: 20, right: 20, top: 20, bottom: this\.insets\.bottomPadding\(24\) \}\)/, + 'the file action form must keep its buttons out of the strip'); +}); + +test('the workspace tools editor keeps its card above the bar', () => { + // The editor is a WebView: its scrolling and padding live inside the web + // renderer, so the ArkUI tail spacer cannot reach its content and the card is + // fixed content instead — its bottom edge stops at the navigation bar's top, + // the same clearance the terminal key row keeps. + const editor = normalize(builderBody(workspaceToolsPanel, 'Editor')); + assert.match(editor, + /\.backgroundColor\(CARD\) \.margin\(\{ bottom: this\.insets\.bottomPadding\(16\) \}\)/, + 'the editor card must stop at the bar top instead of running under it'); +}); + +test('the remaining bottom sheets keep their content out of the bar', () => { + // The session action sheet is a fixed-height bottom sheet: its action rows + // are fixed controls, so the surface's bottom padding is strip-aware — the + // sheet host resolves it, because only the host knows which presentation + // sits at the screen edge. + assert.match(normalize(sessionActionSurface), + /\.padding\(\{ left: 16, right: 16, top: 10, bottom: this\.bottomPadding \}\)/, + 'the action surface must take its bottom padding from its host'); + assert.match(normalize(remoteSessionList), + /bottomPadding: presentation === SessionActionPresentation\.BottomSheet \? this\.insets\.bottomPadding\(18\) : 18/, + 'the bottom-sheet presentation must clear the strip, the popover must not grow'); + // The session details and view settings sheets are scrolling surfaces: their + // viewport runs to the screen edge and their content ends in a strip-aware + // tail, the same contract the settings sheet keeps. + assert.match(normalize(sessionDetailsView), + /\.padding\(\{ left: 20, right: 20, top: 8, bottom: this\.insets\.tailSpacing\(24\) \}\)/, + 'the session details tail must clear the strip'); + assert.match(normalize(conversationViewSettings), + /\.padding\(\{ left: 20, right: 20, bottom: this\.insets\.tailSpacing\(24\) \}\)/, + 'the view settings tail must clear the strip'); +}); + +test('the session sheets each bind on their own node', () => { + // A node carries at most one bindSheet: chaining the action sheet and the + // details sheet on one node left the first sheet unopenable, so the action + // sheet binds on its own zero-height carrier row and the details sheet stays + // on the root — the same pattern the sidebar's section uses for its pair. + const build = normalize(remoteSessionList.slice( + remoteSessionList.indexOf('build() {'), + remoteSessionList.indexOf('@Builder', remoteSessionList.indexOf('build() {')))); + assert.match(build, /Row\(\) \{\}\.height\(0\) \.bindSheet\(\$\$this\.showSessionActionSheet/, + 'the action sheet must bind on its own carrier node'); + assert.match(build, /\.alignItems\(HorizontalAlign\.Start\) \.bindSheet\(\$\$this\.showSessionDetails/, + 'the details sheet must be the root chain\'s only sheet'); +}); + +test('the full-height sheets keep their fixed bottom controls out of the bar', () => { + // These surfaces do not scroll at their bottom edge; their fixed controls + // carry the strip (or their design spacing, where the strip is already clear) + // so no control sits in the navigation bar's strip. + assert.match(normalize(workspacePicker), + /\.padding\(\{ top: 12, bottom: this\.insets\.bottomPadding\(16\) \}\)/, + 'the workspace picker confirm button must clear the strip'); + assert.match(normalize(connectView), + /\.padding\(\{ bottom: this\.insets\.bottomPadding\(28\) \}\)/, + 'the connect sheet status strip must clear the strip'); +}); + +test('the file preview scrollers end in strip-aware tails', () => { + // The preview scrollers already run to the screen edge; their tails are what + // rests the last line above the navigation bar. + assert.match(normalize(filePreviewSurface), + /\.padding\(\{ left: 12, right: 12, top: 14, bottom: this\.insets\.tailSpacing\(24\) \}\)/, + 'the text preview tail must clear the strip'); + assert.match(normalize(filePreviewSurface), + /\.padding\(\{ left: 16, right: 16, top: 16, bottom: this\.insets\.tailSpacing\(28\) \}\)/, + 'the markdown preview tail must clear the strip'); +}); + +test('the welcome dock reads the strip through the shared binding', () => { + // The dock paints into the strip (expandSafeArea carries its background to + // the screen edge), so its buttons must keep their clearance from a measured + // strip: the design spacing where it is already taller, the strip itself + // where a device's indicator is higher. A hard-coded clearance only looked + // right on the reference device. + assert.match(normalize(welcomeHome), + /bottom: this\.wide\(\) \? 0 : this\.insets\.bottomPadding\(G\.welcomeDockBottom\)/, + 'the dock must lift its content by the strip wherever the indicator is taller'); + assert.match(welcomeHome, + /\.expandSafeArea\(\[SafeAreaType\.SYSTEM\], \[SafeAreaEdge\.BOTTOM\]\)/, + 'the dock fill must still reach the screen edge'); +}); + +test('every surface that owns a bottom edge reads the shared binding, not a constant', () => { + for (const [name, text] of [['AppSidebar', appSidebar], ['MiniAppSurface', miniAppSurface], + ['SettingsSheet', settingsSheet], ['ConversationView', conversationView], + ['WorkspaceToolsPanel', workspaceToolsPanel], ['SidebarWorkspacePicker', workspacePicker], + ['ConnectView', connectView], ['FilePreviewSurface', filePreviewSurface], + ['RemoteSessionList', remoteSessionList], ['SessionDetailsView', sessionDetailsView], + ['ConversationViewSettings', conversationViewSettings], ['WelcomeHome', welcomeHome]]) { + assert.match(text, /@Local insets: WindowInsetsBinding = new WindowInsetsBinding\(\);/, + `${name} must hold the shared inset binding`); + assert.match(text, /this\.insets\.bind\(this\.getUIContext\(\), context\)/, + `${name} must bind the insets while it is mounted`); + assert.match(text, /this\.insets\.unbind\(\)/, `${name} must release the insets when it goes`); + } +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/model-role-selection.test.cjs b/src/apps/mobile/harmonyos/tools/tests/model-role-selection.test.cjs new file mode 100644 index 0000000000..bad3937905 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/model-role-selection.test.cjs @@ -0,0 +1,223 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +function load(relativePath, resolve) { + const source = fs.readFileSync(path.join(__dirname, '../..', relativePath), 'utf8'); + const js = ts.transpileModule(source, { + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS} + }).outputText; + const exported = {}; + new Function('require', 'exports', js)(name => (resolve ? resolve(name) : undefined) || {}, exported); + return exported; +} + +const policy = load('entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets'); +const {ConversationModelPresentationPolicy: Policy} = policy; +const ui = load('entry/src/main/ets/services/RemoteUiState.ets', name => name.endsWith('RemoteI18n') ? + {RemoteI18n: {t: key => key, f: (key, value) => `${key}:${value}`}} : + name.endsWith('RemoteSessionIdentity') ? {remoteSessionIdentity: () => ''} : {TranscriptIntegrityPolicy: {}}); +const {RemoteUiState} = ui; + +function model(id, options = {}) { + return { + id, + name: options.name || id, + provider: options.provider || 'openai', + base_url: '', + model_name: options.model_name || id, + context_window: options.context_window, + enabled: options.enabled !== false, + capabilities: [] + }; +} + +const primary = model('anthropic/claude-sonnet-4', { + name: 'Anthropic', provider: 'anthropic', model_name: 'anthropic/claude-sonnet-4', context_window: 200000 +}); +const fast = model('openbitfun:gpt-5-mini', {name: 'Fast', provider: 'openai', model_name: 'openbitfun:gpt-5-mini'}); +const other = model('local:llama', {name: 'Llama', provider: 'local'}); +const disabled = model('disabled-model', {name: 'Disabled', provider: 'test', enabled: false}); + +function catalog(defaults = {}, extra = {}) { + return { + version: 1, + models: [primary, fast, other, disabled], + default_models: defaults, + ...extra + }; +} + +test('resolves role selectors to enabled concrete models', () => { + const both = catalog({primary: primary.id, fast: fast.id}); + assert.equal(Policy.resolveModel(both, 'primary').id, primary.id); + assert.equal(Policy.resolveModel(both, 'fast').id, fast.id); + assert.equal(Policy.resolveModel(both, primary.id).id, primary.id); + assert.equal(Policy.resolveModel(both, ' primary ').id, primary.id); +}); + +test('fast falls back to primary exactly like the host', () => { + const primaryOnly = catalog({primary: primary.id}); + assert.equal(Policy.resolveModel(primaryOnly, 'fast').id, primary.id); + const disabledFast = catalog({primary: primary.id, fast: disabled.id}); + assert.equal(Policy.resolveModel(disabledFast, 'fast').id, primary.id); + const unknownFast = catalog({primary: primary.id, fast: 'missing-model'}); + assert.equal(Policy.resolveModel(unknownFast, 'fast').id, primary.id); +}); + +test('role selectors never resolve disabled, unknown, auto or default selectors', () => { + const both = catalog({primary: primary.id, fast: fast.id}); + assert.equal(Policy.resolveModel(both, disabled.id), undefined); + assert.equal(Policy.resolveModel(both, 'missing-model'), undefined); + assert.equal(Policy.resolveModel(both, 'auto'), undefined); + assert.equal(Policy.resolveModel(both, 'default'), undefined); + assert.equal(Policy.resolveModel(both, ''), undefined); + assert.equal(Policy.resolveModel(catalog({primary: disabled.id}), 'primary'), undefined); +}); + +test('maps a concrete model id to the roles it serves', () => { + assert.deepEqual(Policy.modelRoles(catalog({primary: primary.id, fast: fast.id}), primary.id), ['primary']); + assert.deepEqual(Policy.modelRoles(catalog({primary: primary.id, fast: fast.id}), fast.id), ['fast']); + assert.deepEqual(Policy.modelRoles(catalog({primary: fast.id, fast: fast.id}), fast.id), ['primary', 'fast']); + assert.deepEqual(Policy.modelRoles(catalog({primary: primary.id, fast: fast.id}), other.id), []); + assert.deepEqual(Policy.modelRoles(catalog({primary: primary.id}), ''), []); + assert.deepEqual(Policy.modelRoles(catalog({}), primary.id), []); +}); + +test('selectedModel resolves a semantic session_model_id instead of the primary fallback', () => { + assert.equal(Policy.selectedModel(catalog({primary: primary.id, fast: fast.id}, {session_model_id: 'fast'})).id, fast.id); + assert.equal(Policy.selectedModel(catalog({primary: primary.id, fast: fast.id}, {session_model_id: 'primary'})).id, primary.id); + assert.equal(Policy.selectedModel(catalog({primary: primary.id}, {session_model_id: 'fast'})).id, primary.id); +}); + +test('selectedModel keeps its precedence for concrete and empty selections', () => { + const both = catalog({primary: primary.id, fast: fast.id}); + assert.equal(Policy.selectedModel(both, other.id).id, other.id); + assert.equal(Policy.selectedModel({...both, session_model_id: fast.id}, '').id, fast.id); + assert.equal(Policy.selectedModel(both, '').id, primary.id); + assert.equal(Policy.selectedModel({...both, session_model_id: fast.id}, disabled.id).id, fast.id); + assert.equal(Policy.selectedModel({...both, session_model_id: 'fast'}, 'primary').id, primary.id); +}); + +test('role selection is normalized and never inferred from auto, default or concrete ids', () => { + assert.equal(Policy.selectedRole('primary'), 'primary'); + assert.equal(Policy.selectedRole(' fast '), 'fast'); + assert.equal(Policy.selectedRole('auto'), ''); + assert.equal(Policy.selectedRole('default'), ''); + assert.equal(Policy.selectedRole(''), ''); + assert.equal(Policy.selectedRole(primary.id), ''); + assert.equal(Policy.isSemanticModelId('primary'), true); + assert.equal(Policy.isSemanticModelId(primary.id), false); +}); + +test('trigger label key follows a semantic selection that still resolves', () => { + const both = catalog({primary: primary.id, fast: fast.id}); + assert.equal(Policy.selectedModelLabelKey(both, 'primary'), 'chat.modelPrimary'); + assert.equal(Policy.selectedModelLabelKey(both, 'fast'), 'chat.modelFast'); + assert.equal(Policy.selectedModelLabelKey(both, ' fast '), 'chat.modelFast'); + assert.equal(Policy.selectedModelLabelKey(catalog({fast: fast.id}), 'fast'), 'chat.modelFast'); + assert.equal(Policy.selectedModelLabelKey(catalog({primary: primary.id}), 'fast'), 'chat.modelFast'); + assert.equal(Policy.selectedModelLabelKey(both, 'auto'), ''); + assert.equal(Policy.selectedModelLabelKey(both, ''), ''); + assert.equal(Policy.selectedModelLabelKey(both, fast.id), ''); + assert.equal(Policy.roleLabelKey('primary'), 'chat.modelPrimary'); + assert.equal(Policy.roleLabelKey('fast'), 'chat.modelFast'); + assert.equal(Policy.roleLabelKey(other.id), ''); +}); + +test('an unresolvable role is presented as no role selection at all', () => { + const neither = catalog({}); + assert.equal(Policy.selectedModelLabelKey(neither, 'primary'), ''); + assert.equal(Policy.selectedModelLabelKey(neither, 'fast'), ''); + assert.equal(Policy.resolvedSelectedRole(neither, 'primary'), ''); + assert.equal(Policy.resolvedSelectedRole(neither, 'fast'), ''); + assert.deepEqual(Policy.roleEntries(neither), []); + assert.equal(Policy.selectedModel(neither, 'fast'), undefined); + + const disabledDefaults = catalog({primary: disabled.id, fast: disabled.id}); + assert.equal(Policy.selectedModelLabelKey(disabledDefaults, 'fast'), ''); + assert.equal(Policy.resolvedSelectedRole(disabledDefaults, 'primary'), ''); + assert.equal(Policy.selectedModelLabelKey(disabledDefaults, primary.id), ''); + + const primaryOnly = catalog({primary: primary.id}); + assert.equal(Policy.resolvedSelectedRole(primaryOnly, 'primary'), 'primary'); + assert.equal(Policy.resolvedSelectedRole(primaryOnly, 'fast'), 'fast'); + assert.equal(Policy.resolvedSelectedRole(primaryOnly, ''), ''); + assert.equal(Policy.resolvedSelectedRole(primaryOnly, primary.id), ''); + assert.equal(Policy.resolvedSelectedRole(primaryOnly, 'auto'), ''); + + const fastOnly = catalog({fast: fast.id}); + assert.equal(Policy.resolvedSelectedRole(fastOnly, 'fast'), 'fast'); + assert.equal(Policy.selectedModelLabelKey(fastOnly, 'primary'), ''); +}); + +test('a concrete row is checked only for its own concrete selection', () => { + assert.equal(Policy.isConcreteModelSelected(fast.id, fast.id), true); + assert.equal(Policy.isConcreteModelSelected(fast.id, primary.id), false); + assert.equal(Policy.isConcreteModelSelected('primary', primary.id), false); + assert.equal(Policy.isConcreteModelSelected('fast', fast.id), false); + assert.equal(Policy.isConcreteModelSelected('auto', primary.id), false); + assert.equal(Policy.isConcreteModelSelected('', primary.id), false); +}); + +test('defaults section lists both roles and describes the fast fallback', () => { + const both = Policy.roleEntries(catalog({primary: primary.id, fast: fast.id})); + assert.equal(both.length, 2); + assert.deepEqual(both.map(entry => entry.role), ['primary', 'fast']); + assert.deepEqual(both.map(entry => entry.labelKey), ['chat.modelPrimary', 'chat.modelFast']); + assert.equal(both[0].model.id, primary.id); + assert.equal(both[1].model.id, fast.id); + assert.equal(both[0].fallsBackToPrimary, false); + assert.equal(both[1].fallsBackToPrimary, false); + assert.equal(Policy.roleMetaNoticeKey(both[1]), ''); + + const primaryOnly = Policy.roleEntries(catalog({primary: primary.id})); + assert.equal(primaryOnly[1].model.id, primary.id); + assert.equal(primaryOnly[1].fallsBackToPrimary, true); + assert.equal(Policy.roleMetaNoticeKey(primaryOnly[1]), 'chat.modelFastNotConfigured'); + + const fastOnly = Policy.roleEntries(catalog({fast: fast.id})); + assert.equal(fastOnly[0].model, undefined); + assert.equal(fastOnly[1].model.id, fast.id); + assert.equal(fastOnly[1].fallsBackToPrimary, false); + + assert.deepEqual(Policy.roleEntries(catalog({})), []); + assert.deepEqual(Policy.roleEntries(catalog({primary: disabled.id, fast: disabled.id})), []); +}); + +test('role rows describe the resolved model with provider and context metadata', () => { + assert.equal(Policy.roleMetaLabel(fast, 'Model'), 'gpt-5-mini · openai'); + assert.equal(Policy.roleMetaLabel(primary, 'Model'), 'claude-sonnet-4 · anthropic · 200k'); + assert.equal(Policy.roleMetaLabel(undefined, 'Model'), ''); + assert.equal(Policy.primaryLabel(primary, 'Model'), 'claude-sonnet-4'); + assert.equal(Policy.secondaryLabel(fast, 'Model'), 'openai · Fast'); +}); + +test('catalog hydration keeps a resolvable semantic selection', () => { + const both = catalog({primary: primary.id, fast: fast.id}); + assert.equal(RemoteUiState.selectedModelIdForCatalog(both, 'primary'), 'primary'); + assert.equal(RemoteUiState.selectedModelIdForCatalog(both, 'fast'), 'fast'); + assert.equal(RemoteUiState.selectedModelIdForCatalog({...both, session_model_id: 'fast'}, 'primary'), 'fast'); + assert.equal(RemoteUiState.selectedModelIdForCatalog(both, other.id), other.id); + assert.equal(RemoteUiState.selectedModelIdForCatalog(both, 'missing-model'), primary.id); + assert.equal(RemoteUiState.selectedModelIdForCatalog({...both, models: [primary, fast]}, 'fast'), 'fast'); + assert.equal(RemoteUiState.selectedModelIdForCatalog(catalog({fast: fast.id}), 'primary'), fast.id); + assert.equal(RemoteUiState.selectedModelIdForCatalog(catalog({}), ''), ''); +}); + +test('service and policy semantic helpers agree on padded selectors and defaults', () => { + const padded = catalog({primary: ` ${primary.id} `, fast: ` ${fast.id} `}); + assert.equal(Policy.resolveModel(padded, 'primary').id, primary.id); + assert.equal(Policy.resolveModel(padded, ' fast ').id, fast.id); + assert.equal(Policy.resolvedSelectedRole(padded, ' fast '), 'fast'); + assert.deepEqual(Policy.modelRoles(padded, fast.id), ['fast']); + assert.equal(RemoteUiState.selectedModelIdForCatalog(padded, 'primary'), 'primary'); + assert.equal(RemoteUiState.selectedModelIdForCatalog(padded, ' fast '), 'fast'); + assert.equal(RemoteUiState.selectedModelIdForCatalog(catalog({primary: primary.id, fast: fast.id}), ' primary '), 'primary'); + + const paddedFastDisabled = catalog({primary: ` ${primary.id} `, fast: ` ${disabled.id} `}); + assert.equal(Policy.resolveModel(paddedFastDisabled, 'fast').id, primary.id); + assert.equal(RemoteUiState.selectedModelIdForCatalog(paddedFastDisabled, 'fast'), 'fast'); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs b/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs new file mode 100644 index 0000000000..e78af739f3 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs @@ -0,0 +1,340 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const ROOT = path.join(__dirname, '../../entry/src/main/ets'); +const cache = new Map(); + +/** Loads one .ets module and resolves its relative imports from the source tree. */ +function load(relative) { + if (cache.has(relative)) return cache.get(relative); + const source = fs.readFileSync(path.join(ROOT, relative + '.ets'), 'utf8') + .replace(/@ObservedV2\s*/g, '').replace(/@Trace\s*/g, ''); + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText; + const exported = {}; + cache.set(relative, exported); + new Function('require', 'exports', js)(name => { + // Platform kits are only reached by code paths these tests do not run. + if (!name.startsWith('.')) return {}; + const target = path.relative(ROOT, path.resolve(path.join(ROOT, relative), '..', name)) + .split(path.sep).join('/'); + return fs.existsSync(path.join(ROOT, `${target}.ets`)) ? load(target) : {}; + }, exported); + return exported; +} + +const { DurableSessionReducer } = load('services/DurableSessionReducer'); +const { ChatTimelineStore } = load('services/ChatTimelineStore'); +const { ChatSessionController } = load('services/ChatSessionController'); +const { ChatTimelineRowStore } = load('model/ChatTimelineModels'); +const ui = load('pages/state/ConversationUiModels'); +const { ChatMessageStructurePolicy: Policy } = load('pages/policy/ChatMessageStructurePolicy'); + +const SESSION = 'session'; +const TURN = 'turn'; +const CHILD_SESSION = 'child-session'; +const TASK_CALL_ID = 'task_call_1'; + +/** One durable session-record event exactly as the host publishes it. */ +function record(revision, id, type, order, data) { + return { + session_id: SESSION, + event: 'session-record', + payload: { + sessionId: SESSION, + id: `item/${id}`, + revision, + turn: { + turnId: TURN, turnIndex: 0, sessionId: SESSION, timestamp: 1, + userMessage: { id: 'user', content: 'run the audit', timestamp: 1 }, + status: 'inprogress' + }, + round: { id: 'round', turnId: TURN, roundIndex: 0, timestamp: 2, status: 'inprogress' }, + item: { type, data: Object.assign({ id, orderIndex: order, timestamp: 3 }, data) } + } + }; +} + +/** + * The owner Task tool record. + * + * The host records the child Session id on the Task tool item — the durable + * record contract is `is_subagent_item` / `subagent_session_id` on the item + * data in `src/crates/services/services-core/src/session/types.rs`, published + * as `isSubagentItem` / `subagentSessionId` — and every mobile client maps that + * field onto the subagent marker (`DurableSessionReducer.ets:157`), so the + * owner Task arrives marked exactly like the records it owns. + */ +function ownerTaskRecord(revision) { + return record(revision, TASK_CALL_ID, 'tool', 0, { + toolName: 'Task', + toolCall: { id: TASK_CALL_ID, input: { description: 'Audit the payment module', subagent_type: 'Explore' } }, + startTime: 3, + status: 'running', + subagentSessionId: CHILD_SESSION + }); +} + +function childThinkingRecord(revision) { + return record(revision, 'child-think', 'thinking', 1, { + content: 'Reading the payment module', subagentSessionId: CHILD_SESSION + }); +} + +function childToolRecord(revision) { + return record(revision, 'child-tool', 'tool', 2, { + toolName: 'Read', + toolCall: { id: 'child-read', input: { path: 'src/payments.ts' } }, + startTime: 4, + status: 'completed', + subagentSessionId: CHILD_SESSION + }); +} + +function childTextRecord(revision) { + return record(revision, 'child-text', 'text', 3, { + content: 'Found no hard-coded keys', subagentSessionId: CHILD_SESSION + }); +} + +/** Every record a running Task publishes, in host order. */ +function runningTaskRecords() { + return [ + ownerTaskRecord(1), + childThinkingRecord(2), + childToolRecord(3), + childTextRecord(4) + ]; +} + +async function timelineHarness() { + const timeline = new ChatTimelineStore(); + timeline.reset(SESSION); + let stream; + const controller = new ChatSessionController({ + getModelCatalog: async () => ({ version: 1, models: [], default_models: {} }), + subscribeSession: (_id, callbacks) => { + stream = callbacks; + return { wake() {}, close() {} }; + } + }, { + onSnapshot: snapshot => timeline.applySnapshot(snapshot), + canPoll: () => true, + onError: error => { throw error; } + }); + controller.start(SESSION, { pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0 }); + await stream.onCaughtUp(); + for (const event of runningTaskRecords()) await stream.onEvent(event); + return { timeline, stream, controller }; +} + +/** + * The production chain the live assistant row walks: durable records -> timeline + * store -> projector -> keyed V2 rows -> conversation UI DTO -> grouped render + * model. Returns the Task card the bubble would draw. + */ +function taskCardFromActiveTurn(timeline) { + const rows = new ChatTimelineRowStore().reconcile(timeline.project(false)); + const liveRow = rows.find(row => row.type === 'assistant_live_turn'); + assert.ok(liveRow, 'the running turn is projected as a live assistant row'); + const message = ui.toConversationUiMessage(liveRow.message); + const groups = Policy.structuredGroups(message.items || [], 'item', true, message.status || 'active'); + const cards = groups + .filter(group => group.type === 'item' && group.items.length === 1) + .map(group => group.items[0]) + .filter(entry => entry.tool && entry.tool.name === 'Task'); + assert.equal(cards.length, 1, 'exactly one Task card is drawn for one Task tool call'); + return { message, card: cards[0], groups }; +} + +test('a running Task card receives the subagent process published flat behind it', async () => { + const { timeline, controller } = await timelineHarness(); + const { card } = taskCardFromActiveTurn(timeline); + + assert.equal((card.subItems || []).length, 3, 'thinking, tool and text are Task children'); + assert.deepEqual((card.subItems || []).map(child => child.type), ['thinking', 'tool', 'text']); + assert.equal(card.subItems[0].content, 'Reading the payment module'); + assert.equal(card.subItems[1].tool.name, 'Read'); + assert.equal(card.subItems[2].content, 'Found no hard-coded keys'); + controller.stop(); +}); + +test('the subagent process never leaks into the parent transcript as sibling rows', async () => { + const { timeline, controller } = await timelineHarness(); + const { message, groups } = taskCardFromActiveTurn(timeline); + + const siblingTypes = groups.flatMap(group => group.items.concat(group.tools.map(tool => ({ type: 'tool', tool })))) + .filter(entry => entry.type !== 'tool' || !entry.tool || entry.tool.name !== 'Task') + .map(entry => entry.type); + assert.deepEqual(siblingTypes, [], 'only the Task card represents the running subagent'); + assert.equal(message.text, '', 'the subagent answer is not the parent answer'); + assert.equal(message.thinking, '', 'the subagent reasoning is not the parent reasoning'); + controller.stop(); +}); + +test('the subagent process stays visible after the turn completes', async () => { + const { timeline, stream, controller } = await timelineHarness(); + const finished = ownerTaskRecord(5); + finished.payload.item.data.status = 'completed'; + finished.payload.turn.status = 'completed'; + await stream.onEvent(finished); + + const assistant = timeline.snapshot().persistedMessages.find(row => row.role === 'assistant'); + assert.ok(assistant, 'the completed turn is persisted'); + const message = ui.toConversationUiMessage(assistant); + const groups = Policy.structuredGroups(message.items || [], 'item', false, message.status || 'done'); + const card = groups.map(group => group.items[0]).find(entry => entry.tool && entry.tool.name === 'Task'); + assert.ok(card, 'the Task card survives completion'); + assert.equal((card.subItems || []).length, 3, 'its process stays under it'); + controller.stop(); +}); + +test('an owner Task marked as a subagent is still the branch owner', () => { + const task = { + type: 'tool', + is_subagent: true, + tool: { id: TASK_CALL_ID, name: 'Task', status: 'running', tool_input: { description: 'Audit' } } + }; + const children = [ + { type: 'thinking', is_subagent: true, content: 'child reasoning' }, + { type: 'tool', is_subagent: true, tool: { id: 'child-read', name: 'Read', status: 'running' } }, + { type: 'text', is_subagent: true, content: 'child output' } + ]; + const scoped = Policy.scopeSubagentItems([task].concat(children)); + assert.equal(scoped.length, 1, 'the marked children fold into the marked owner Task'); + assert.equal((scoped[0].subItems || []).length, 3); +}); + +test('every Task name variant the host can send opens the branch', () => { + for (const name of ['Task', 'task', 'TASK', ' Task ', 'Task']) { + const task = { type: 'tool', tool: { id: 'call', name, status: 'running' } }; + const child = { type: 'text', is_subagent: true, content: 'child output' }; + const scoped = Policy.scopeSubagentItems([task, child]); + assert.equal((scoped[0].subItems || []).length, 1, `Task name ${JSON.stringify(name)} owns the child`); + } +}); + +test('a marked record that arrives before its Task is not lost', () => { + const orphan = { type: 'thinking', is_subagent: true, content: 'first child reasoning' }; + const task = { type: 'tool', is_subagent: true, tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const later = { type: 'text', is_subagent: true, content: 'later child output' }; + const scoped = Policy.scopeSubagentItems([orphan, task, later]); + + assert.equal(scoped.length, 2, 'the early record keeps its own branch'); + const orphanBranch = scoped[0]; + assert.equal(orphanBranch.type, 'thinking'); + assert.equal((orphanBranch.subItems || []).length, 1, 'its content survives as a child, not as parent output'); + assert.equal(orphanBranch.subItems[0].is_subagent, false, 'the inner copy renders as subagent process'); + assert.equal(scoped[1].tool.name, 'Task'); + assert.equal((scoped[1].subItems || []).length, 1, 'the later record still joins its Task'); +}); + +test('an orphaned subagent tool still renders inside its own branch', () => { + const orphanTool = { type: 'tool', is_subagent: true, tool: { id: 'orphan-read', name: 'Read', status: 'running' } }; + const groups = Policy.structuredGroups([orphanTool], 'item', true, 'active'); + assert.equal(groups.length, 1, 'the orphan is drawn'); + assert.equal(groups[0].items.length, 1); + assert.equal(groups[0].items[0].tool.name, 'Read', 'with its tool identity for the card title'); + assert.equal((groups[0].items[0].subItems || []).length, 1, 'and its own process child'); +}); + +test('an orphaned subagent record labels its branch without repeating its own text', () => { + const orphan = { type: 'thinking', is_subagent: true, content: 'first child reasoning' }; + const branch = Policy.scopeSubagentItems([orphan])[0]; + + assert.equal(branch.content, '', 'the branch label does not repeat the step it wraps'); + assert.equal(Policy.subagentBody(branch), '', 'and the branch carries no second copy as its body'); + assert.equal(Policy.subagentTitle(branch), '', + 'with no text left the title falls back to the generic Task name'); + assert.equal((branch.subItems || []).length, 1, 'the step itself survives'); + assert.equal(branch.subItems[0].content, 'first child reasoning', 'and keeps the text as its body'); + assert.equal(branch.subItems[0].is_subagent, false, 'the step renders as content, not as a nested branch'); + + const orphanTool = { type: 'tool', is_subagent: true, tool: { id: 'orphan-read', name: 'Read', status: 'running' } }; + const toolBranch = Policy.scopeSubagentItems([orphanTool])[0]; + assert.equal(Policy.subagentTitle(toolBranch), 'Read', 'a tool orphan keeps its tool-derived label'); + assert.equal(toolBranch.subItems[0].tool.name, 'Read', 'and its step is the tool row'); +}); + +test('an unmarked Task with flat marked children is unchanged', () => { + const task = { type: 'tool', tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const children = [ + { type: 'thinking', is_subagent: true, content: 'child reasoning' }, + { type: 'tool', is_subagent: true, tool: { id: 'child-read', name: 'Read', status: 'completed' } } + ]; + const scoped = Policy.scopeSubagentItems([task].concat(children)); + assert.equal(scoped.length, 1); + assert.equal((scoped[0].subItems || []).length, 2); +}); + +/** + * The branch view key is what ArkUI keys the Task card node by, and an unchanged + * key never re-runs the item builder. The card therefore cannot rely on a + * rebuild to receive a step that arrives later: it re-reads its branch from the + * observed message on every render. These tests pin that contract, so a future + * "fix" that instead churns the key (and so destroys an open branch on every + * streamed step) fails here. + */ +test('the Task branch view key stays stable while its subagent publishes steps', () => { + const task = { type: 'tool', tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const child = { type: 'thinking', is_subagent: true, content: 'child reasoning' }; + const branchKeys = items => Policy.structuredGroups(Policy.scopeSubagentItems(items), 'item', true, 'active') + .map(group => group.key).join('|'); + + assert.equal(branchKeys([task, child]), branchKeys([task]), + 'an arriving step must not recreate the branch node that is already open'); +}); + +test('a card re-reads its branch, so a later step reaches the open card', () => { + const owner = { type: 'tool', is_subagent: true, tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const firstStep = { type: 'thinking', is_subagent: true, content: 'step one' }; + const secondStep = { type: 'tool', is_subagent: true, tool: { id: 'child-read', name: 'Read', status: 'running' } }; + + // What the frozen item builder captured when the Task card was first drawn. + const captured = Policy.structuredGroups([owner], 'item', true, 'active')[0].items[0]; + assert.equal((captured.subItems || []).length, 0, 'a Task card is born with no process items'); + + // What the card reads on every later render, from the transcript as it is now. + const first = Policy.subagentBranchAt([owner, firstStep], 0, TASK_CALL_ID); + assert.equal((first.subItems || []).length, 1, 'the first step is visible'); + const second = Policy.subagentBranchAt([owner, firstStep, secondStep], 0, TASK_CALL_ID); + assert.equal((second.subItems || []).length, 2, 'a step published later is visible too'); + assert.equal(second.subItems[1].tool.name, 'Read'); + assert.notEqual(second, captured, 'the stale captured snapshot is not what the card draws'); +}); + +test('a branch is found by its Task tool call and by its render ordinal', () => { + const owner = { type: 'tool', is_subagent: true, tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const child = { type: 'text', is_subagent: true, content: 'child output' }; + + assert.equal(Policy.subagentBranchAt([owner, child], 0, TASK_CALL_ID).tool.id, TASK_CALL_ID); + assert.equal(Policy.subagentBranchAt([owner, child], 0, '').tool.id, TASK_CALL_ID, + 'the ordinal identifies the branch when the host sent no tool call id'); + assert.equal(Policy.subagentBranchAt([owner, child], 1, TASK_CALL_ID).tool.id, TASK_CALL_ID, + 'an unknown ordinal still resolves through the owning tool call'); + assert.equal(Policy.subagentBranchAt([owner, child], 3, ''), undefined, + 'an ordinal with no branch resolves to nothing, so the card keeps its snapshot'); +}); + +test('a branch path reports the ordinal the render path numbered it with', () => { + assert.equal(Policy.subagentOrdinal('item-subagent-0'), 0); + assert.equal(Policy.subagentOrdinal('item-subagent-2'), 2); + assert.equal(Policy.subagentOrdinal('item-subagent-0-adjacent-1'), 0); + assert.equal(Policy.subagentOrdinal('item-tool-0'), 0); +}); + +test('the Task card is fed the branch re-read from the observed message', () => { + const card = fs.readFileSync(path.join(ROOT, 'pages/components/SubagentTaskCard.ets'), 'utf8'); + assert.match(card, /@Param items: ConversationUiMessageItem\[\]/, + 'the card takes the branch process items as its param'); + + const bubble = fs.readFileSync(path.join(ROOT, 'pages/components/ChatMessageBubble.ets'), 'utf8'); + assert.match(bubble, /items: this\.currentSubagentBranch\(entry, path\)\.subItems/, + 'the bubble re-resolves the branch on every render instead of using the captured entry'); + assert.match(bubble, /ChatMessageStructurePolicy\.subagentBranchAt\(/, + 'the re-resolution goes through the shared policy'); +});