feat(cli): historique de messages navigable haut/bas dans la CLI custom + contrôles de cellule visibles pendant busy — persistance localStorage du transcript custom (CustomAgentChatView), layering des contrôles sous l'overlay busy (LayoutGrid), bump pointeur IdeaSDK (docs plugin minimal + hot reload) (QA verte: 65 tests CustomAgentChatView, 25 tests LayoutGrid chat/layering, npm build frontend OK)

This commit is contained in:
2026-09-08 09:39:35 +02:00
parent 154728b828
commit fc3d124574
6 changed files with 350 additions and 48 deletions

View File

@ -140,6 +140,7 @@ function setMeasuredTextOverflow(
describe("CustomAgentChatView", () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
__clearCustomAgentChatViewCacheForTests();
__resetProjectCliInteractionActivityForTests();
});
@ -236,6 +237,144 @@ describe("CustomAgentChatView", () => {
expect(screen.queryByRole("button", { name: "Ouvrir en TUI native" })).toBeNull();
});
it("recalls previously sent prompts with arrow up and down", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
async (_sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void) => {
onChunk({ kind: "final", content: `answer ${prompt}` });
},
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
layoutId="layout-1"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
fireEvent.change(composer, { target: { value: "first prompt" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"first prompt",
expect.any(Function),
));
fireEvent.change(composer, { target: { value: "second prompt" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"second prompt",
expect.any(Function),
));
fireEvent.keyDown(composer, { key: "ArrowUp" });
await waitFor(() => expect(composer.value).toBe("second prompt"));
fireEvent.keyDown(composer, { key: "ArrowUp" });
await waitFor(() => expect(composer.value).toBe("first prompt"));
fireEvent.keyDown(composer, { key: "ArrowDown" });
await waitFor(() => expect(composer.value).toBe("second prompt"));
fireEvent.change(composer, { target: { value: "draft in progress" } });
fireEvent.keyDown(composer, { key: "ArrowUp" });
await waitFor(() => expect(composer.value).toBe("second prompt"));
fireEvent.keyDown(composer, { key: "ArrowDown" });
await waitFor(() => expect(composer.value).toBe("draft in progress"));
});
it("persists prompt history across a custom chat remount", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
async (_sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void) => {
onChunk({ kind: "final", content: `answer ${prompt}` });
},
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const props = {
projectId: "project-1",
agentId: "agent-1",
agentName: "Worker",
profile,
cwd: "/repo",
layoutId: "layout-1",
nodeId: "node-1",
sessionId: "chat-session-1",
conversationId: "conversation-1",
onSessionId: vi.fn(),
onConversationId: vi.fn(),
};
const view = render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView {...props} />
</DIProvider>,
);
let composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "remember me" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"remember me",
expect.any(Function),
));
view.unmount();
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView {...props} />
</DIProvider>,
);
composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.keyDown(composer, { key: "ArrowUp" });
await waitFor(() => expect(composer.value).toBe("remember me"));
});
it("repaints attachment labels from user prompt scrollback", async () => {
const agent = {
launchAgentChat: vi.fn(),

View File

@ -83,6 +83,7 @@ const chatConversationViewCache = new Map<string, CachedChatView>();
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
const COMPOSER_RESIZE_STEP = 12;
const FALLBACK_COMPOSER_HEIGHT = 64;
const COMPOSER_HISTORY_LIMIT = 100;
const MIN_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
const MAX_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2100, 0, 1);
const MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor(
@ -416,6 +417,55 @@ function conversationCacheKey(
return conversationId ? `${projectId}::${agentId}::${conversationId}` : null;
}
function composerHistoryStorageKey(
projectId: string,
agentId: string,
layoutId: string | undefined,
nodeId: string | undefined,
): string {
return [
"idea.custom-chat-history",
projectId,
layoutId ?? "default",
nodeId ?? "agent",
agentId,
].join(".");
}
function readComposerHistory(storageKey: string): string[] {
if (typeof window === "undefined") return [];
try {
const parsed: unknown = JSON.parse(window.localStorage.getItem(storageKey) ?? "[]");
if (!Array.isArray(parsed)) return [];
return parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean)
.slice(-COMPOSER_HISTORY_LIMIT);
} catch {
return [];
}
}
function writeComposerHistory(storageKey: string, history: string[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
storageKey,
JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)),
);
} catch {
/* local history only */
}
}
function appendComposerHistoryEntry(history: string[], text: string): string[] {
const entry = text.trim();
if (!entry) return history;
const withoutDuplicateTail = history.at(-1) === entry ? history.slice(0, -1) : history;
return [...withoutDuplicateTail, entry].slice(-COMPOSER_HISTORY_LIMIT);
}
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
let key = progressUpsertKey(progress, state.progressScope);
let existingIndex = state.turns.findIndex(
@ -953,6 +1003,7 @@ export function CustomAgentChatView({
const lastPersistedDraftRef = useRef("");
const draftRef = useRef("");
draftRef.current = draft;
const draftBeforeHistoryNavigationRef = useRef("");
const attachmentsRef = useRef<AttachmentDraft[]>([]);
attachmentsRef.current = attachments;
const imageTokenSeqRef = useRef(0);
@ -1061,6 +1112,14 @@ export function CustomAgentChatView({
() => conversationCacheKey(projectId, agentId, conversationId),
[agentId, conversationId, projectId],
);
const composerHistoryKey = useMemo(
() => composerHistoryStorageKey(projectId, agentId, layoutId, nodeId),
[agentId, layoutId, nodeId, projectId],
);
const [composerHistory, setComposerHistory] = useState<string[]>(() =>
readComposerHistory(composerHistoryKey),
);
const [, setComposerHistoryIndex] = useState<number | null>(null);
const displayTurns = turns;
const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
const [requesterDelegation, setRequesterDelegation] =
@ -1127,9 +1186,82 @@ export function CustomAgentChatView({
const editDraft = useCallback((text: string) => {
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = "";
setDraft(text);
}, []);
const recallComposerHistory = useCallback(
(direction: "previous" | "next") => {
if (composerHistory.length === 0) return;
setComposerHistoryIndex((current) => {
if (current === null && direction === "previous") {
draftBeforeHistoryNavigationRef.current = draftRef.current;
}
const nextIndex =
direction === "previous"
? current === null
? composerHistory.length - 1
: Math.max(0, current - 1)
: current === null
? null
: current >= composerHistory.length - 1
? null
: current + 1;
if (nextIndex === null) {
setDraft(draftBeforeHistoryNavigationRef.current);
draftBeforeHistoryNavigationRef.current = "";
} else {
setDraft(composerHistory[nextIndex] ?? "");
}
return nextIndex;
});
},
[composerHistory],
);
const handleComposerHistoryKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
if (
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
) {
return false;
}
const target = event.currentTarget;
if (
event.key === "ArrowUp" &&
target.value.slice(0, target.selectionStart).includes("\n")
) {
return false;
}
if (
event.key === "ArrowDown" &&
target.value.slice(target.selectionEnd).includes("\n")
) {
return false;
}
event.preventDefault();
recallComposerHistory(event.key === "ArrowUp" ? "previous" : "next");
return true;
},
[recallComposerHistory],
);
useEffect(() => {
const restored = readComposerHistory(composerHistoryKey);
setComposerHistory(restored);
setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = "";
}, [composerHistoryKey]);
useEffect(() => {
writeComposerHistory(composerHistoryKey, composerHistory);
}, [composerHistory, composerHistoryKey]);
useEffect(() => {
void refreshRequesterDelegation();
}, [refreshRequesterDelegation]);
@ -1913,6 +2045,11 @@ export function CustomAgentChatView({
markProjectCliInteractionFinished(projectId, interactionId);
activeInteractionIdRef.current = null;
};
if (text) {
setComposerHistory((prev) => appendComposerHistoryEntry(prev, text));
setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = "";
}
setDraft("");
setAttachments([]);
imageTokenSeqRef.current = 0;
@ -2373,6 +2510,7 @@ export function CustomAgentChatView({
return;
}
}
if (handleComposerHistoryKeyDown(e)) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void send();