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

@ -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