Add robust queued-message UX and focused-filter restore fixes

This commit is contained in:
George Pickett
2026-02-27 15:18:49 -08:00
parent 1c9afdd4e2
commit 9015500ed1
9 changed files with 385 additions and 11 deletions
+5
View File
@@ -759,6 +759,7 @@ const AgentStudioPage = () => {
flushPendingDraft,
handleDraftChange,
handleSend,
removeQueuedMessage,
handleNewSession,
handleStopRun,
queueLivePatch,
@@ -766,6 +767,7 @@ const AgentStudioPage = () => {
} = useChatInteractionController({
client,
status,
agents,
dispatch,
setError,
getAgents: () => stateRef.current.agents,
@@ -1660,6 +1662,9 @@ const AgentStudioPage = () => {
onSend={(message) =>
handleSend(focusedAgent.agentId, focusedAgent.sessionKey, message)
}
onRemoveQueuedMessage={(index) =>
removeQueuedMessage(focusedAgent.agentId, index)
}
onStopRun={() => handleStopRun(focusedAgent.agentId, focusedAgent.sessionKey)}
onAvatarShuffle={() => handleAvatarShuffle(focusedAgent.agentId)}
pendingExecApprovals={focusedPendingExecApprovals}
@@ -13,7 +13,7 @@ import {
import type { AgentState as AgentRecord } from "@/features/agents/state/store";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Check, ChevronRight, Clock, Cog, Pencil, Shuffle, X } from "lucide-react";
import { Check, ChevronRight, Clock, Cog, Pencil, Shuffle, Trash2, X } from "lucide-react";
import type { GatewayModelChoice } from "@/lib/gateway/models";
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
@@ -128,6 +128,7 @@ type AgentChatPanelProps = {
onThinkingTracesToggle?: (enabled: boolean) => void;
onDraftChange: (value: string) => void;
onSend: (message: string) => void;
onRemoveQueuedMessage?: (index: number) => void;
onStopRun: () => void;
onAvatarShuffle: () => void;
pendingExecApprovals?: PendingExecApproval[];
@@ -825,7 +826,6 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
});
const noopToggle = () => {};
const InlineHoverTooltip = ({
text,
children,
@@ -857,6 +857,8 @@ const AgentChatComposer = memo(function AgentChatComposer({
stopDisabledReason,
running,
sendDisabled,
queuedMessages,
onRemoveQueuedMessage,
inputRef,
modelOptions,
modelValue,
@@ -879,6 +881,8 @@ const AgentChatComposer = memo(function AgentChatComposer({
stopDisabledReason?: string | null;
running: boolean;
sendDisabled: boolean;
queuedMessages: string[];
onRemoveQueuedMessage?: (index: number) => void;
inputRef: (el: HTMLTextAreaElement | HTMLInputElement | null) => void;
modelOptions: { value: string; label: string }[];
modelValue: string;
@@ -920,6 +924,65 @@ const AgentChatComposer = memo(function AgentChatComposer({
const thinkingSelectWidthCh = Math.max(9, Math.min(22, thinkingSelectedLabel.length + 6));
return (
<div className="rounded-2xl border border-border/65 bg-surface-2/45 px-3 py-2">
{queuedMessages.length > 0 ? (
<div
className={`mb-2 grid items-start gap-2 ${
running ? "grid-cols-[minmax(0,1fr)_auto_auto]" : "grid-cols-[minmax(0,1fr)_auto]"
}`}
>
<div
className="min-w-0 max-w-full space-y-1 overflow-hidden"
data-testid="queued-messages-bar"
aria-label="Queued messages"
>
{queuedMessages.map((queuedMessage, index) => (
<div
key={`${index}-${queuedMessage}`}
className="flex w-full min-w-0 max-w-full items-center gap-1 overflow-hidden rounded-md border border-border/70 bg-card/80 px-2 py-1 text-[11px] text-foreground"
>
<span className="font-mono text-[10px] uppercase tracking-[0.06em] text-muted-foreground">
Queued
</span>
<span
className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
title={queuedMessage}
>
{queuedMessage}
</span>
<button
type="button"
className="inline-flex h-4 w-4 flex-none items-center justify-center rounded-sm text-muted-foreground transition hover:bg-surface-2 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove queued message ${index + 1}`}
onClick={() => onRemoveQueuedMessage?.(index)}
disabled={!onRemoveQueuedMessage}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
{running ? (
<button
type="button"
aria-hidden="true"
tabIndex={-1}
disabled
className="invisible rounded-md border border-border/70 bg-surface-3 px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em] text-foreground"
>
{stopBusy ? "Stopping" : "Stop"}
</button>
) : null}
<button
type="button"
aria-hidden="true"
tabIndex={-1}
disabled
className="ui-btn-primary ui-btn-send invisible px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em]"
>
Send
</button>
</div>
) : null}
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
@@ -1052,6 +1115,7 @@ export const AgentChatPanel = ({
onThinkingTracesToggle = noopToggle,
onDraftChange,
onSend,
onRemoveQueuedMessage,
onStopRun,
onAvatarShuffle,
pendingExecApprovals = [],
@@ -1142,7 +1206,7 @@ export const AgentChatPanel = ({
const handleSend = useCallback(
(message: string) => {
if (!canSend || agent.status === "running") return;
if (!canSend) return;
const trimmed = message.trim();
if (!trimmed) return;
plainDraftRef.current = "";
@@ -1151,7 +1215,7 @@ export const AgentChatPanel = ({
scrollToBottomNextOutputRef.current = true;
onSend(trimmed);
},
[agent.status, canSend, onDraftChange, onSend]
[canSend, onDraftChange, onSend]
);
const chatItems = useMemo(
@@ -1204,7 +1268,7 @@ export const AgentChatPanel = ({
() => resolveEmptyChatIntroMessage(agent.agentId, agent.sessionEpoch),
[agent.agentId, agent.sessionEpoch]
);
const sendDisabled = !canSend || running || !draftValue.trim();
const sendDisabled = !canSend || !draftValue.trim();
const handleComposerChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
@@ -1476,6 +1540,8 @@ export const AgentChatPanel = ({
stopDisabledReason={stopDisabledReason}
running={running}
sendDisabled={sendDisabled}
queuedMessages={agent.queuedMessages ?? []}
onRemoveQueuedMessage={onRemoveQueuedMessage}
modelOptions={modelOptionsWithFallback.map((option) => ({
value: option.value,
label: option.label,
@@ -150,8 +150,9 @@ export function planFocusedPreferenceRestore(params: {
};
}
const restoredFilter = preference.filter === "running" ? "all" : preference.filter;
return {
preferredSelectedAgentId: preference.selectedAgentId,
focusFilter: preference.filter,
focusFilter: restoredFilter,
};
}
@@ -14,7 +14,10 @@ import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
type ChatInteractionDispatchAction =
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
| { type: "appendOutput"; agentId: string; line: string };
| { type: "appendOutput"; agentId: string; line: string }
| { type: "enqueueQueuedMessage"; agentId: string; message: string }
| { type: "removeQueuedMessage"; agentId: string; index: number }
| { type: "shiftQueuedMessage"; agentId: string; expectedMessage?: string };
type GatewayClientLike = {
call: (method: string, params: unknown) => Promise<unknown>;
@@ -23,6 +26,7 @@ type GatewayClientLike = {
export type UseChatInteractionControllerParams = {
client: GatewayClientLike;
status: GatewayStatus;
agents: AgentState[];
dispatch: (action: ChatInteractionDispatchAction) => void;
setError: (message: string) => void;
getAgents: () => AgentState[];
@@ -40,6 +44,7 @@ export type ChatInteractionController = {
flushPendingDraft: (agentId: string | null) => void;
handleDraftChange: (agentId: string, value: string) => void;
handleSend: (agentId: string, sessionKey: string, message: string) => Promise<void>;
removeQueuedMessage: (agentId: string, index: number) => void;
handleNewSession: (agentId: string) => Promise<void>;
handleStopRun: (agentId: string, sessionKey: string) => Promise<void>;
queueLivePatch: (agentId: string, patch: Partial<AgentState>) => void;
@@ -54,6 +59,7 @@ export function useChatInteractionController(
const pendingDraftValuesRef = useRef<Map<string, string>>(new Map());
const pendingDraftTimersRef = useRef<Map<string, number>>(new Map());
const pendingLivePatchesRef = useRef<Map<string, Partial<AgentState>>>(new Map());
const activeQueueSendAgentIdsRef = useRef<Set<string>>(new Set());
const flushLivePatchesRef = useRef<() => void>(() => {});
const livePatchBatcherRef = useRef(createRafBatcher(() => flushLivePatchesRef.current()));
@@ -189,7 +195,27 @@ export function useChatInteractionController(
pendingDraftTimersRef.current.delete(agentId);
}
pendingDraftValuesRef.current.delete(agentId);
clearPendingLivePatch(agentId);
const agent =
params.agents.find((entry) => entry.agentId === agentId) ??
params.getAgents().find((entry) => entry.agentId === agentId) ??
null;
if (!agent) {
params.dispatch({
type: "appendOutput",
agentId,
line: "Error: Agent not found.",
});
return;
}
if (agent.status === "running") {
params.dispatch({
type: "enqueueQueuedMessage",
agentId,
message: trimmed,
});
return;
}
clearPendingLivePatch(agent.agentId);
await sendChatMessageViaStudio({
client: params.client,
dispatch: params.dispatch,
@@ -204,6 +230,65 @@ export function useChatInteractionController(
[clearPendingLivePatch, params]
);
const removeQueuedMessage = useCallback(
(agentId: string, index: number) => {
if (!Number.isInteger(index) || index < 0) return;
params.dispatch({
type: "removeQueuedMessage",
agentId,
index,
});
},
[params]
);
const sendNextQueuedMessage = useCallback(
async (agent: Pick<AgentState, "agentId" | "sessionKey"> & { nextMessage: string }) => {
if (params.status !== "connected") return;
const nextMessage = agent.nextMessage.trim();
if (!nextMessage) return;
params.dispatch({
type: "shiftQueuedMessage",
agentId: agent.agentId,
expectedMessage: nextMessage,
});
clearPendingLivePatch(agent.agentId);
await sendChatMessageViaStudio({
client: params.client,
dispatch: params.dispatch,
getAgent: (currentAgentId) =>
params.getAgents().find((entry) => entry.agentId === currentAgentId) ?? null,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: nextMessage,
clearRunTracking: (runId) => params.clearRunTracking(runId),
});
},
[clearPendingLivePatch, params]
);
useEffect(() => {
if (params.status !== "connected") return;
for (const agent of params.agents) {
if (agent.status !== "idle") continue;
const nextMessage = agent.queuedMessages?.[0];
if (!nextMessage) continue;
if (activeQueueSendAgentIdsRef.current.has(agent.agentId)) continue;
activeQueueSendAgentIdsRef.current.add(agent.agentId);
void (async () => {
try {
await sendNextQueuedMessage({
agentId: agent.agentId,
sessionKey: agent.sessionKey,
nextMessage,
});
} finally {
activeQueueSendAgentIdsRef.current.delete(agent.agentId);
}
})();
}
}, [params.agents, params.status, sendNextQueuedMessage]);
const handleStopRun = useCallback(
async (agentId: string, sessionKey: string) => {
const stopIntent = planStopRunIntent({
@@ -294,6 +379,7 @@ export function useChatInteractionController(
flushPendingDraft,
handleDraftChange,
handleSend,
removeQueuedMessage,
handleNewSession,
handleStopRun,
queueLivePatch,
+48
View File
@@ -56,6 +56,7 @@ export type AgentState = AgentStoreSeed & {
latestPreview: string | null;
lastUserMessage: string | null;
draft: string;
queuedMessages?: string[];
sessionSettingsSynced: boolean;
historyLoadedAt: number | null;
historyFetchLimit: number | null;
@@ -89,6 +90,7 @@ export const buildNewSessionAgentPatch = (agent: AgentState): Partial<AgentState
latestPreview: null,
lastUserMessage: null,
draft: "",
queuedMessages: [],
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
@@ -119,6 +121,9 @@ type Action =
| { type: "setLoading"; loading: boolean }
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
| { type: "appendOutput"; agentId: string; line: string; transcript?: TranscriptAppendMeta }
| { type: "enqueueQueuedMessage"; agentId: string; message: string }
| { type: "removeQueuedMessage"; agentId: string; index: number }
| { type: "shiftQueuedMessage"; agentId: string; expectedMessage?: string }
| { type: "markActivity"; agentId: string; at?: number }
| { type: "selectAgent"; agentId: string | null };
@@ -164,6 +169,7 @@ const createRuntimeAgentState = (
): AgentState => {
const sameSessionKey = existing?.sessionKey === seed.sessionKey;
const outputLines = sameSessionKey ? (existing?.outputLines ?? []) : [];
const queuedMessages = sameSessionKey ? [...(existing?.queuedMessages ?? [])] : [];
const transcriptEntries = sameSessionKey
? Array.isArray(existing?.transcriptEntries)
? existing.transcriptEntries
@@ -202,6 +208,7 @@ const createRuntimeAgentState = (
latestPreview: sameSessionKey ? (existing?.latestPreview ?? null) : null,
lastUserMessage: sameSessionKey ? (existing?.lastUserMessage ?? null) : null,
draft: sameSessionKey ? (existing?.draft ?? "") : "",
queuedMessages,
sessionSettingsSynced: sameSessionKey ? (existing?.sessionSettingsSynced ?? false) : false,
historyLoadedAt: sameSessionKey ? (existing?.historyLoadedAt ?? null) : null,
historyFetchLimit: sameSessionKey ? (existing?.historyFetchLimit ?? null) : null,
@@ -400,6 +407,47 @@ const reducer = (state: AgentStoreState, action: Action): AgentStoreState => {
};
}),
};
case "enqueueQueuedMessage":
return {
...state,
agents: state.agents.map((agent) => {
if (agent.agentId !== action.agentId) return agent;
const message = action.message.trim();
if (!message) return agent;
const queuedMessages = [...(agent.queuedMessages ?? []), message];
return { ...agent, queuedMessages };
}),
};
case "removeQueuedMessage":
return {
...state,
agents: state.agents.map((agent) => {
if (agent.agentId !== action.agentId) return agent;
if (!Number.isInteger(action.index) || action.index < 0) return agent;
const queuedMessages = agent.queuedMessages ?? [];
if (action.index >= queuedMessages.length) return agent;
return {
...agent,
queuedMessages: queuedMessages.filter((_, index) => index !== action.index),
};
}),
};
case "shiftQueuedMessage":
return {
...state,
agents: state.agents.map((agent) => {
if (agent.agentId !== action.agentId) return agent;
const queuedMessages = agent.queuedMessages ?? [];
if (queuedMessages.length === 0) return agent;
if (
action.expectedMessage !== undefined &&
action.expectedMessage.trim() !== queuedMessages[0]
) {
return agent;
}
return { ...agent, queuedMessages: queuedMessages.slice(1) };
}),
};
case "markActivity": {
const at = action.at ?? Date.now();
return {
@@ -329,6 +329,59 @@ describe("AgentChatPanel controls", () => {
expect(onStopRun).toHaveBeenCalledTimes(1);
});
it("allows_send_while_running_so_follow_up_can_be_queued", () => {
const onSend = vi.fn();
render(
createElement(AgentChatPanel, {
agent: { ...createAgent(), status: "running" },
isSelected: true,
canSend: true,
models,
stopBusy: false,
onLoadMoreHistory: vi.fn(),
onOpenSettings: vi.fn(),
onModelChange: vi.fn(),
onThinkingChange: vi.fn(),
onDraftChange: vi.fn(),
onSend,
onStopRun: vi.fn(),
onAvatarShuffle: vi.fn(),
})
);
const textarea = screen.getByPlaceholderText("type a message");
fireEvent.change(textarea, { target: { value: "follow up" } });
fireEvent.click(screen.getByRole("button", { name: "Send" }));
expect(onSend).toHaveBeenCalledWith("follow up");
});
it("renders_queue_bar_and_supports_removing_queued_messages", () => {
const onRemoveQueuedMessage = vi.fn();
render(
createElement(AgentChatPanel, {
agent: { ...createAgent(), queuedMessages: ["first queued", "second queued"] },
isSelected: true,
canSend: true,
models,
stopBusy: false,
onLoadMoreHistory: vi.fn(),
onOpenSettings: vi.fn(),
onModelChange: vi.fn(),
onThinkingChange: vi.fn(),
onDraftChange: vi.fn(),
onSend: vi.fn(),
onRemoveQueuedMessage,
onStopRun: vi.fn(),
onAvatarShuffle: vi.fn(),
})
);
expect(screen.getByTestId("queued-messages-bar")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Remove queued message 1" }));
expect(onRemoveQueuedMessage).toHaveBeenCalledWith(0);
});
it("disables_stop_button_with_tooltip_when_stop_is_unavailable", () => {
const stopDisabledReason =
"This task is running as an automatic heartbeat check. Stopping heartbeat runs from Studio isn't available yet (coming soon).";
+2 -2
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentState, AgentStoreSeed } from "@/features/agents/state/store";
import type { AgentStoreSeed } from "@/features/agents/state/store";
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import type { StudioSettingsPatch } from "@/lib/studio/settings";
@@ -174,7 +174,7 @@ describe("studioBootstrapOperation", () => {
},
{
kind: "set-focus-filter",
filter: "running",
filter: "all",
},
{
kind: "set-focused-preferences-loaded",
@@ -178,4 +178,30 @@ describe("studioBootstrapWorkflow", () => {
focusFilter: "all",
});
});
it("restores running filter as all", () => {
const settings: StudioSettings = {
version: 1,
gateway: null,
focused: {
"https://gateway.test": {
mode: "focused",
selectedAgentId: "agent-7",
filter: "running",
},
},
avatars: {},
};
expect(
planFocusedPreferenceRestore({
settings,
gatewayKey: "https://gateway.test",
focusFilterTouched: false,
})
).toEqual({
preferredSelectedAgentId: "agent-7",
focusFilter: "all",
});
});
});
@@ -33,6 +33,7 @@ const createAgent = (overrides?: Partial<AgentState>): AgentState => {
latestPreview: null,
lastUserMessage: null,
draft: "",
queuedMessages: [],
sessionSettingsSynced: true,
historyLoadedAt: null,
historyFetchLimit: null,
@@ -58,7 +59,10 @@ type ControllerValue = ReturnType<typeof useChatInteractionController>;
type GatewayStatus = "disconnected" | "connecting" | "connected";
type InteractionDispatchAction =
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
| { type: "appendOutput"; agentId: string; line: string };
| { type: "appendOutput"; agentId: string; line: string }
| { type: "enqueueQueuedMessage"; agentId: string; message: string }
| { type: "removeQueuedMessage"; agentId: string; index: number }
| { type: "shiftQueuedMessage"; agentId: string; expectedMessage?: string };
type CallFn = (method: string, params: unknown) => Promise<unknown>;
type DispatchFn = (action: InteractionDispatchAction) => void;
type ErrorFn = (message: string) => void;
@@ -130,6 +134,7 @@ const renderController = (
call,
},
status: overrides?.status ?? "connected",
agents,
dispatch,
setError,
getAgents: () => agents,
@@ -286,6 +291,90 @@ describe("useChatInteractionController", () => {
).toBe(false);
});
it("queues messages instead of sending while the agent is running", async () => {
const ctx = renderController({
agents: [createAgent({ status: "running", queuedMessages: [] })],
});
await act(async () => {
await ctx.getValue().handleSend("agent-1", "session-1", " follow up ");
});
expect(mockedSendChatMessageViaStudio).not.toHaveBeenCalled();
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "enqueueQueuedMessage",
agentId: "agent-1",
message: "follow up",
});
});
it("drains one queued message when an agent becomes idle", async () => {
const ctx = renderController({
agents: [createAgent({ status: "running", queuedMessages: ["next message"] })],
});
act(() => {
ctx.setAgents([
createAgent({
status: "idle",
sessionKey: "agent:agent-1:studio:drain",
queuedMessages: ["next message"],
}),
]);
});
await act(async () => {
await Promise.resolve();
});
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "shiftQueuedMessage",
agentId: "agent-1",
expectedMessage: "next message",
});
expect(mockedSendChatMessageViaStudio).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-1",
sessionKey: "agent:agent-1:studio:drain",
message: "next message",
})
);
});
it("does not drain queued messages while disconnected", async () => {
const ctx = renderController({
status: "disconnected",
agents: [createAgent({ status: "idle", queuedMessages: ["keep queued"] })],
});
await act(async () => {
await Promise.resolve();
});
expect(mockedSendChatMessageViaStudio).not.toHaveBeenCalled();
expect(
ctx.dispatch.mock.calls.some(
([action]: [InteractionDispatchAction]) => action.type === "shiftQueuedMessage"
)
).toBe(false);
});
it("removes a queued message by index", () => {
const ctx = renderController({
agents: [createAgent({ queuedMessages: ["first", "second"] })],
});
act(() => {
ctx.getValue().removeQueuedMessage("agent-1", 0);
});
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "removeQueuedMessage",
agentId: "agent-1",
index: 0,
});
});
it("deduplicates stop-run while busy and clears busy state after success", async () => {
let resolveAbort: ((value?: void | PromiseLike<void>) => void) | undefined;
const abortPromise = new Promise<void>((resolve) => {