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:
@ -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?.();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user