mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 08:52:03 +00:00
Add robust queued-message UX and focused-filter restore fixes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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).";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user