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
1 change: 1 addition & 0 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1585,6 +1585,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const result = await runGoalCommandSafely({
session: effectiveTargetSession,
userHint: parsed.userHint,
loadingMessage: t('chatInput.goalGenerating', { defaultValue: 'Generating session goal...' }),
failedTitle: t('chatInput.goalFailed', { defaultValue: 'Goal mode activation failed' }),
unknownErrorMessage: t('error.unknown'),
aiFailedMessage: t('chatInput.goalAiFailed', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useFlowChatSync } from './useFlowChatSync';
import { useFlowChatToolActions } from './useFlowChatToolActions';
import { useFlowChatSearch } from './useFlowChatSearch';
import { useVirtualItems, useActiveSession, useVisibleTurnInfo, type VisibleTurnInfo } from '../../store/modernFlowChatStore';
import type { FlowChatConfig, FlowToolItem, Session } from '../../types/flow-chat';
import type { FlowChatConfig, FlowToolItem, Session, DialogTurn } from '../../types/flow-chat';
import type { LineRange } from '@/component-library';
import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext';
import { parsePullRequestUrl } from '@/shared/utils/pullRequestLinks';
Expand Down Expand Up @@ -276,17 +276,26 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
searchCurrentMatchVirtualIndex,
]);

const resolveLocalCommandHeaderTitle = useCallback((metadata: DialogTurn['userMessage']['metadata']) => {
if (metadata?.localCommandKind === 'usage_report') {
return t('usage.title');
}
if (metadata?.localCommandKind === 'goal_pending') {
return t('chatInput.goalGenerating');
}
return null;
}, [t]);

const turnSummaries = useMemo<FlowChatHeaderTurnSummary[]>(() => {
return (activeSession?.dialogTurns ?? [])
.filter(turn => !!turn.userMessage)
.map((turn, index) => ({
turnId: turn.id,
turnIndex: index + 1,
title: turn.userMessage?.metadata?.localCommandKind === 'usage_report'
? t('usage.title')
: turn.userMessage?.content ?? '',
title: resolveLocalCommandHeaderTitle(turn.userMessage?.metadata)
?? turn.userMessage?.content ?? '',
}));
}, [activeSession?.dialogTurns, t]);
}, [activeSession?.dialogTurns, resolveLocalCommandHeaderTitle]);

const effectiveVisibleTurnInfo = useMemo<VisibleTurnInfo | null>(() => {
if (!pendingHeaderTurnId) {
Expand All @@ -312,11 +321,12 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
return effectiveVisibleTurnInfo?.userMessage ?? '';
}
const turn = activeSession?.dialogTurns.find(item => item.id === turnId);
if (turn?.userMessage?.metadata?.localCommandKind === 'usage_report') {
return t('usage.title');
const localCommandTitle = resolveLocalCommandHeaderTitle(turn?.userMessage?.metadata);
if (localCommandTitle) {
return localCommandTitle;
}
return effectiveVisibleTurnInfo?.userMessage ?? '';
}, [activeSession?.dialogTurns, effectiveVisibleTurnInfo?.turnId, effectiveVisibleTurnInfo?.userMessage, t]);
}, [activeSession?.dialogTurns, effectiveVisibleTurnInfo?.turnId, effectiveVisibleTurnInfo?.userMessage, resolveLocalCommandHeaderTitle]);

useEffect(() => {
if (!pendingHeaderTurnId) return;
Expand Down
16 changes: 15 additions & 1 deletion src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { snapshotAPI } from '@/infrastructure/api';
import { notificationService } from '@/shared/notification-system';
import { globalEventBus } from '@/infrastructure/event-bus';
import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection';
import { ReproductionStepsBlock, Tooltip, confirmDanger } from '@/component-library';
import { ReproductionStepsBlock, Tooltip, confirmDanger, ToolProcessingDots } from '@/component-library';
import { UserMessageEditComposer } from './UserMessageEditComposer';
import {
describeUserMessageEditImpact,
Expand Down Expand Up @@ -69,6 +69,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
const messageContent = typeof message?.content === 'string' ? message.content : String(message?.content || '');
const messageImages = useMemo(() => message?.images ?? [], [message?.images]);
const isUsageReportMessage = message?.metadata?.localCommandKind === 'usage_report';
const isGoalPendingMessage = message?.metadata?.localCommandKind === 'goal_pending';
const isUsageReportLoading = message?.metadata?.usageReportStatus === 'loading';
const usageReport = coerceSessionUsageReport(message?.metadata?.usageReport);
const sessionRelationship = useMemo(
Expand Down Expand Up @@ -356,6 +357,19 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
/>
);
}

if (isGoalPendingMessage) {
return (
<div className="session-usage-report-card session-usage-report-card--loading" aria-live="polite">
<div className="session-usage-report-card__loading-main">
<ToolProcessingDots className="session-usage-report-card__loading-dots" size={12} />
<div>
<h3 className="session-usage-report-card__loading-title">{messageContent}</h3>
</div>
</div>
</div>
);
}

return (
<div
Expand Down
78 changes: 50 additions & 28 deletions src/web-ui/src/flow_chat/services/goalService.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI';
import { notificationService } from '@/shared/notification-system';
import type { Session } from '../types/flow-chat';
import { flowChatStore } from '../store/FlowChatStore';
import { FlowChatManager } from './FlowChatManager';

export { isGoalSlashCommand, parseGoalCommand } from './goalCommandParser';

export interface GoalCommandParams {
session: Session;
userHint?: string;
loadingMessage: string;
failedTitle: string;
unknownErrorMessage: string;
aiFailedMessage: string;
Expand All @@ -24,40 +26,60 @@ export async function runGoalCommand(params: GoalCommandParams): Promise<GoalCom
throw new Error('A workspace is required to activate goal mode.');
}

const activation = await agentAPI.activateSessionGoal({
const pendingId = `pending-${params.session.sessionId}-${Date.now()}`;
const pendingTurn = flowChatStore.addLocalGoalPendingTurn({
sessionId: params.session.sessionId,
userHint: params.userHint,
workspacePath: params.session.workspacePath,
remoteConnectionId: params.session.remoteConnectionId,
remoteSshHost: params.session.remoteSshHost,
message: params.loadingMessage,
pendingId,
});
let finalizedPendingTurn = false;

const flowChatManager = FlowChatManager.getInstance();
await flowChatManager.sendMessage(
activation.kickoffMessage,
params.session.sessionId,
activation.displayMessage,
undefined,
undefined,
{
userMessageMetadata: {
goalModeKickoff: true,
goalModeCommand: params.userHint ? `/goal ${params.userHint}` : '/goal',
goalText: activation.goalText,
successCriteria: activation.successCriteria,
},
try {
const activation = await agentAPI.activateSessionGoal({
sessionId: params.session.sessionId,
userHint: params.userHint,
workspacePath: params.session.workspacePath,
remoteConnectionId: params.session.remoteConnectionId,
remoteSshHost: params.session.remoteSshHost,
});

if (pendingTurn) {
flowChatStore.deleteDialogTurn(params.session.sessionId, pendingTurn.id);
}
);
finalizedPendingTurn = true;

notificationService.success(activation.goalText, {
title: params.activatedTitle,
duration: 6000,
});
const flowChatManager = FlowChatManager.getInstance();
await flowChatManager.sendMessage(
activation.kickoffMessage,
params.session.sessionId,
activation.displayMessage,
undefined,
undefined,
{
userMessageMetadata: {
goalModeKickoff: true,
goalModeCommand: params.userHint ? `/goal ${params.userHint}` : '/goal',
goalText: activation.goalText,
successCriteria: activation.successCriteria,
},
}
);

return {
goalText: activation.goalText,
successCriteria: activation.successCriteria,
};
notificationService.success(activation.goalText, {
title: params.activatedTitle,
duration: 6000,
});

return {
goalText: activation.goalText,
successCriteria: activation.successCriteria,
};
} catch (error) {
if (pendingTurn && !finalizedPendingTurn) {
flowChatStore.deleteDialogTurn(params.session.sessionId, pendingTurn.id);
}
throw error;
}
}

function resolveGoalCommandError(error: unknown, params: GoalCommandParams): string {
Expand Down
45 changes: 45 additions & 0 deletions src/web-ui/src/flow_chat/store/FlowChatStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,51 @@ describe('FlowChatStore local usage reports', () => {
expect(stored?.lastActiveAt).toBe(4321);
});

it('can append local goal pending turns', () => {
const session = createSession();
flowChatStore.setState(() => ({
sessions: new Map([[session.sessionId, session]]),
activeSessionId: session.sessionId,
}));

const turn = flowChatStore.addLocalGoalPendingTurn({
sessionId: session.sessionId,
message: 'Generating session goal...',
pendingId: 'goal-1',
});

const stored = flowChatStore.getState().sessions.get(session.sessionId)?.dialogTurns[0];
expect(turn).not.toBeNull();
expect(stored?.kind).toBe('local_command');
expect(stored?.status).toBe('processing');
expect(stored?.userMessage.content).toBe('Generating session goal...');
expect(stored?.userMessage.metadata).toMatchObject({
localCommandKind: 'goal_pending',
modelVisible: false,
goalPendingId: 'goal-1',
});
});

it('can delete local goal pending turns', () => {
const session = createSession();
flowChatStore.setState(() => ({
sessions: new Map([[session.sessionId, session]]),
activeSessionId: session.sessionId,
}));

const turn = flowChatStore.addLocalGoalPendingTurn({
sessionId: session.sessionId,
message: 'Generating session goal...',
pendingId: 'goal-1',
});

expect(turn).not.toBeNull();
flowChatStore.deleteDialogTurn(session.sessionId, turn!.id);

const stored = flowChatStore.getState().sessions.get(session.sessionId);
expect(stored?.dialogTurns).toHaveLength(0);
});

it('appends repeated usage reports as separate snapshots', () => {
const session = createSession();
flowChatStore.setState(() => ({
Expand Down
61 changes: 61 additions & 0 deletions src/web-ui/src/flow_chat/store/FlowChatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,66 @@ export class FlowChatStore {
return dialogTurn;
}

public addLocalGoalPendingTurn(params: {
sessionId: string;
message: string;
pendingId: string;
}): DialogTurn | null {
const session = this.state.sessions.get(params.sessionId);
if (!session) {
log.warn('Session not found, cannot add local goal pending turn', {
sessionId: params.sessionId,
});
return null;
}

const generatedAt = Date.now();
const metadata: LocalCommandMetadata = {
localCommandKind: 'goal_pending',
modelVisible: false,
goalPendingId: params.pendingId,
generatedAt,
};
const turnIndex = session.dialogTurns.length;
const dialogTurn: DialogTurn = {
id: `local-goal-${params.pendingId}`,
sessionId: params.sessionId,
kind: 'local_command',
userMessage: {
id: `local-goal-user-${params.pendingId}`,
content: params.message,
timestamp: generatedAt,
metadata,
},
modelRounds: [],
status: 'processing',
startTime: generatedAt,
endTime: generatedAt,
backendTurnIndex: turnIndex,
};

this.setState(prev => {
const currentSession = prev.sessions.get(params.sessionId);
if (!currentSession) return prev;

if (currentSession.dialogTurns.some(turn => turn.id === dialogTurn.id)) {
return prev;
}

const newSessions = new Map(prev.sessions);
newSessions.set(params.sessionId, {
...currentSession,
dialogTurns: [...currentSession.dialogTurns, dialogTurn],
});

return {
...prev,
sessions: newSessions,
};
});
return dialogTurn;
}

public deleteDialogTurn(sessionId: string, dialogTurnId: string): void {
this.setState(prev => {
const session = prev.sessions.get(sessionId);
Expand Down Expand Up @@ -2368,6 +2428,7 @@ export class FlowChatStore {

const displayContent =
metadata?.localCommandKind === 'usage_report'
|| metadata?.localCommandKind === 'goal_pending'
? turn.userMessage.content
: metadata?.original_text || this.cleanRemoteUserInput(turn.userMessage.content);
const normalizedTurnStatus = normalizeRecoveredTurnStatus(turn.status, { error: undefined });
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/en-US/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@
"usageNoWorkspace": "A workspace is required to build a usage report.",
"usageFailed": "Usage report failed",
"goalAction": "Session goal",
"goalGenerating": "Generating session goal...",
"goalNoSession": "No active session for /goal",
"goalUsage": "Use /goal with optional focus text, for example /goal fix the login bug.",
"goalFailed": "Goal mode activation failed",
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/zh-CN/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@
"compactUsage": "请直接使用 /compact,不要附加额外参数。",
"compactFailed": "会话压缩失败",
"goalAction": "会话目标",
"goalGenerating": "正在生成目标…",
"goalNoSession": "当前没有可用于 /goal 的会话",
"goalUsage": "使用 /goal 并可选择附加目标描述,例如 /goal 修复登录问题。",
"goalFailed": "目标模式激活失败",
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/zh-TW/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@
"compactUsage": "請直接使用 /compact,不要附加額外參數。",
"compactFailed": "會話壓縮失敗",
"goalAction": "會話目標",
"goalGenerating": "正在生成目標…",
"goalNoSession": "當前沒有可用於 /goal 的會話",
"goalUsage": "使用 /goal 並可選擇附加目標描述,例如 /goal 修復登入問題。",
"goalFailed": "目標模式啟用失敗",
Expand Down
11 changes: 7 additions & 4 deletions src/web-ui/src/shared/types/session-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,17 @@ export interface ReviewActionPersistedState {
export type SessionStatus = 'active' | 'archived' | 'completed';
export type DialogTurnKind = 'user_dialog' | 'manual_compaction' | 'local_command';

export type LocalCommandKind = 'usage_report' | 'goal_pending';

export interface LocalCommandMetadata {
localCommandKind: 'usage_report';
reportId: string;
schemaVersion: number;
generatedAt: number;
localCommandKind: LocalCommandKind;
reportId?: string;
schemaVersion?: number;
generatedAt?: number;
modelVisible: false;
usageReport?: Record<string, any>;
usageReportStatus?: 'loading' | 'completed';
goalPendingId?: string;
}

export interface SessionList {
Expand Down
Loading