Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
@@ -0,0 +1 @@
- Fixed queued-message editing so duplicate prompts always target the selected queue entry.
125 changes: 32 additions & 93 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1017,8 +1017,6 @@ export class InteractiveMode {
private retryCountdown: CountdownTimer | undefined = undefined;
private traceUploadAllAbortController: AbortController | undefined = undefined;

// Session-owned queued messages mirrored from connection events.
private connectionQueue: AgentConnectionQueueState = { steering: [], followUp: [] };
private readonly queueSelection = new QueueSelection();
private isApplyingQueueSelectionText = false;
private queueMutationChain: Promise<void> = Promise.resolve();
Expand Down Expand Up @@ -2510,24 +2508,28 @@ export class InteractiveMode {
}
}

private getConnectionQueue(): AgentConnectionQueueState {
return {
steering: [...(this.connectionState?.sessionActions.steering ?? [])],
followUp: [...(this.connectionState?.sessionActions.followUps ?? [])],
};
}

private async refreshConnectionQueue(): Promise<void> {
this.replaceConnectionQueue(await this.agentConnection.getQueue());
}

private replaceConnectionQueue(queue: AgentConnectionQueueState): void {
this.connectionQueue = {
steering: [...queue.steering],
followUp: [...queue.followUp],
};
const dropped = this.queueSelection.sync(this.connectionQueue);
if (dropped !== undefined) {
const editorText = this.editor.getText();
if (editorText === dropped) {
this.setEditorTextFromQueueSelection(this.queueSelection.reset());
} else if (!this.pendingQueueEdit) {
this.queueSelection.replaceDraft(editorText);
}
}
const sessionActions = this.connectionState?.sessionActions;
if (!sessionActions) return;
this.patchConnectionState({
sessionActions: {
...sessionActions,
queuedCount: queue.steering.length + queue.followUp.length,
steering: [...queue.steering],
followUps: [...queue.followUp],
},
});
Comment thread
snimu marked this conversation as resolved.
Resolved
this.updatePendingMessagesDisplay();
}

Expand Down Expand Up @@ -2829,7 +2831,6 @@ export class InteractiveMode {
this.shortcutGuideContainer.clear();
this.pendingMessagesContainer.clear();
this.queuedMessagesContainer.clear();
this.connectionQueue = { steering: [], followUp: [] };
this.pendingQueueEdit = undefined;
// The selection and its stashed draft belong to the previous session;
// every editor draft is cleared below, so discard rather than restore.
Expand Down Expand Up @@ -4420,7 +4421,8 @@ export class InteractiveMode {
for (const entry of this.editor.getHistory?.() ?? []) {
add(entry);
}
for (const msg of [...this.connectionQueue.steering, ...this.connectionQueue.followUp]) {
const queue = this.getConnectionQueue();
for (const msg of [...queue.steering, ...queue.followUp]) {
add(msg);
}
return ids;
Expand Down Expand Up @@ -5363,10 +5365,7 @@ export class InteractiveMode {
break;

case "session_action_update": {
this.replaceConnectionQueue({
steering: [...event.actions.steering],
followUp: [...event.actions.followUps],
});
this.updatePendingMessagesDisplay();
Comment thread
snimu marked this conversation as resolved.
Resolved
this.ui.requestRender();
break;
}
Expand Down Expand Up @@ -6992,7 +6991,7 @@ export class InteractiveMode {

private browseQueueSelection(direction: -1 | 1): void {
if (this.pendingQueueEdit) return;
const text = this.queueSelection.move(this.connectionQueue, this.editor.getText(), direction);
const text = this.queueSelection.move(this.getConnectionQueue(), this.editor.getText(), direction);
if (text === undefined) return;
this.setEditorTextFromQueueSelection(text);
this.ui.requestRender();
Expand All @@ -7009,46 +7008,21 @@ export class InteractiveMode {
}

private moveQueueSelection(direction: -1 | 1): void {
if (this.pendingQueueEdit) return;
const submittedSelection = this.queueSelection.selected;
if (!submittedSelection) return;
if (this.pendingQueueEdit || !this.queueSelection.selected) return;
const sessionGeneration = this.sessionEventGeneration;
void this.enqueueQueueMutation(async () => {
if (sessionGeneration !== this.sessionEventGeneration) return;
const lane = this.connectionQueue[submittedSelection.lane];
const resolvedIndex =
lane[submittedSelection.index] === submittedSelection.text
? submittedSelection.index
: lane.indexOf(submittedSelection.text);
if (resolvedIndex < 0) {
this.showStatus("Queue changed; reorder not applied");
return;
}
const selected = { ...submittedSelection, index: resolvedIndex };
const queueBefore = this.connectionQueue;
const selected = this.queueSelection.selected;
if (!selected) return;
const status = await this.agentConnection.mutateQueuedMessage(selected.lane, selected.index, selected.text, {
type: "move",
direction,
});
if (sessionGeneration !== this.sessionEventGeneration) return;
if (status === "applied") {
// The queue event for this mutation can land before or after the
// response. Patch the mirror only when no event has replaced it
// meanwhile (events always assign a fresh object); patching an
// already-updated mirror would apply the mutation twice.
const lane = this.connectionQueue[selected.lane];
const target = selected.index + direction;
if (
this.connectionQueue === queueBefore &&
lane[selected.index] === selected.text &&
target >= 0 &&
target < lane.length
) {
[lane[selected.index], lane[target]] = [lane[target] as string, selected.text];
this.queueSelection.sync(this.connectionQueue);
this.updatePendingMessagesDisplay();
this.ui.requestRender();
}
await this.refreshConnectionQueue();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
this.queueSelection.refreshAfterMove(this.getConnectionQueue(), selected.lane, selected.index + direction);
this.ui.requestRender();
} else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon");
else this.showStatus("Queue changed; reorder not applied");
}).catch((error) => {
Expand All @@ -7064,9 +7038,7 @@ export class InteractiveMode {
* Empty text deletes; otherwise replaces, moving the item to `targetLane`.
*/
private applyQueueSelection(text: string, targetLane: "steering" | "followUp"): Promise<boolean> {
if (this.pendingQueueEdit) return Promise.resolve(false);
const submittedSelection = this.queueSelection.selected;
if (!submittedSelection) return Promise.resolve(false);
if (this.pendingQueueEdit || !this.queueSelection.selected) return Promise.resolve(false);
const pendingQueueEdit = Symbol("pending-queue-edit");
this.pendingQueueEdit = pendingQueueEdit;
const sessionGeneration = this.sessionEventGeneration;
Expand Down Expand Up @@ -7096,27 +7068,8 @@ export class InteractiveMode {
};
return this.enqueueQueueMutation(async () => {
if (discardStaleSelection()) return true;
// Earlier serialized moves may have changed the selected item's index.
const lane = this.connectionQueue[submittedSelection.lane];
const resolvedIndex =
lane[submittedSelection.index] === submittedSelection.text
? submittedSelection.index
: lane.indexOf(submittedSelection.text);
if (resolvedIndex < 0) {
this.queueSelection.sync(this.connectionQueue);
const editorUntouched =
submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore;
if (editorUntouched) {
this.setEditorTextFromQueueSelection(text);
}
this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText());
this.showStatus("Queue changed; edit kept in the editor");
this.updatePendingMessagesDisplay();
this.ui.requestRender();
return true;
}
const selected = { ...submittedSelection, index: resolvedIndex };
const queueBefore = this.connectionQueue;
const selected = this.queueSelection.selected;
if (!selected) return true;
Comment thread
snimu marked this conversation as resolved.
Resolved
let status: AgentConnectionQueuedMessageMutationStatus;
try {
status = await this.agentConnection.mutateQueuedMessage(
Expand All @@ -7142,22 +7095,11 @@ export class InteractiveMode {
const editorUntouched =
submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore;
if (status === "applied") {
// Same optimistic patch as moveQueueSelection, and the same guard:
// skip when a queue event already replaced the mirror.
const lane = this.connectionQueue[selected.lane];
if (this.connectionQueue === queueBefore && lane[selected.index] === selected.text) {
if (!trimmed) lane.splice(selected.index, 1);
else if (targetLane === selected.lane) lane[selected.index] = trimmed;
else {
lane.splice(selected.index, 1);
this.connectionQueue[targetLane].push(trimmed);
}
}
await this.refreshConnectionQueue();
if (trimmed) this.editor.addToHistory?.(trimmed);
const draft = this.queueSelection.reset();
if (editorUntouched) this.setEditorTextFromQueueSelection(draft);
} else {
this.queueSelection.sync(this.connectionQueue);
// Enter submissions clear the editor before onSubmit runs; restore the
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
// edit so a failed mutation never swallows it.
if (editorUntouched) this.setEditorTextFromQueueSelection(text);
Expand Down Expand Up @@ -7422,10 +7364,7 @@ export class InteractiveMode {
}

private getAllQueuedMessages(): { steering: string[]; followUp: string[] } {
return {
steering: [...this.connectionQueue.steering],
followUp: [...this.connectionQueue.followUp],
};
return this.getConnectionQueue();
}

private updatePendingMessagesDisplay(): void {
Expand Down
20 changes: 2 additions & 18 deletions packages/coding-agent/src/modes/interactive/queue-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ export class QueueSelection {
if (direction > 0) return undefined;
this.items = flatten(queue);
if (this.items.length === 0) return undefined;
// A drop (sync) keeps the previous draft stashed; do not overwrite it
// with the dropped item's text still sitting in the editor.
if (!this.hasStashedDraft) {
this.draft = draft;
this.hasStashedDraft = true;
Expand All @@ -62,23 +60,9 @@ export class QueueSelection {
return this.items[next]?.text;
}

/**
* Track queue changes while browsing: keep the selection when its text is
Comment thread
snimu marked this conversation as resolved.
Resolved
* still present. Returns the dropped item's text when the selection could
* not be kept, so the caller can restore the stashed draft.
*/
sync(queue: AgentConnectionQueueState): string | undefined {
const selected = this.selected;
refreshAfterMove(queue: AgentConnectionQueueState, lane: QueueLane, index: number): void {
this.items = flatten(queue);
if (!selected) return undefined;
const exact = this.items[selected.lane === "steering" ? selected.index : queue.steering.length + selected.index];
if (exact?.lane === selected.lane && exact.text === selected.text) {
this.cursor = this.items.indexOf(exact);
return undefined;
}
const retargeted = this.items.find((item) => item.lane === selected.lane && item.text === selected.text);
this.cursor = retargeted ? this.items.indexOf(retargeted) : -1;
return retargeted ? undefined : selected.text;
this.cursor = lane === "steering" ? index : queue.steering.length + index;
}

/** Called after a mutation or submit resolved the selection. Returns the stashed draft. */
Expand Down
10 changes: 6 additions & 4 deletions packages/coding-agent/test/interactive-mode-ctrl-c.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ type FakeInteractiveMode = {
retryAttempt: number;
sessionActions: { queuedCount: number; steering: readonly string[]; followUps: readonly string[] };
};
connectionQueue: { steering: string[]; followUp: string[] };
agentConnection: {
abort: Mock;
clearQueue: Mock;
Expand Down Expand Up @@ -98,7 +97,6 @@ function createInteractiveFake(options: {
retryAttempt: options.retryAttempt ?? 0,
sessionActions: { queuedCount: 0, steering: [], followUps: [] },
},
connectionQueue: { steering: [], followUp: [] },
agentConnection: {
abort: vi.fn().mockResolvedValue(undefined),
clearQueue: vi.fn().mockResolvedValue({ steering: [], followUp: [] }),
Expand Down Expand Up @@ -172,15 +170,19 @@ describe("InteractiveMode interrupt shortcuts", () => {

it("preserves the queue and the draft when interrupting streaming", () => {
const mode = createInteractiveFake({ editorText: "draft", streaming: true });
mode.connectionQueue = { steering: ["steer"], followUp: ["follow"] };
mode.connectionState.sessionActions = { queuedCount: 2, steering: ["steer"], followUps: ["follow"] };

Reflect.get(InteractiveMode.prototype, "handleCtrlC").call(mode);

expect(mode.agentConnection.abort).toHaveBeenCalledTimes(1);
expect(mode.agentConnection.abortAndClearQueue).not.toHaveBeenCalled();
expect(mode.agentConnection.clearQueue).not.toHaveBeenCalled();
expect(mode.editor.getText()).toBe("draft");
expect(mode.connectionQueue).toEqual({ steering: ["steer"], followUp: ["follow"] });
expect(mode.connectionState.sessionActions).toEqual({
queuedCount: 2,
steering: ["steer"],
followUps: ["follow"],
});
});

it("exits on the second Ctrl+C while the hint is visible", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ function createMode() {
featureHintContainer,
loadingAnimation: loader,
workingVisible: true,
connectionState: { isStreaming: true },
connectionState: {
isStreaming: true,
sessionActions: { queuedCount: 0, steering: [], followUps: [] },
},
workingTimer: undefined,
workingStartedAt: 0,
featureHintDeck,
Expand All @@ -43,7 +46,6 @@ function createMode() {
featureHintAnimationTimer: undefined,
featureHintComponent: undefined,
featureHintRunPending: false,
connectionQueue: { steering: [], followUp: [] },
compactionQueuedMessages: [],
options: { returnToAgentsView: true },
ui: { requestRender },
Expand Down
13 changes: 7 additions & 6 deletions packages/coding-agent/test/interactive-mode-prompt-stash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ type PromptStashHarness = {
};

type PromptStashLiveMarkerHarness = PromptStashHarness & {
connectionQueue: { steering: string[]; followUp: string[] };
connectionState: {
sessionActions: { queuedCount: number; steering: readonly string[]; followUps: readonly string[] };
};
};

type SharedPromptStashHarness = PromptStashHarness & {
Expand Down Expand Up @@ -628,13 +630,13 @@ describe("InteractiveMode prompt stash", () => {
expect(mode.editor.getText()).toBe("half-written draft");
});

it("drops queued image references from old sessions while keeping stashed images", () => {
it("drops old-session images while keeping stashed images", () => {
const base = createPromptStashHarness({ stash: "keep [image #1]" });
const mode: ResetHarness = {
...base,
defaultEditor: base.editor,
queueSelection: new QueueSelection(),
connectionQueue: { steering: ["old [image #2]"], followUp: [] },
connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } },
chatContainer: { clear: vi.fn() },
shortcutGuideContainer: { clear: vi.fn() },
pendingMessagesContainer: { clear: vi.fn() },
Expand All @@ -661,7 +663,6 @@ describe("InteractiveMode prompt stash", () => {

interactiveModeMethods.resetCurrentSessionRenderState.call(mode);

expect(mode.connectionQueue).toEqual({ steering: [], followUp: [] });
expect(mode.promptStash?.text).toBe("keep [image #1]");
expect(mode.pastedImages.has(1)).toBe(true);
expect(mode.pastedImages.has(2)).toBe(false);
Expand All @@ -673,7 +674,7 @@ describe("InteractiveMode prompt stash", () => {
...base,
defaultEditor: base.editor,
queueSelection: new QueueSelection(),
connectionQueue: { steering: [], followUp: [] },
connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } },
chatContainer: { clear: vi.fn() },
shortcutGuideContainer: { clear: vi.fn() },
pendingMessagesContainer: { clear: vi.fn() },
Expand Down Expand Up @@ -818,7 +819,7 @@ describe("InteractiveMode prompt stash", () => {
it("keeps image markers in a stashed prompt live", () => {
const mode: PromptStashLiveMarkerHarness = {
...createPromptStashHarness({ stash: "look at [image #7]" }),
connectionQueue: { steering: [], followUp: [] },
connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } },
};
Object.setPrototypeOf(mode, InteractiveMode.prototype);

Expand Down
Loading