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
40 changes: 40 additions & 0 deletions ACTION_BAR_LAYOUT_OPTIMIZATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Action Bar Layout Optimization

## Context

`ActionBar.updateButtonsLayout()` currently disposes and recreates the Send or
Cancel button whenever the chat view or feature-flag-driven button topology is
refreshed. The state refactor intentionally keeps this layout behavior and
restores the current `ActionButtonState` after each rebuild.

Layout optimization is deferred so state correctness and widget lifecycle
changes can be reviewed independently.

## Current Costs and Risks

- Recreating controls churns selection listeners and accessibility metadata.
- Send button images are disposed and reloaded with the control.
- Rebuilds can cause unnecessary layout work and visible flicker.
- Future code can accidentally initialize a recreated control instead of
rendering the current state.
- Asynchronous feature flag notifications can queue redundant rebuilds.

## Preferred Follow-up

Keep both action controls stable after construction:

1. Create the primary Send or Cancel button once.
2. Create the optional coding-agent button once.
3. Toggle visibility and `GridData.exclude` when preview availability changes.
4. Adjust the parent column count without disposing either control.
5. Render the existing `ActionButtonState` after topology changes.
6. Coalesce redundant asynchronous refresh requests when practical.

## Validation

- Switch between Ask and Agent while a turn is running.
- Deliver repeated feature flag notifications while a turn is running.
- Toggle preview availability in both idle and running states.
- Confirm keyboard focus, accessibility names, and tooltips remain correct.
- Confirm images and listeners are disposed exactly once with the ActionBar.
- Compare layout and repaint frequency before and after the optimization.
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ A delegated agent that the main agent spawns to carry out a scoped sub-task on
its behalf. The main agent invokes it through the `run_subagent` tool and
receives the sub-agent's result back as part of its own turn.

### Top-level turn
A user-initiated unit of chat work that owns the chat-wide cancellation and
completion lifecycle.

### Sub-agent turn
A delegated unit of chat work nested within a top-level turn. Its completion
returns control to the parent and does not complete the top-level turn.

### Sub-agent policy
Organization-level governance that decides whether sub-agents are permitted for
a user. It is authoritative and is **enforced by the language server**, not by
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,44 @@ Not exercised:
`setMinHeight()` was never recomputed after the dialog disposal.
- Verify the Allow path is unaffected: clicking **Allow** on a
separate invocation should still lay out correctly (no regression).

---

## 7. Top-level turn lifecycle

### TC-008: Cancel remains available after a subagent turn completes

**Type:** `Regression`
**Priority:** `P0`

#### Preconditions
- The Chat view is open in Agent mode.
- A new conversation (fresh session).
- Subagents are available for the signed-in account.

#### Steps
1. Send a prompt that requires both subagent work and a main-agent follow-up,
for example: `Use a subagent to inspect this project for TODO comments.
After the subagent completes, continue in the main agent and summarize the
findings.`
2. Wait for the `SubagentMessageBlock` to appear and verify that the action
button shows **Cancel**.
3. Wait until the subagent card reports completion.
4. While the main agent continues working or streams its summary, verify that
the action button still shows **Cancel**.
5. Wait for the main agent's top-level turn to complete.

#### Expected Result
- Completing the subagent turn does not change the action button to **Send**.
- **Cancel** remains available while the main agent continues the top-level
turn.
- The button changes to **Send** only after the main agent's top-level turn
completes.

#### Key Screenshots
- [ ] **Subagent completed** - completed subagent card with Cancel still shown.
- [ ] **Top-level turn completed** - main-agent summary complete and Send shown.

#### Notes on failure modes
- Send appears while the main agent is still working - a subagent progress
`end` was incorrectly treated as completion of the top-level turn.
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

package com.microsoft.copilot.eclipse.ui.chat;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.lang.reflect.Method;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class ActionBarActionButtonStateTest {
private static final String SEND_ENABLED = "SEND_ENABLED";
private static final String SEND_DISABLED = "SEND_DISABLED";
private static final String CANCEL_ENABLED = "CANCEL_ENABLED";
private static final String TURN_STARTED = "TURN_STARTED";
private static final String TURN_FINISHED = "TURN_FINISHED";
private static final String INPUT_CHANGED = "INPUT_CHANGED";
private static final Class<?> STATE_TYPE = loadStateType();
private static final Class<?> EVENT_TYPE = loadEventType();

@ParameterizedTest
@ValueSource(strings = { SEND_ENABLED, SEND_DISABLED, CANCEL_ENABLED })
void testOnTurnStarted_anyState_returnsCancelEnabled(String stateName) {
assertEquals(CANCEL_ENABLED, stateName(nextState(stateName, TURN_STARTED, false)));
}

@ParameterizedTest
@ValueSource(strings = { SEND_ENABLED, SEND_DISABLED, CANCEL_ENABLED })
void testOnTurnFinished_withInput_returnsSendEnabled(String stateName) {
assertEquals(SEND_ENABLED, stateName(nextState(stateName, TURN_FINISHED, true)));
}

@ParameterizedTest
@ValueSource(strings = { SEND_ENABLED, SEND_DISABLED, CANCEL_ENABLED })
void testOnTurnFinished_withoutInput_returnsSendDisabled(String stateName) {
assertEquals(SEND_DISABLED, stateName(nextState(stateName, TURN_FINISHED, false)));
}

@Test
void testOnInputChanged_runningTurn_keepsCancelEnabled() {
assertEquals(CANCEL_ENABLED, stateName(nextState(CANCEL_ENABLED, INPUT_CHANGED, true)));
assertEquals(CANCEL_ENABLED, stateName(nextState(CANCEL_ENABLED, INPUT_CHANGED, false)));
}

@Test
void testOnInputChanged_idleTurn_reflectsInputPresence() {
assertEquals(SEND_ENABLED, stateName(nextState(SEND_DISABLED, INPUT_CHANGED, true)));
assertEquals(SEND_DISABLED, stateName(nextState(SEND_ENABLED, INPUT_CHANGED, false)));
}

@Test
void testRepeatedTransitions_sameInput_areIdempotent() {
Object startedTwice = nextState(nextState(CANCEL_ENABLED, TURN_STARTED, false), TURN_STARTED, false);
Object enabledTwice = nextState(nextState(SEND_ENABLED, TURN_FINISHED, true), TURN_FINISHED, true);
Object disabledTwice = nextState(nextState(SEND_DISABLED, TURN_FINISHED, false), TURN_FINISHED, false);

assertEquals(CANCEL_ENABLED, stateName(startedTwice));
assertEquals(SEND_ENABLED, stateName(enabledTwice));
assertEquals(SEND_DISABLED, stateName(disabledTwice));
}

@Test
void testIsTurnRunning_onlyCancelEnabled_returnsTrue() {
assertTrue(invokeBoolean(state(CANCEL_ENABLED), "isTurnRunning"));
assertFalse(invokeBoolean(state(SEND_ENABLED), "isTurnRunning"));
assertFalse(invokeBoolean(state(SEND_DISABLED), "isTurnRunning"));
}

private static Class<?> loadStateType() {
return loadNestedType("ActionButtonState");
}

private static Class<?> loadEventType() {
return loadNestedType("ActionButtonEvent");
}

private static Class<?> loadNestedType(String simpleName) {
try {
return Class.forName(ActionBar.class.getName() + "$" + simpleName);
} catch (ClassNotFoundException e) {
throw new AssertionError(simpleName + " is missing", e);
}
}

private static Object enumConstant(Class<?> enumType, String name) {
for (Object value : enumType.getEnumConstants()) {
if (name.equals(stateName(value))) {
return value;
}
}
throw new AssertionError("Unknown " + enumType.getSimpleName() + ": " + name);
}

private static String stateName(Object state) {
return ((Enum<?>) state).name();
}

private static Object nextState(String stateName, String eventName, boolean hasInput) {
return nextState(enumConstant(STATE_TYPE, stateName), eventName, hasInput);
}

private static Object nextState(Object state, String eventName, boolean hasInput) {
try {
Method method = ActionBar.class.getDeclaredMethod("nextActionButtonState", STATE_TYPE, EVENT_TYPE,
boolean.class);
method.setAccessible(true);
return method.invoke(null, state, enumConstant(EVENT_TYPE, eventName), hasInput);
} catch (ReflectiveOperationException e) {
throw new AssertionError("Failed to invoke ActionBar.nextActionButtonState", e);
}
}

private static boolean invokeBoolean(Object state, String methodName) {
try {
Method method = STATE_TYPE.getDeclaredMethod(methodName);
method.setAccessible(true);
return (Boolean) method.invoke(state);
} catch (ReflectiveOperationException e) {
throw new AssertionError("Failed to invoke ActionButtonState." + methodName, e);
}
}

private static Object state(String name) {
return enumConstant(STATE_TYPE, name);
}
}
Loading
Loading