feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): persistance et reprise des conversations de CLI custom par agent — index durable <projet>/.ideai/conversations/custom-index.json borné aux conversations non vides par agent (conversation_id logique, titre dérivé de la première invite, created/updated_at_ms), port CustomConversationIndex + adaptateur FsCustomConversationIndex, persistence des messages utilisateur et finals agent dans le log de conversation existant, DTO/commandes app-tauri et routes web-server pour lister/rouvrir, CustomAgentChatView: ouverture par défaut sur une nouvelle conversation, menu reprise d'une conversation passée ou nouvelle conversation, replay de l'historique sauvegardé (user+finals) à la réouverture, affichage normal des nouveaux messages après reprise, /clean ouvre une nouvelle conversation, aucune entrée créée si la conversation courante est vide, injection de l'historique dans le contexte agent différée au premier nouveau message après reprise — #363 (QA verte: git diff --check, cargo fmt --all -- --check, cargo tests ciblés infrastructure/application/web-server/app-tauri/backend, npm run typecheck, tests frontend ciblés et CustomAgentChatView, npm run build)

This commit is contained in:
2026-09-16 23:20:31 +02:00
parent 83b78f438f
commit 943c4525d7
30 changed files with 1794 additions and 109 deletions

View File

@ -369,6 +369,12 @@ describe("TauriAgentGateway invoke payloads", () => {
cellKind: "chat",
assignedConversationId: "conversation-1",
transcriptUnavailableReason: "missingTranscript",
history: [
{ id: "turn-1", atMs: 42, role: "prompt", source: { kind: "human" }, text: "Hello", textLen: 5 },
],
historyHasMore: true,
historyNextAnchor: "turn-0",
resumed: true,
});
const out = await new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", {
@ -397,6 +403,32 @@ describe("TauriAgentGateway invoke payloads", () => {
cellKind: "chat",
assignedConversationId: "conversation-1",
transcriptUnavailableReason: "missingTranscript",
history: [
{ id: "turn-1", atMs: 42, role: "prompt", source: { kind: "human" }, text: "Hello", textLen: 5 },
],
historyHasMore: true,
historyNextAnchor: "turn-0",
resumed: true,
});
});
it("lists custom conversations with the nested backend request contract", async () => {
invoke.mockResolvedValueOnce({
conversations: [
{
conversationId: "conversation-1",
title: "First prompt",
createdAtMs: 10,
updatedAtMs: 20,
},
],
});
await expect(
new TauriAgentGateway().listCustomAgentConversations("proj-1", "agent-2", 25),
).resolves.toHaveLength(1);
expect(invoke).toHaveBeenCalledWith("list_custom_agent_conversations", {
request: { projectId: "proj-1", agentId: "agent-2", limit: 25 },
});
});

View File

@ -32,6 +32,7 @@ import type {
ChatDraftScope,
ChatDraftSnapshot,
ConversationDetails,
CustomAgentConversationSummary,
CreateAgentInput,
LiveAgent,
OpenTerminalOptions,
@ -55,6 +56,10 @@ interface LaunchAgentResponse {
cellKind?: "chat" | "pty";
/** Conversation id minted by this launch (omitted when nothing was assigned). */
assignedConversationId?: string;
history?: import("@/domain").TurnView[];
historyHasMore?: boolean;
historyNextAnchor?: string;
resumed?: boolean;
transcriptUnavailableReason?: AgentChatHandle["transcriptUnavailableReason"];
}
@ -306,12 +311,32 @@ export class TauriAgentGateway implements AgentGateway {
...(res.assignedConversationId
? { assignedConversationId: res.assignedConversationId }
: {}),
...(res.history ? { history: res.history } : {}),
...(res.historyHasMore !== undefined
? { historyHasMore: res.historyHasMore }
: {}),
...(res.historyNextAnchor
? { historyNextAnchor: res.historyNextAnchor }
: {}),
...(res.resumed !== undefined ? { resumed: res.resumed } : {}),
...(res.transcriptUnavailableReason
? { transcriptUnavailableReason: res.transcriptUnavailableReason }
: {}),
};
}
async listCustomAgentConversations(
projectId: string,
agentId: string,
limit?: number,
): Promise<CustomAgentConversationSummary[]> {
const response = await invoke<{ conversations: CustomAgentConversationSummary[] }>(
"list_custom_agent_conversations",
{ request: { projectId, agentId, ...(limit === undefined ? {} : { limit }) } },
);
return response.conversations;
}
async reattachAgentChat(
sessionId: string,
onChunk: (chunk: ReplyChunk) => void,

View File

@ -14,7 +14,7 @@
* survives JSON.
*/
import type { ReplyChunk } from "@/domain";
import type { ReplyChunk, TurnView } from "@/domain";
// ---------------------------------------------------------------------------
// Base64 helpers (binary PTY bytes over JSON frames)
@ -156,4 +156,8 @@ export interface ChatAttachedPayload {
engineSessionId?: string;
scrollback?: ReplyChunk[];
transcriptUnavailableReason?: "missingTranscript" | "emptyConversation";
history?: TurnView[];
historyHasMore?: boolean;
historyNextAnchor?: string;
resumed?: boolean;
}

View File

@ -48,6 +48,7 @@ import type {
ChatDraftScope,
ChatDraftSnapshot,
ConversationDetails,
CustomAgentConversationSummary,
CreateAgentInput,
CreateTicketInput,
LiveAgent,
@ -389,9 +390,30 @@ export class HttpAgentGateway implements AgentGateway {
...(payload.transcriptUnavailableReason
? { transcriptUnavailableReason: payload.transcriptUnavailableReason }
: {}),
...(payload.history ? { history: payload.history } : {}),
...(payload.historyHasMore !== undefined
? { historyHasMore: payload.historyHasMore }
: {}),
...(payload.historyNextAnchor
? { historyNextAnchor: payload.historyNextAnchor }
: {}),
...(payload.resumed !== undefined ? { resumed: payload.resumed } : {}),
};
}
async listCustomAgentConversations(
projectId: string,
agentId: string,
limit?: number,
): Promise<CustomAgentConversationSummary[]> {
const response = await this.http.invoke<{
conversations: CustomAgentConversationSummary[];
}>("list_custom_agent_conversations", {
request: { projectId, agentId, ...(limit === undefined ? {} : { limit }) },
});
return response.conversations;
}
async reattachAgentChat(
sessionId: string,
onChunk: (chunk: ReplyChunk) => void,

View File

@ -118,6 +118,7 @@ import type {
ConversationGateway,
ConversationPageRequest,
ConversationDetails,
CustomAgentConversationSummary,
CloneProfileFromSeedInput,
CloneOpenCodeProfileFromSeedInput,
CreateAgentInput,
@ -961,6 +962,14 @@ export class MockAgentGateway implements AgentGateway {
};
}
async listCustomAgentConversations(
_projectId: string,
_agentId: string,
_limit?: number,
): Promise<CustomAgentConversationSummary[]> {
return [];
}
async reattachAgentChat(
sessionId: string,
_onChunk: (chunk: ReplyChunk) => void,
@ -1057,6 +1066,7 @@ export class MockAgentGateway implements AgentGateway {
kind: "cleanConversation",
sessionId: options.sessionId,
clearedChunks: 0,
conversationId: `mock-conversation-${options.sessionId}-${Date.now()}`,
},
};
}

View File

@ -1922,7 +1922,12 @@ export interface SlashCommand {
export type SlashCommandEffect =
| { kind: "help"; commands: SlashCommand[] }
| { kind: "cleanConversation"; sessionId: string; clearedChunks: number }
| {
kind: "cleanConversation";
sessionId: string;
clearedChunks: number;
conversationId?: string;
}
| { kind: "profileSwitch"; sessionId: string }
| {
kind: "pluginCallback";

View File

@ -5585,7 +5585,7 @@ describe("CustomAgentChatView", () => {
expect(shell.className).toContain("h-full");
expect(shell.className).toContain("min-h-0");
expect(shell.className).toContain("overflow-hidden");
expect(toolbar.className).toContain("overflow-hidden");
expect(toolbar.className).not.toContain("overflow-hidden");
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
expect(scroll.className).toContain("flex-1");
expect(scroll.className).toContain("basis-0");
@ -6322,4 +6322,120 @@ describe("CustomAgentChatView", () => {
);
expect(agent.launchAgentChat).not.toHaveBeenCalled();
});
it("lists and resumes durable custom conversations without recreating technical history", async () => {
const history = [
{ id: "p1", atMs: 10, role: "prompt" as const, source: { kind: "human" as const }, text: "Saved question", textLen: 14 },
{ id: "tool", atMs: 11, role: "toolActivity" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Hidden tool", textLen: 11 },
{ id: "r1", atMs: 12, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Saved final", textLen: 11 },
];
const agent = {
launchAgentChat: vi.fn()
.mockResolvedValueOnce({ sessionId: "fresh-session", assignedConversationId: "fresh-conversation" })
.mockResolvedValueOnce({
sessionId: "resumed-session",
assignedConversationId: "past-conversation",
history,
historyHasMore: false,
resumed: true,
}),
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
listCustomAgentConversations: vi.fn(async () => [{
conversationId: "past-conversation",
title: "Saved question",
createdAtMs: 10,
updatedAtMs: 12,
}]),
sendAgentChat: vi.fn(async (_sid: string, _prompt: string, onChunk: (chunk: ReplyChunk) => void) => {
onChunk({ kind: "final", content: "New final" });
}),
cancelAgentChat: vi.fn(async () => ({ sessionId: "resumed-session", outcome: "cancelled" as const })),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider gateways={{
agent,
conversation: { readPage: vi.fn() },
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}>
<CustomAgentChatView
freshOnOpen
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId="stale-layout-conversation"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
expect(agent.launchAgentChat.mock.calls[0]?.[2].conversationId).toBeUndefined();
fireEvent.click(screen.getByRole("button", { name: "Conversations" }));
const past = await screen.findByRole("menuitem", { name: /Saved question/ });
fireEvent.click(past);
expect(await screen.findByText("Conversation reprise")).toBeTruthy();
expect(screen.getByText("Saved question")).toBeTruthy();
expect(screen.getByText("Saved final")).toBeTruthy();
expect(screen.queryByText("Hidden tool")).toBeNull();
expect(agent.sendAgentChat).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Continue" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
expect(await screen.findByText("Conversation poursuivie")).toBeTruthy();
expect(await screen.findByText("New final")).toBeTruthy();
});
it("uses /clean for a new conversation and disables conversation changes during a turn", async () => {
let finishSend: (() => void) | undefined;
const agent = {
launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session", assignedConversationId: "conversation-1" })),
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
listCustomAgentConversations: vi.fn(async () => [{
conversationId: "past-conversation",
title: "Past conversation",
createdAtMs: 10,
updatedAtMs: 20,
}]),
sendAgentChat: vi.fn(() => new Promise<void>((resolve) => { finishSend = resolve; })),
executeSlashCommand: vi.fn(async () => ({
command: { name: "/clean", shortDescription: "Clean", requiresConfirmation: false, availability: { status: "available" as const }, source: "native" as const, native: "clean" as const },
effect: { kind: "cleanConversation" as const, sessionId: "clean-session", clearedChunks: 0, conversationId: "conversation-2" },
})),
cancelAgentChat: vi.fn(async () => ({ sessionId: "chat-session", outcome: "cancelled" as const })),
closeAgentChat: vi.fn(async () => {}),
};
const onConversationId = vi.fn();
render(
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView
freshOnOpen projectId="project-1" agentId="agent-1" agentName="Worker"
profile={profile} cwd="/repo" nodeId="node-1" sessionId={null}
conversationId={null} onSessionId={vi.fn()} onConversationId={onConversationId}
/>
</DIProvider>,
);
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("button", { name: "Conversations" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Nouvelle conversation" }));
await waitFor(() => expect(agent.executeSlashCommand).toHaveBeenCalledWith("/clean", {
sessionId: "chat-session",
}));
expect(await screen.findByText("Nouvelle conversation prête.")).toBeTruthy();
expect(onConversationId).toHaveBeenCalledWith("conversation-2");
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Working" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
fireEvent.click(screen.getByRole("button", { name: "Conversations" }));
expect(await screen.findByText("Arrêtez la réponse avant de changer de conversation.")).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Nouvelle conversation" }).hasAttribute("disabled")).toBe(true);
expect((await screen.findByRole("menuitem", { name: /Past conversation/ })).hasAttribute("disabled")).toBe(true);
finishSend?.();
});
});

View File

@ -29,6 +29,7 @@ import type {
ReplyProgressSource,
ReplyProgressStage,
SlashCommand,
TurnView,
} from "@/domain";
import { useGateways } from "@/app/di";
import { useI18n } from "@/i18n";
@ -56,6 +57,7 @@ import type {
ChatAttachmentInput,
ChatDraftScope,
ConversationTranscriptUnavailableReason,
CustomAgentConversationSummary,
ProjectIconRef,
} from "@/ports";
@ -69,6 +71,8 @@ export interface CustomAgentChatViewProps {
nodeId?: string;
sessionId: string | null;
conversationId: string | null;
/** Start blank when there is no live session; durable threads reopen via the menu. */
freshOnOpen?: boolean;
composerHeightPx?: number;
composerDefaultHeightPx?: number;
onComposerHeightPxChange?: (heightPx: number) => void;
@ -87,6 +91,7 @@ type ChatTurn =
| { role: "final"; text: string; receivedAtMs?: number | null }
| { role: "error"; text: string; receivedAtMs?: number | null }
| { role: "restart"; key: string; text: string; message?: string; failed?: boolean; receivedAtMs?: number | null }
| { role: "separator"; text: string }
| { role: "unknown"; text: string; receivedAtMs?: number | null };
interface CachedChatView {
@ -488,6 +493,25 @@ function emptyChatViewState(): ChatViewState {
return { turns: [], progressIndex: new Map(), progressScope: 0 };
}
function durableHistoryTurns(history: TurnView[]): ChatTurn[] {
return history.flatMap((turn): ChatTurn[] => {
if (turn.role === "prompt") {
return [{ role: "user", text: turn.text, receivedAtMs: turn.atMs }];
}
if (turn.role === "response") {
return [{ role: "final", text: turn.text, receivedAtMs: turn.atMs }];
}
return [];
});
}
function conversationActivityLabel(atMs: number): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(atMs));
}
function conversationCacheKey(
projectId: string,
agentId: string,
@ -999,6 +1023,7 @@ export function CustomAgentChatView({
nodeId,
sessionId,
conversationId,
freshOnOpen = false,
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
onComposerHeightPxChange,
@ -1008,13 +1033,13 @@ export function CustomAgentChatView({
onSessionId,
onConversationId,
}: CustomAgentChatViewProps) {
const { agent, profile: profileGateway, project, system, workState } = useGateways();
const { agent, conversation, profile: profileGateway, project, system, workState } = useGateways();
const { t, tr } = useI18n();
const pluginRuntime = usePluginRuntime();
const initialConversationCacheKey = conversationCacheKey(
projectId,
agentId,
conversationId,
sessionId || !freshOnOpen ? conversationId : null,
);
const [turns, setTurns] = useState<ChatTurn[]>(
() =>
@ -1044,6 +1069,14 @@ export function CustomAgentChatView({
() => new Set(),
);
const [currentSession, setCurrentSession] = useState(sessionId);
// A newly opened custom CLI intentionally starts blank. A conversation id
// persisted on the layout is only reused when it still has a live session;
// durable threads are reopened explicitly from the Conversations menu.
const [activeConversationId, setActiveConversationId] = useState<string | null>(
sessionId || !freshOnOpen ? conversationId : null,
);
const activeConversationIdRef = useRef(activeConversationId);
activeConversationIdRef.current = activeConversationId;
const [externalSessionId, setExternalSessionId] = useState(sessionId);
const initialDraftScope = explicitDraftScope ?? (
layoutId && nodeId ? { projectId, layoutId, nodeId } : null
@ -1077,7 +1110,9 @@ export function CustomAgentChatView({
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
const [profileDialog, setProfileDialog] =
useState<ProfileCommandDialogState | null>(null);
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
const [opening, setOpening] = useState(
Boolean(sessionId || (!freshOnOpen && conversationId)),
);
const [openRetryNonce, setOpenRetryNonce] = useState(0);
const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState<
ConversationTranscriptUnavailableReason | "conversationNotFound" | null
@ -1088,6 +1123,16 @@ export function CustomAgentChatView({
const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
const [error, setError] = useState<ChatErrorState | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
const [conversationMenuOpen, setConversationMenuOpen] = useState(false);
const [conversationList, setConversationList] = useState<CustomAgentConversationSummary[]>([]);
const [conversationListLoading, setConversationListLoading] = useState(false);
const [conversationListError, setConversationListError] = useState<string | null>(null);
const [conversationNotice, setConversationNotice] = useState<string | null>(null);
const [resumedConversation, setResumedConversation] = useState(false);
const [historyHasMore, setHistoryHasMore] = useState(false);
const [historyNextAnchor, setHistoryNextAnchor] = useState<string | undefined>();
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(null);
const turnsRef = useRef(turns);
@ -1253,8 +1298,8 @@ export function CustomAgentChatView({
[agentId, layoutId, nodeId, projectId],
);
const currentConversationCacheKey = useMemo(
() => conversationCacheKey(projectId, agentId, conversationId),
[agentId, conversationId, projectId],
() => conversationCacheKey(projectId, agentId, activeConversationId),
[activeConversationId, agentId, projectId],
);
const composerHistoryKey = useMemo(
() => composerHistoryStorageKey(projectId, agentId, layoutId, nodeId),
@ -1817,6 +1862,12 @@ export function CustomAgentChatView({
onSessionIdRef.current(nextSessionId);
}, []);
const publishConversationId = useCallback((nextConversationId: string | null) => {
activeConversationIdRef.current = nextConversationId;
setActiveConversationId(nextConversationId);
onConversationIdRef.current(nextConversationId);
}, []);
const reattachStructuredSession = useCallback(
async (
sid: string,
@ -1857,7 +1908,7 @@ export function CustomAgentChatView({
publishSessionId(reattached.sessionId);
}
if (reattached.conversationId) {
onConversationIdRef.current(reattached.conversationId);
publishConversationId(reattached.conversationId);
}
if (options.applyScrollback) {
scrollbackChunkCountRef.current = reattached.scrollback.length;
@ -1881,7 +1932,7 @@ export function CustomAgentChatView({
}
return reattached.sessionId;
},
[agent, agentId, applyChatViewState, projectId, publishSessionId, receive],
[agent, agentId, applyChatViewState, projectId, publishConversationId, publishSessionId, receive],
);
useEffect(() => {
@ -1926,7 +1977,7 @@ export function CustomAgentChatView({
console.debug("[ticket149] recoverStructuredSession:start", {
timestamp: new Date().toISOString(),
currentSession: sessionRef.current,
conversationId,
conversationId: activeConversationIdRef.current,
options,
});
let retryAttachNotFound = Boolean(options.retryAttachNotFound);
@ -1937,7 +1988,7 @@ export function CustomAgentChatView({
cols: 80,
conversationId:
options.conversationId === undefined
? conversationId ?? undefined
? activeConversationIdRef.current ?? undefined
: options.conversationId ?? undefined,
...(nodeId ? { nodeId } : {}),
};
@ -1973,14 +2024,26 @@ export function CustomAgentChatView({
setCurrentSession(launched.sessionId);
publishSessionId(launched.sessionId);
if (launched.assignedConversationId) {
onConversationIdRef.current(launched.assignedConversationId);
publishConversationId(launched.assignedConversationId);
}
if (launched.resumed) {
const replayed = {
...emptyChatViewState(),
turns: durableHistoryTurns(launched.history ?? []),
};
applyChatViewState(replayed);
setResumedConversation(true);
setConversationNotice(null);
setHistoryHasMore(Boolean(launched.historyHasMore));
setHistoryNextAnchor(launched.historyNextAnchor);
setHistoryError(null);
}
if (launched.transcriptUnavailableReason) {
setTranscriptUnavailableReason(launched.transcriptUnavailableReason);
}
try {
const recoveredSessionId = await reattachStructuredSession(launched.sessionId, {
applyScrollback: options.applyScrollback,
applyScrollback: launched.resumed ? false : options.applyScrollback,
});
console.debug("[ticket149] recoverStructuredSession:success", {
timestamp: new Date().toISOString(),
@ -2012,8 +2075,9 @@ export function CustomAgentChatView({
agentId,
cwd,
nodeId,
conversationId,
reattachStructuredSession,
applyChatViewState,
publishConversationId,
publishSessionId,
],
);
@ -2139,8 +2203,13 @@ export function CustomAgentChatView({
error: instrumentationError(e),
});
}
} else if (currentConversationCacheKey) {
const cached = chatConversationViewCache.get(currentConversationCacheKey);
} else {
const cacheKey = conversationCacheKey(
projectId,
agentId,
activeConversationIdRef.current,
);
const cached = cacheKey ? chatConversationViewCache.get(cacheKey) : undefined;
if (cached) {
scrollbackChunkCountRef.current = cached.chunkCount;
applyChatViewState({
@ -2165,7 +2234,7 @@ export function CustomAgentChatView({
}
}
await recoverStructuredSession({
applyScrollback: Boolean(conversationId),
applyScrollback: Boolean(activeConversationIdRef.current),
retryAttachNotFound: true,
});
if (cancelled) return;
@ -2181,7 +2250,7 @@ export function CustomAgentChatView({
sessionId: externalSessionId,
error: instrumentationError(e),
});
if (!cancelled && conversationId && isNotFound(e)) {
if (!cancelled && activeConversationIdRef.current && isNotFound(e)) {
setTranscriptUnavailableReason("conversationNotFound");
setCurrentSession(null);
publishSessionId(null);
@ -2204,8 +2273,6 @@ export function CustomAgentChatView({
recoverStructuredSession,
publishSessionId,
applyChatViewState,
currentConversationCacheKey,
conversationId,
openRetryNonce,
]);
@ -2242,6 +2309,85 @@ export function CustomAgentChatView({
setOpenRetryNonce((value) => value + 1);
}
const refreshConversationList = useCallback(async () => {
if (!agent.listCustomAgentConversations) {
setConversationListError(
tr("Liste des conversations indisponible.", "Conversation list unavailable."),
);
return;
}
setConversationListLoading(true);
setConversationListError(null);
try {
setConversationList(
await agent.listCustomAgentConversations(projectId, agentId, 50),
);
} catch (e) {
setConversationListError(describe(e));
} finally {
setConversationListLoading(false);
}
}, [agent, agentId, projectId, tr]);
useEffect(() => {
if (conversationMenuOpen) void refreshConversationList();
}, [conversationMenuOpen, refreshConversationList]);
async function reopenConversation(nextConversationId: string) {
if (effectiveBusy || opening || nextConversationId === activeConversationId) return;
setConversationMenuOpen(false);
setOpening(true);
setError(null);
try {
const sid = sessionRef.current;
if (sid) await agent.closeAgentChat?.(sid);
setCurrentSession(null);
setExternalSessionId(null);
publishSessionId(null);
publishConversationId(nextConversationId);
applyChatViewState(emptyChatViewState());
setResumedConversation(false);
setConversationNotice(null);
setHistoryHasMore(false);
setHistoryNextAnchor(undefined);
setOpenRetryNonce((value) => value + 1);
} catch (e) {
setError({ message: describe(e), openFailure: false });
} finally {
setOpening(false);
}
}
async function startNewConversation() {
if (effectiveBusy || opening) return;
setConversationMenuOpen(false);
await executeSlashCommand({ name: "/clean", arguments: [] });
}
async function loadOlderHistory() {
if (!activeConversationId || !historyNextAnchor || historyLoading) return;
setHistoryLoading(true);
setHistoryError(null);
try {
const page = await conversation.readPage(projectId, activeConversationId, {
anchor: historyNextAnchor,
direction: "backward",
limit: 50,
});
const older = durableHistoryTurns(page.turns);
updateChatViewState((previous) => ({
...previous,
turns: [...older, ...previous.turns],
}));
setHistoryHasMore(page.hasMore);
setHistoryNextAnchor(page.nextAnchor);
} catch (e) {
setHistoryError(describe(e));
} finally {
setHistoryLoading(false);
}
}
async function openProfileDialog() {
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
Boolean(candidate.structuredAdapter),
@ -2363,6 +2509,17 @@ export function CustomAgentChatView({
}
if (result.effect.kind === "cleanConversation") {
applyChatViewState(emptyChatViewState());
setCurrentSession(result.effect.sessionId);
setExternalSessionId(result.effect.sessionId);
publishSessionId(result.effect.sessionId);
publishConversationId(result.effect.conversationId ?? null);
setResumedConversation(false);
setHistoryHasMore(false);
setHistoryNextAnchor(undefined);
setHistoryError(null);
setConversationNotice(
tr("Nouvelle conversation prête.", "New conversation ready."),
);
return;
}
if (result.effect.kind === "profileSwitch") {
@ -2549,10 +2706,18 @@ export function CustomAgentChatView({
threadScroll.followLatest();
setBusy(true);
setError(null);
const continuingResumedConversation = resumedConversation;
if (continuingResumedConversation) {
setResumedConversation(false);
setConversationNotice(null);
}
updateChatViewState((prev) => ({
...prev,
turns: [
...prev.turns,
...(continuingResumedConversation
? [{ role: "separator" as const, text: tr("Conversation poursuivie", "Conversation continued") }]
: []),
{
role: "user",
text: displayText,
@ -2801,7 +2966,7 @@ export function CustomAgentChatView({
<div
role="toolbar"
aria-label={t("custom.actions")}
className="relative z-20 flex shrink-0 items-center gap-2 overflow-hidden border-b border-border px-3 py-2"
className="relative z-20 flex shrink-0 items-center gap-2 border-b border-border px-3 py-2"
>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="truncate text-sm font-medium">{agentName}</div>
@ -2809,6 +2974,92 @@ export function CustomAgentChatView({
{tr("CLI personnalisée", "Custom CLI")} · {profile.name}
</div>
</div>
<div className="relative shrink-0">
<Button
size="sm"
variant="ghost"
aria-expanded={conversationMenuOpen}
aria-haspopup="menu"
onClick={() => setConversationMenuOpen((open) => !open)}
>
{tr("Conversations", "Conversations")}
</Button>
{conversationMenuOpen && (
<div
role="menu"
aria-label={tr("Conversations", "Conversations")}
className="absolute right-0 top-full z-50 mt-1 w-[min(22rem,calc(100vw-1rem))] overflow-hidden rounded-md border border-border bg-raised shadow-xl"
>
{effectiveBusy && (
<p className="border-b border-border bg-warning/10 px-3 py-2 text-xs text-warning">
{tr(
"Arrêtez la réponse avant de changer de conversation.",
"Stop the response before changing conversations.",
)}
</p>
)}
<button
type="button"
role="menuitem"
disabled={effectiveBusy || opening}
className="flex min-h-11 w-full items-center px-3 text-left text-sm font-semibold text-content hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void startNewConversation()}
>
{tr("Nouvelle conversation", "New conversation")}
</button>
<div className="border-t border-border" />
<div className="max-h-72 overflow-y-auto py-1">
{conversationListLoading ? (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-muted" role="status">
<Spinner size={14} />
{tr("Chargement des conversations…", "Loading conversations…")}
</div>
) : conversationListError ? (
<div className="flex flex-col gap-2 px-3 py-3 text-xs text-danger" role="alert">
<span>{tr("Impossible de charger les conversations.", "Unable to load conversations.")}</span>
<button
type="button"
className="self-start font-semibold text-content"
onClick={() => void refreshConversationList()}
>
{t("common.retry")}
</button>
</div>
) : conversationList.length === 0 ? (
<p className="px-3 py-3 text-xs text-muted">
{tr("Aucune conversation passée.", "No past conversations.")}
</p>
) : (
conversationList.map((item) => {
const isOpen = item.conversationId === activeConversationId;
return (
<button
key={item.conversationId}
type="button"
role="menuitem"
disabled={effectiveBusy || opening || isOpen}
className="flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left hover:bg-surface disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void reopenConversation(item.conversationId)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-content">{item.title}</span>
<span className="block text-[11px] text-muted">
{conversationActivityLabel(item.updatedAtMs)}
</span>
</span>
{isOpen && (
<span className="shrink-0 rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold text-primary">
{tr("Ouverte", "Open")}
</span>
)}
</button>
);
})
)}
</div>
</div>
)}
</div>
</div>
{hasPreviousFinal && (
<button
@ -2994,6 +3245,39 @@ export function CustomAgentChatView({
fallbackText={delegationFallbackText}
/>
</div>
{conversationNotice && (
<p role="status" className="shrink-0 rounded-md bg-success/10 px-3 py-2 text-xs text-success">
{conversationNotice}
</p>
)}
{resumedConversation && (
<div className="shrink-0 rounded-md border border-border bg-raised/75 px-3 py-2 text-xs text-muted">
<strong className="block text-content">
{tr("Conversation reprise", "Conversation resumed")}
</strong>
<span>
{tr(
"Historique sauvegardé de cet agent. Vous pouvez poursuivre ci-dessous.",
"Saved history for this agent. You can continue below.",
)}
</span>
</div>
)}
{resumedConversation && historyHasMore && (
<button
type="button"
className="self-center rounded-md px-3 py-1 text-xs font-semibold text-primary hover:bg-raised disabled:opacity-50"
disabled={historyLoading}
onClick={() => void loadOlderHistory()}
>
{historyLoading
? tr("Chargement…", "Loading…")
: tr("Charger les messages précédents", "Load earlier messages")}
</button>
)}
{historyError && (
<p role="alert" className="text-center text-xs text-danger">{historyError}</p>
)}
{opening && displayTurns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
@ -3026,9 +3310,20 @@ export function CustomAgentChatView({
</div>
</div>
) : displayTurns.length === 0 ? (
<p className="project-cell-empty-label text-xs text-muted">
{t("custom.empty")}
</p>
<div className="project-cell-empty-label m-auto max-w-sm text-center text-xs text-muted">
<strong className="block text-sm text-content">
{tr("Nouvelle conversation", "New conversation")}
</strong>
<span>
{t("custom.empty")}
</span>{" "}
<span>
{tr(
"Vous pouvez aussi ouvrir une conversation passée depuis le menu Conversations.",
"You can also open a past conversation from the Conversations menu.",
)}
</span>
</div>
) : (
displayTurns.map((turn, index) => (
<ChatBubble
@ -3368,6 +3663,15 @@ function ChatBubble({
onToggleProgressExpanded: (key: string) => void;
}) {
const { tr } = useI18n();
if (turn.role === "separator") {
return (
<div className="flex items-center gap-2 py-1 text-[11px] text-muted" role="separator">
<span className="h-px flex-1 bg-border" />
<span>{turn.text}</span>
<span className="h-px flex-1 bg-border" />
</div>
);
}
if (turn.role === "restart") {
return (
<div

View File

@ -481,7 +481,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
});
it("restores a custom CLI transcript when returning with a conversation id but no session (#217)", async () => {
it("starts a fresh custom CLI conversation when returning without a live session (#363)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
@ -506,15 +506,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({
sessionId: "chat-resumed",
sessionId: "chat-fresh",
cellKind: "chat" as const,
assignedConversationId: "conversation-fresh",
});
const reattachChat = vi.spyOn(agent, "reattachAgentChat").mockResolvedValue({
sessionId: "chat-resumed",
scrollback: [
{ kind: "userPrompt", text: "known prompt" },
{ kind: "final", content: "known answer" },
],
sessionId: "chat-fresh",
scrollback: [],
});
renderGrid({
@ -525,25 +523,25 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
system,
} as unknown as Gateways);
expect(await screen.findByText("known answer")).toBeTruthy();
expect(screen.getByText("known prompt")).toBeTruthy();
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
expect(screen.queryByText("known prompt")).toBeNull();
expect(launchChat).toHaveBeenCalledWith(
"p1",
created.id,
expect.objectContaining({
nodeId: leafId,
conversationId: "conversation-known",
conversationId: undefined,
}),
);
expect(reattachChat).toHaveBeenCalledWith("chat-resumed", expect.any(Function));
expect(reattachChat).toHaveBeenCalledWith("chat-fresh", expect.any(Function));
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
const leaf = leaves(updated)[0];
expect(leaf.conversationId).toBe("conversation-known");
expect(leaf.session).toBe("chat-resumed");
expect(leaf.conversationId).toBe("conversation-fresh");
expect(leaf.session).toBe("chat-fresh");
});
expect(screen.queryByText("Envoyez un message pour démarrer la conversation structurée."))
.toBeNull();
expect(screen.getByText("Envoyez un message pour démarrer la conversation structurée."))
.toBeTruthy();
});
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {

View File

@ -1695,6 +1695,7 @@ function LeafView({
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
<CustomAgentChatView
key={`${id}-${agentId}-custom`}
freshOnOpen
projectId={projectId}
agentId={agentId}
agentName={customCliAgent.name}

View File

@ -200,6 +200,7 @@ export function WebAgentCell({
)}
{mode === "custom" && showModeSwitch && resolvedAgent && resolvedProfile ? (
<CustomAgentChatView
freshOnOpen
projectId={projectId}
agentId={agentId}
agentName={resolvedAgent.name}

View File

@ -107,6 +107,7 @@ import type {
TicketPriority,
TicketStatus,
TurnPage,
TurnView,
Unsubscribe,
} from "@/domain";
@ -207,10 +208,23 @@ export interface AgentChatHandle {
readonly cellKind: "chat";
/** Conversation id assigned by launch when the backend minted one. */
readonly assignedConversationId?: string;
/** Durable Prompt/Final history returned only for an explicit reopen. */
readonly history?: TurnView[];
readonly historyHasMore?: boolean;
readonly historyNextAnchor?: string;
readonly resumed?: boolean;
/** Why a successful structured open has no replayable transcript. */
readonly transcriptUnavailableReason?: ConversationTranscriptUnavailableReason;
}
/** One non-empty durable custom-CLI conversation in the agent menu. */
export interface CustomAgentConversationSummary {
conversationId: string;
title: string;
createdAtMs: number;
updatedAtMs: number;
}
export type ConversationTranscriptUnavailableReason =
| "missingTranscript"
| "emptyConversation";
@ -364,6 +378,12 @@ export interface AgentGateway {
agentId: string,
options: OpenTerminalOptions,
): Promise<AgentChatHandle>;
/** Lists the latest durable custom-CLI conversations for one agent. */
listCustomAgentConversations?(
projectId: string,
agentId: string,
limit?: number,
): Promise<CustomAgentConversationSummary[]>;
/**
* Re-attaches a custom chat view to a still-live structured session, replaying
* retained reply chunks before subsequent live chunks arrive.