Skip to content
Open
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
11 changes: 10 additions & 1 deletion packages/happy-app/sources/-session/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,7 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
resolveVisibleAgentGoalStatus(session)
), [
session.agentState?.agentGoalStatus,
session.agentState?.agentGoalStatusV2,
session.presence,
session.metadata?.claudeSessionId,
session.metadata?.codexThreadId,
Expand All @@ -620,7 +621,15 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
}),
dispatchGoalAction: (nextAction, objective) => sessionGoalAction(sessionId, nextAction, objective),
setInFlight: setGoalActionInFlight,
onError: (error) => console.error('Failed to perform goal action', error),
onError: (error) => {
console.error('Failed to perform goal action', error);
Modal.alert(
t('common.error'),
error instanceof Error && error.message.trim().length > 0
? error.message
: t('errors.unknownError'),
);
},
});
}, [sessionId, visibleAgentGoal?.text]);

Expand Down
30 changes: 25 additions & 5 deletions packages/happy-app/sources/-session/agentGoalActionHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,41 @@ describe('performAgentGoalAction', () => {
expect(setInFlight).not.toHaveBeenCalled();
});

it('does nothing for stop before setting in-flight state', async () => {
it.each(['pause', 'resume'] as const)('dispatches %s without an objective', async (action) => {
const promptEditGoal = vi.fn();
const dispatchGoalAction = vi.fn();
const dispatchGoalAction = vi.fn().mockResolvedValue(undefined);
const setInFlight = vi.fn();

await performAgentGoalAction({
action: 'stop',
action,
currentGoalText: 'finish the branch',
promptEditGoal,
dispatchGoalAction,
setInFlight,
});

expect(promptEditGoal).not.toHaveBeenCalled();
expect(dispatchGoalAction).not.toHaveBeenCalled();
expect(setInFlight).not.toHaveBeenCalled();
expect(dispatchGoalAction).toHaveBeenCalledWith(action, undefined);
expect(setInFlight).toHaveBeenNthCalledWith(1, action);
expect(setInFlight).toHaveBeenNthCalledWith(2, null);
});

it('reports RPC failures and always clears in-flight state', async () => {
const error = new Error('Goal is not loaded');
const onError = vi.fn();
const setInFlight = vi.fn();

await performAgentGoalAction({
action: 'resume',
currentGoalText: 'finish the branch',
promptEditGoal: vi.fn(),
dispatchGoalAction: vi.fn().mockRejectedValue(error),
setInFlight,
onError,
});

expect(onError).toHaveBeenCalledWith(error);
expect(setInFlight).toHaveBeenNthCalledWith(1, 'resume');
expect(setInFlight).toHaveBeenNthCalledWith(2, null);
});
});
4 changes: 0 additions & 4 deletions packages/happy-app/sources/-session/agentGoalActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,6 @@ export async function performAgentGoalAction({
setInFlight,
onError,
}: PerformAgentGoalActionOptions): Promise<void> {
if (action === 'stop') {
return;
}

let objective: string | undefined;
if (action === 'edit') {
const nextGoal = await promptEditGoal(currentGoalText);
Expand Down
99 changes: 91 additions & 8 deletions packages/happy-app/sources/components/AgentGoalBar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import { ActivityIndicator } from 'react-native';
import { describe, expect, it, vi } from 'vitest';
import type { VisibleAgentGoalStatus } from './agentGoalStatus';

Expand Down Expand Up @@ -37,8 +38,13 @@ vi.mock('@/text', () => ({
const values: Record<string, string> = {
'components.agentGoalBar.currentGoal': 'Current goal',
'components.agentGoalBar.clearGoal': 'Clear goal',
'components.agentGoalBar.stopGoal': 'Stop goal',
'components.agentGoalBar.pauseGoal': 'Pause goal',
'components.agentGoalBar.resumeGoal': 'Resume goal',
'components.agentGoalBar.editGoal': 'Edit goal',
'components.agentGoalBar.statusPaused': 'Paused',
'components.agentGoalBar.statusBlocked': 'Blocked',
'components.agentGoalBar.statusUsageLimited': 'Usage limited',
'components.agentGoalBar.statusBudgetLimited': 'Budget limited',
};
if (key === 'components.agentGoalBar.accessibilityLabel') {
return `Current goal: ${params?.goal ?? ''}`;
Expand All @@ -53,6 +59,7 @@ const goal: VisibleAgentGoalStatus = {
text: 'finish the current task',
observedAt: 11_000,
sourceSessionId: 'claude-session-1',
providerStatus: 'active',
};

type ElementWithProps = React.ReactElement<Record<string, any>>;
Expand Down Expand Up @@ -85,6 +92,20 @@ function findAllByLabel(node: React.ReactNode, label: string): ElementWithProps[
return matches;
}

function findAllByType(node: React.ReactNode, type: React.ElementType): ElementWithProps[] {
const matches: ElementWithProps[] = [];
if (React.isValidElement(node)) {
const element = node as ElementWithProps;
if (element.type === type) {
matches.push(element);
}
for (const child of childrenOf(element)) {
matches.push(...findAllByType(child, type));
}
}
return matches;
}

async function renderGoalBar(props: Record<string, unknown>): Promise<ElementWithProps> {
const { AgentGoalBar } = await import('./AgentGoalBar');
return AgentGoalBar(props as any) as ElementWithProps;
Expand All @@ -96,19 +117,20 @@ describe('AgentGoalBar', () => {

expect(textContent(element)).toContain('Current goal');
expect(textContent(element)).toContain('finish the current task');
expect(textContent(element)).not.toContain('Active');
expect(findAllByLabel(element, 'Current goal: finish the current task')).toHaveLength(1);
});

it('does not render action buttons without an action handler', async () => {
const element = await renderGoalBar({
goal: {
...goal,
capabilities: { clear: true, stop: true, edit: true },
capabilities: { clear: true, pause: true, edit: true },
},
});

expect(findAllByLabel(element, 'Clear goal')).toHaveLength(0);
expect(findAllByLabel(element, 'Stop goal')).toHaveLength(0);
expect(findAllByLabel(element, 'Pause goal')).toHaveLength(0);
expect(findAllByLabel(element, 'Edit goal')).toHaveLength(0);
});

Expand All @@ -117,14 +139,14 @@ describe('AgentGoalBar', () => {
const element = await renderGoalBar({
goal: {
...goal,
capabilities: { clear: true, stop: false, edit: true },
capabilities: { clear: true, pause: false, edit: true },
},
onAction,
});

const clearButton = findAllByLabel(element, 'Clear goal')[0];
const editButton = findAllByLabel(element, 'Edit goal')[0];
expect(findAllByLabel(element, 'Stop goal')).toHaveLength(0);
expect(findAllByLabel(element, 'Pause goal')).toHaveLength(0);

clearButton.props.onPress();
editButton.props.onPress();
Expand All @@ -144,21 +166,82 @@ describe('AgentGoalBar', () => {

expect(findAllByLabel(element, 'Edit goal')).toHaveLength(1);
expect(findAllByLabel(element, 'Clear goal')).toHaveLength(0);
expect(findAllByLabel(element, 'Stop goal')).toHaveLength(0);
expect(findAllByLabel(element, 'Pause goal')).toHaveLength(0);
});

it('renders and dispatches Pause for an active goal', async () => {
const onAction = vi.fn();
const element = await renderGoalBar({
goal: {
...goal,
providerStatus: 'active',
capabilities: { pause: true },
},
onAction,
});

const pauseButton = findAllByLabel(element, 'Pause goal')[0];
expect(findAllByLabel(element, 'Resume goal')).toHaveLength(0);

pauseButton.props.onPress();
expect(onAction).toHaveBeenCalledWith('pause');
});

it('disables the in-flight action button', async () => {
it('renders Paused state and dispatches Resume for a paused goal', async () => {
const onAction = vi.fn();
const element = await renderGoalBar({
goal: {
...goal,
capabilities: { clear: true },
providerStatus: 'paused',
capabilities: { resume: true },
},
onAction,
});

expect(textContent(element)).toContain('Current goal · Paused');
expect(findAllByLabel(element, 'Current goal: finish the current task. Paused')).toHaveLength(1);
const resumeButton = findAllByLabel(element, 'Resume goal')[0];
expect(findAllByLabel(element, 'Pause goal')).toHaveLength(0);

resumeButton.props.onPress();
expect(onAction).toHaveBeenCalledWith('resume');
});

it.each([
['blocked', 'Blocked'],
['usageLimited', 'Usage limited'],
['budgetLimited', 'Budget limited'],
] as const)('renders %s state without a lifecycle action', async (providerStatus, label) => {
const element = await renderGoalBar({
goal: {
...goal,
providerStatus,
capabilities: { pause: true, resume: true, clear: true },
},
onAction: vi.fn(),
});

expect(textContent(element)).toContain(`Current goal · ${label}`);
expect(findAllByLabel(element, 'Pause goal')).toHaveLength(0);
expect(findAllByLabel(element, 'Resume goal')).toHaveLength(0);
expect(findAllByLabel(element, 'Clear goal')).toHaveLength(1);
});

it('disables every action while showing a spinner only on the in-flight action', async () => {
const onAction = vi.fn();
const element = await renderGoalBar({
goal: {
...goal,
capabilities: { clear: true, edit: true },
},
onAction,
inFlightAction: 'clear',
});

const clearButton = findAllByLabel(element, 'Clear goal')[0];
const editButton = findAllByLabel(element, 'Edit goal')[0];
expect(clearButton.props.accessibilityState).toEqual({ disabled: true });
expect(editButton.props.accessibilityState).toEqual({ disabled: true });
expect(findAllByType(element, ActivityIndicator)).toHaveLength(1);
});
});
42 changes: 33 additions & 9 deletions packages/happy-app/sources/components/AgentGoalBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as React from 'react';
import { ActivityIndicator, Pressable, Text, View } from 'react-native';
import { useUnistyles } from 'react-native-unistyles';

export type AgentGoalAction = 'clear' | 'stop' | 'edit';
export type AgentGoalAction = 'clear' | 'pause' | 'resume' | 'edit';

type AgentGoalBarProps = {
goal: VisibleAgentGoalStatus;
Expand All @@ -14,30 +14,52 @@ type AgentGoalBarProps = {
onPressDetails?: () => void;
};

const ACTION_CONFIG: Array<{
type GoalActionConfig = {
action: AgentGoalAction;
capability: keyof NonNullable<VisibleAgentGoalStatus['capabilities']>;
icon: keyof typeof Ionicons.glyphMap;
}> = [
};

const BASE_ACTION_CONFIG: GoalActionConfig[] = [
{ action: 'edit', capability: 'edit', icon: 'create-outline' },
{ action: 'stop', capability: 'stop', icon: 'pause-outline' },
{ action: 'clear', capability: 'clear', icon: 'trash-outline' },
];

export function AgentGoalBar(props: AgentGoalBarProps) {
const { theme } = useUnistyles();
const lifecycleAction: GoalActionConfig | null = props.goal.providerStatus === 'active'
? { action: 'pause', capability: 'pause', icon: 'pause-outline' }
: props.goal.providerStatus === 'paused'
? { action: 'resume', capability: 'resume', icon: 'play-outline' }
: null;
const actionConfig = lifecycleAction
? [BASE_ACTION_CONFIG[0], lifecycleAction, BASE_ACTION_CONFIG[1]]
: BASE_ACTION_CONFIG;
const actions = props.onAction
? ACTION_CONFIG.filter((item) => props.goal.capabilities?.[item.capability])
? actionConfig.filter((item) => props.goal.capabilities?.[item.capability])
: [];
const actionLabels: Record<AgentGoalAction, string> = {
edit: t('components.agentGoalBar.editGoal'),
stop: t('components.agentGoalBar.stopGoal'),
pause: t('components.agentGoalBar.pauseGoal'),
resume: t('components.agentGoalBar.resumeGoal'),
clear: t('components.agentGoalBar.clearGoal'),
};
const statusLabel = props.goal.providerStatus === 'paused'
? t('components.agentGoalBar.statusPaused')
: props.goal.providerStatus === 'blocked'
? t('components.agentGoalBar.statusBlocked')
: props.goal.providerStatus === 'usageLimited'
? t('components.agentGoalBar.statusUsageLimited')
: props.goal.providerStatus === 'budgetLimited'
? t('components.agentGoalBar.statusBudgetLimited')
: null;

return (
<Pressable
accessibilityLabel={t('components.agentGoalBar.accessibilityLabel', { goal: props.goal.text })}
accessibilityLabel={[
t('components.agentGoalBar.accessibilityLabel', { goal: props.goal.text }),
statusLabel,
].filter(Boolean).join('. ')}
onPress={props.onPressDetails}
style={({ pressed }) => ({
backgroundColor: theme.colors.surfaceHigh,
Expand Down Expand Up @@ -65,6 +87,7 @@ export function AgentGoalBar(props: AgentGoalBarProps) {
numberOfLines={1}
>
{t('components.agentGoalBar.currentGoal')}
{statusLabel ? ` · ${statusLabel}` : null}
</Text>
<Text
style={{
Expand All @@ -81,7 +104,8 @@ export function AgentGoalBar(props: AgentGoalBarProps) {
{actions.length > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
{actions.map((item) => {
const disabled = props.inFlightAction === item.action;
const disabled = props.inFlightAction != null;
const showSpinner = props.inFlightAction === item.action;
return (
<Pressable
key={item.action}
Expand All @@ -101,7 +125,7 @@ export function AgentGoalBar(props: AgentGoalBarProps) {
opacity: disabled ? 0.6 : 1,
})}
>
{disabled ? (
{showSpinner ? (
<ActivityIndicator size="small" color={theme.colors.textSecondary} />
) : (
<Ionicons name={item.icon} size={16} color={theme.colors.button.secondary.tint} />
Expand Down
14 changes: 13 additions & 1 deletion packages/happy-app/sources/components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,25 @@ function UserTextBlock(props: {
return null;
}

const parsed = parseLocalCommandMessage(props.message.displayText || props.message.text);
const parsed = parseLocalCommandMessage(
props.message.displayText || props.message.text,
props.metadata?.flavor,
);
if (parsed.kind === 'caveat') {
return null;
}
if (parsed.kind === 'goal-confirmation') {
return null;
}
if (parsed.kind === 'goal-action') {
return (
<View style={styles.userMessageContainer}>
<View style={[styles.commandChip, bubbleStyle]}>
<Text style={styles.commandChipText}>/goal {parsed.action}</Text>
</View>
</View>
);
}
if (parsed.kind === 'goal-run') {
return (
<View style={styles.userMessageContainer}>
Expand Down
Loading