Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const EN_US_MESSAGES: [string, string][] = [
['common.running', 'Running'],
['common.waiting', 'Waiting'],

['notification.taskCompletedTitle', 'Task completed'],
['notification.taskCompletedBody', 'Your BitFun task is ready. Tap to open BitFun and view the result.'],

['chatHome.prompt', 'What do you want to do today?'],
['chatHome.placeholder', 'Ask BitFun'],
['chatHome.localHint', 'This chat stays on the phone. Switch to Remote to read and write projects on your computer.'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const ZH_CN_MESSAGES: [string, string][] = [
['common.running', '运行中'],
['common.waiting', '等待中'],

['notification.taskCompletedTitle', '任务已完成'],
['notification.taskCompletedBody', 'BitFun 任务已经完成,点击打开 BitFun 查看结果。'],

['chatHome.prompt', '今天想做什么?'],
['chatHome.placeholder', '问问 BitFun'],
['chatHome.localHint', '这是手机上的对话。要读写电脑上的项目,请切换到远程。'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ export interface PollSessionResponse extends CommandStatusResponse {
title?: string;
new_messages?: ChatMessageResponse[];
total_msg_count?: number;
message_snapshot?: ChatMessageResponse[];
active_turn?: ActiveTurnSnapshotResponse;
model_catalog?: RemoteModelCatalog;
}
Expand All @@ -456,6 +457,7 @@ export interface PollSessionResult {
title: string;
newMessages: ChatMessage[];
totalMessageCount: number;
messageSnapshot?: ChatMessage[];
activeTurn?: ChatMessage;
modelCatalog?: RemoteModelCatalog;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionAction
/**
* How many conversations show before the rest go behind an overflow row.
*
* The conversations and the workspaces share one scroll now, so the list can no
* longer take every pixel it wants: six rows leave the workspace section
* visible on a phone without scrolling to find it.
* The conversations and the workspaces share one scroll. Recent history comes
* after the device/workspace navigation, so cap each expansion to keep the
* bottom section browseable without turning the sidebar into an unbounded list.
*/
const RECENT_BATCH: number = 6;

Expand Down Expand Up @@ -60,7 +60,7 @@ export struct AppSidebar {
@Local archivedSessionsExpanded: boolean = false;
@Local visibleRecentCount: number = RECENT_BATCH;
/**
* The workspace section, appended below the conversations.
* The device/workspace section, rendered before recent conversations.
*
* It is a slot rather than a component this file constructs because the
* sidebar has no business knowing about remote state; the host wires it.
Expand All @@ -81,18 +81,17 @@ export struct AppSidebar {
}

Stack({ alignContent: Alignment.Bottom }) {
// Conversations and workspaces scroll together. They are different
// kinds of thing — a timeline and a set of places — so they stack
// rather than compete for the same pane, and neither needs a label
// saying whether it runs here or on a desktop.
// Places are the primary navigation, so devices and workspaces come
// first. Recent conversations are history and stay at the bottom of
// the same scroll instead of pushing the current working context down.
Scroll() {
Column() {
if (this.showConversationSection) {
this.ConversationSection()
}
if (this.showWorkspaceSection) {
this.contentSlot()
}
if (this.showConversationSection) {
this.ConversationSection()
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
ObservableConversationUiMessage,
toConversationUiMessage
} from '../state/ConversationUiModels';
import { ConversationMessageRenderPolicy } from '../policy/ConversationMessageRenderPolicy';
import { RemoteI18n } from '../../i18n/RemoteI18n';
import {
ChatTimelineItem,
Expand Down Expand Up @@ -190,7 +189,7 @@ export struct ChatTimeline {
.onAppear(() => {
this.logRenderedItem(repeatItem.item);
})
} else if (repeatItem.item.message && this.shouldRenderTimelineMessage(repeatItem.item)) {
} else if (repeatItem.item.message) {
ListItem() {
if (repeatItem.item.message!.role === 'user') {
ChatUserMessageRow({
Expand Down Expand Up @@ -250,7 +249,7 @@ export struct ChatTimeline {
// temporary object through an @Builder freezes the first empty active
// snapshot on current ArkUI builds.
ForEach(this.activeTimelineItems(), (item: ObservableChatTimelineItem) => {
if (item.message && this.shouldRenderTimelineMessage(item)) {
if (item.message) {
ListItem() {
ChatAssistantTimelineRow({
row: item,
Expand Down Expand Up @@ -459,27 +458,6 @@ export struct ChatTimeline {
return summary;
}

private shouldRenderTimelineMessage(item: ChatTimelineItem): boolean {
if (!item.message) {
return false;
}
try {
const uiMessage = toConversationUiMessage(item.message);
const render = ConversationMessageRenderPolicy.shouldRender(uiMessage);
RemoteLogger.info(
`chat timeline decide type=${item.type} id=${item.id} role=${uiMessage.role} ` +
`render=${render ? 1 : 0} text=${uiMessage.text ? uiMessage.text.length : -1} ` +
`thinking=${uiMessage.thinking ? uiMessage.thinking.length : 0} ` +
`tools=${uiMessage.tools ? uiMessage.tools.length : 0} ` +
`items=${uiMessage.items ? uiMessage.items.length : 0}`
);
return render;
} catch (err) {
RemoteLogger.error(`chat timeline decide failed type=${item.type} id=${item.id} err=${err}`);
return false;
}
}

private logTimelineProjection(reason: string): void {
const parts: string[] = [];
this.timelineItems.forEach((item: ChatTimelineItem) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ChatTimelineItem } from '../../model/ChatTimelineModels';
import { ConversationMessageRenderPolicy } from '../policy/ConversationMessageRenderPolicy';
import { ChatSurface } from '../state/ChatSurface';
import { toConversationUiMessage } from '../state/ConversationUiModels';
import { ComposerPresentation } from './ComposerBar';

/** Layout and host chrome that vary independently from conversation data. */
Expand Down Expand Up @@ -28,7 +30,18 @@ export class ConversationViewContract {
}

static visibleTimelineItems<T extends ChatTimelineItem>(timelineItems: T[]): T[] {
return timelineItems.filter((item: T) => item.type !== 'empty_state');
// Repeat.virtualScroll requires every input entry to create one child. A
// hollow assistant is deliberately invisible, so passing it through and
// conditionally omitting its ListItem corrupts the virtual index/height
// map and can move adjacent user bubbles outside the viewport. Keep the
// renderability fence at the view contract instead: everything handed to
// the virtual list is guaranteed to own a visible row.
return timelineItems.filter((item: T) => {
if (item.type === 'empty_state' || !item.message) {
return false;
}
return ConversationMessageRenderPolicy.shouldRender(toConversationUiMessage(item.message));
});
}

static hasRealTimelineItem(timelineItems: ChatTimelineItem[]): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition {

onPageShow(): void {
RemoteLogger.info(`page show state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`);
this.taskCompletionNotificationController.onForeground();
this.remoteActivityViewModel.resume();
// Idempotent: covers the pairing that happened after the last cold start.
void this.startWatchProvisioning();
Expand All @@ -84,7 +85,17 @@ export class AppRootRuntime extends AppRootRuntimeComposition {
onPageHide(): void {
RemoteLogger.info(`page hide state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`);
this.settingsController.stopPresencePolling();
this.remoteActivityViewModel.invalidate();
const activeRemoteTurn = this.remotePageState.activeTurnMessage;
if (this.isRemoteConversationContext(this.remotePageState.activeSession.sessionId)) {
this.trackRemoteTaskCompletion(this.remotePageState.activeSession.sessionId, activeRemoteTurn);
}
const keepRemotePolling = this.taskCompletionNotificationController.isTracking('remote');
const watchingCompletion = this.taskCompletionNotificationController.onBackground();
if (keepRemotePolling && watchingCompletion) {
this.remoteActivityViewModel.suspendKeepingActiveTurnPoll();
} else {
this.remoteActivityViewModel.invalidate();
}
this.remoteConnectionCoordinator.invalidate();
this.remotePageState.setBusy(false);
this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput);
Expand All @@ -93,6 +104,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition {
aboutToDisappear(): void {
this.settingsController.stopPresencePolling();
this.watchProvisionController.stop();
this.taskCompletionNotificationController.stop();
this.remoteActivityViewModel.invalidate();
this.remoteConnectionCoordinator.invalidate();
this.remotePageState.setBusy(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ import { RemoteWorkspaceViewModel } from '../viewmodel/RemoteWorkspaceViewModel'
import { RemoteSessionViewModel } from '../viewmodel/RemoteSessionViewModel';
import { GeneralChatConversationViewModel } from '../viewmodel/GeneralChatConversationViewModel';
import { ModelProviderGeneralChatAdapter } from '../../services/general-chat/ModelProviderGeneralChatAdapter';
import { HarmonyTaskCompletionNotificationPort } from '../../services/HarmonyTaskCompletionNotificationPort';
import {
TaskCompletionNotificationController,
TaskCompletionObservation
} from '../../services/TaskCompletionNotificationController';

export enum ConnectionState {
Idle = 'idle',
Expand Down Expand Up @@ -148,6 +153,15 @@ export abstract class AppRootRuntimeComposition {
abstract toggleVoiceInput(): Promise<void>;

readonly sessionManager: RemoteSessionManager = new RemoteSessionManager();
readonly taskCompletionNotificationController: TaskCompletionNotificationController =
new TaskCompletionNotificationController(
new HarmonyTaskCompletionNotificationPort((): Context => this.host.context()),
(observation: TaskCompletionObservation): void => {
if (observation.source === 'remote') {
this.remoteChatPollingLifecycleController.stop();
}
}
);
readonly workspaceRepository: RemoteWorkspaceRepository =
new RemoteWorkspaceRepository(this.sessionManager);
readonly workspaceCoordinator: RemoteWorkspaceCoordinator =
Expand Down Expand Up @@ -491,6 +505,11 @@ export abstract class AppRootRuntimeComposition {
this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId);
this.conversationController.syncRemoteTimeline();
}
this.taskCompletionNotificationController.track({
source: 'remote',
sessionId: this.remotePageState.activeSession.sessionId || '',
turnId
});
this.remoteChatPollingLifecycleController.nudge();
},
onSendFailed: (
Expand Down Expand Up @@ -582,6 +601,25 @@ export abstract class AppRootRuntimeComposition {
},
onSnapshot: (snapshot: RemoteChatPollingSnapshot) => {
this.conversationController.applyRemoteSnapshot(snapshot);
if (snapshot.activeTurn && snapshot.activeTurn.id.length > 0) {
const status = (snapshot.activeTurn.status || '').toLowerCase();
if (status === 'failed' || status === 'error' || status === 'cancelled' || status === 'canceled') {
if (this.taskCompletionNotificationController.cancel('remote', snapshot.sessionId)) {
this.remoteChatPollingLifecycleController.stop();
}
} else {
this.trackRemoteTaskCompletion(snapshot.sessionId, snapshot.activeTurn);
}
} else if (snapshot.sessionState.toLowerCase() === 'idle' && snapshot.completedTurnId.length === 0 &&
this.taskCompletionNotificationController.cancel('remote', snapshot.sessionId)) {
this.remoteChatPollingLifecycleController.stop();
}
if (snapshot.completedTurnId.length > 0 &&
this.taskCompletionNotificationController.complete(
'remote', snapshot.sessionId, snapshot.completedTurnId
)) {
this.remoteChatPollingLifecycleController.stop();
}
},
onError: (error: Object) => {
this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(error));
Expand Down Expand Up @@ -660,9 +698,29 @@ export abstract class AppRootRuntimeComposition {
currentActiveTurnId: (): string => this.currentActiveTurnId(),
latestUserMessageText: (): string => this.conversationController.latestUserMessageText(),
syncTimeline: (): void => this.conversationController.syncGeneralTimeline(),
refreshSessions: (): void => this.generalChatCommandController.refreshSessions()
refreshSessions: (): void => this.generalChatCommandController.refreshSessions(),
onTaskStarted: (sessionId: string, turnId: string): void => {
this.taskCompletionNotificationController.track({ source: 'general', sessionId, turnId });
},
onTaskCompleted: (sessionId: string, turnId: string): void => {
this.taskCompletionNotificationController.complete('general', sessionId, turnId);
},
onTaskStopped: (sessionId: string, turnId: string): void => {
this.taskCompletionNotificationController.cancel('general', sessionId, turnId);
}
}
);

protected trackRemoteTaskCompletion(sessionId: string, activeTurn: ChatMessage): void {
const status = (activeTurn.status || '').toLowerCase();
if (status !== 'active' && status !== 'completed' && status !== 'done' && status !== 'success') {
return;
}
const explicitTurnId = (activeTurn.turnId || '').trim();
const turnId = explicitTurnId.length > 0 ? explicitTurnId :
(activeTurn.id.indexOf('active-') === 0 ? activeTurn.id.slice('active-'.length) : '');
this.taskCompletionNotificationController.track({ source: 'remote', sessionId, turnId });
}
readonly remoteConnectionController: RemoteConnectionController =
new RemoteConnectionController(
this.remotePageState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export interface GeneralChatConversationViewModelHooks {
readonly latestUserMessageText: () => string;
readonly syncTimeline: () => void;
readonly refreshSessions: () => void;
readonly onTaskStarted?: (sessionId: string, turnId: string) => void;
readonly onTaskCompleted?: (sessionId: string, turnId: string) => void;
readonly onTaskStopped?: (sessionId: string, turnId: string) => void;
}

/** Owns General Chat stream state and publishes all updates through ConversationViewModel. */
Expand Down Expand Up @@ -94,7 +97,7 @@ export class GeneralChatConversationViewModel {
this.flushDelta(sessionId);
this.hooks.refreshSessions();
if (this.stream.text().length > 0) {
this.finishStream(sessionId, result);
await this.finishStream(sessionId, result);
} else {
const finalMessage = this.messageForActiveTurn(result.assistantMessage);
this.resetStream();
Expand All @@ -106,6 +109,9 @@ export class GeneralChatConversationViewModel {
message: finalMessage
});
await this.persistAssistantMessage(sessionId, finalMessage);
if (this.hooks.onTaskCompleted) {
this.hooks.onTaskCompleted(sessionId, finalMessage.turnId || '');
}
this.hooks.syncTimeline();
this.pageState.setBusy(false);
this.pageState.setServiceState(GeneralChatServiceState.Ready);
Expand All @@ -123,6 +129,7 @@ export class GeneralChatConversationViewModel {
}
const hadStream = hasStream || this.pageState.activeTurnMessage.id.length > 0;
const currentSessionId = this.stream.currentSessionId();
const currentTurnId = this.stream.currentTurnId() || this.hooks.currentActiveTurnId();
const requestInFlight = this.stream.isRequestInFlight();
if (cancelled && currentSessionId.length > 0) {
this.flushDelta(currentSessionId);
Expand All @@ -131,6 +138,9 @@ export class GeneralChatConversationViewModel {
this.stream.markRequestCancelled();
this.command.cancelCurrentRequest();
}
if (hadStream && this.hooks.onTaskStopped) {
this.hooks.onTaskStopped(currentSessionId || this.pageState.activeSession.sessionId || '', currentTurnId);
}
this.stream.reset();
if (cancelled && this.pageState.activeTurnMessage.id.length > 0) {
const active = this.pageState.activeTurnMessage;
Expand Down Expand Up @@ -175,11 +185,15 @@ export class GeneralChatConversationViewModel {

private beginStream(sessionId: string): void {
const activeTurn = this.stream.begin(sessionId);
const turnId = activeTurn.turnId || activeTurn.id.replace('active-', '');
this.timeline.dispatch({
type: 'turn_started',
sessionId,
turnId: activeTurn.turnId || activeTurn.id.replace('active-', '')
turnId
});
if (this.hooks.onTaskStarted) {
this.hooks.onTaskStarted(sessionId, turnId);
}
this.hooks.syncTimeline();
}

Expand Down Expand Up @@ -207,7 +221,7 @@ export class GeneralChatConversationViewModel {
this.hooks.syncTimeline();
}

private finishStream(sessionId: string, result: GeneralChatSendResult): void {
private async finishStream(sessionId: string, result: GeneralChatSendResult): Promise<void> {
const currentSessionId = this.stream.currentSessionId();
const visible = this.hooks.isVisible(sessionId);
RemoteLogger.info(`general chat finish check session=${this.shortId(sessionId)} current=${this.shortId(currentSessionId)} visible=${visible}`);
Expand All @@ -222,7 +236,10 @@ export class GeneralChatConversationViewModel {
turnId: finalMessage.turnId || this.hooks.currentActiveTurnId(),
message: finalMessage
});
this.persistAssistantMessage(sessionId, finalMessage);
await this.persistAssistantMessage(sessionId, finalMessage);
if (this.hooks.onTaskCompleted) {
this.hooks.onTaskCompleted(sessionId, finalMessage.turnId || '');
}
this.hooks.syncTimeline();
this.resetStream();
this.pageState.setBusy(false);
Expand Down Expand Up @@ -255,6 +272,12 @@ export class GeneralChatConversationViewModel {
images: SelectedImageAttachment[],
err: Object
): Promise<void> {
if (this.hooks.onTaskStopped) {
this.hooks.onTaskStopped(
sessionId,
this.stream.currentTurnId() || this.hooks.currentActiveTurnId()
);
}
this.stream.setRequestInFlight(false);
if (this.stream.takeRequestCancelled()) {
const finalMessage = this.stream.takePendingFinalMessage();
Expand Down
Loading
Loading