merge(cli): conservation conversation au switch layout Git Graph — #217 (QA verte)

This commit is contained in:
2026-08-15 19:33:05 +02:00
9 changed files with 330 additions and 19 deletions

View File

@ -1,8 +1,8 @@
---
issueRef: "#216"
version: 4
version: 6
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786813039107
updatedAt: 1786813140292
---
## Triage 2026-08-15
- Symptôme rapporté: dans la CLI custom, impression qu'après le premier `Final`, il n'y a plus de progress remontés.
@ -38,3 +38,10 @@ updatedAt: 1786813039107
- Tests QA:
- `npm test -- CustomAgentChatView.test.tsx` => OK
- `npm run typecheck` => OK
## Git
- Commit fix: `3582b25` — `fix(cli): affiche les progress reçus après Final sans rouvrir le tour — #216`
- Commit bookkeeping: `7fd47b3` — `chore(idea): bookkeeping #216 QA verte + clôtures #214/#215`
- Merge local: `02715a0` — `merge(cli): progress après Final dans la CLI custom — #216 (QA verte)`
- Branche finale active: `develop`
- Aucun push effectué.

View File

@ -2,7 +2,7 @@
id: "81ef20fa-57bf-468c-b396-4f1ee2c61c08"
number: 216
title: "CLI custom: plus de progress remontés après le premier Final"
status: "qa"
status: "closed"
priority: "high"
sprint: null
links: []
@ -11,7 +11,7 @@ attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1786812380073
updatedAt: 1786813039107
version: 4
updatedAt: 1786813140292
version: 6
---
Investigation et correction du comportement où la CLI custom semble ne plus afficher/remonter les événements de progression après un premier message Final. Vérifier si le bug est purement d'affichage ou si le flux de progress s'arrête réellement.

View File

@ -0,0 +1,49 @@
---
issueRef: "#217"
version: 6
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786815105743
---
## Triage 2026-08-15
- Symptôme rapporté: la conversation disparaît parfois quand l'utilisateur passe de son layout agent au layout Git Graph puis revient.
- Cadrage UX: un switch de layout ne doit jamais réinitialiser la conversation; au retour, il faut restaurer transcript connu + état de réattachement explicite au lieu d'une surface vide.
- Cadrage Architect: cause probable côté projection frontend / corrélation de réattache (`conversationId`/`sessionId`) plus qu'une perte backend réelle.
- Couches à inspecter d'abord: `frontend/src/features/layout/LayoutGrid.tsx`, `frontend/src/features/agents/CustomAgentChatView.tsx`, hooks layout frontend, puis `reattach_*` côté app-tauri si nécessaire.
- Branche locale créée par Git: `fix/217-layout-gitgraph-conversation-loss` depuis `develop` @ `02715a0`.
## Diagnostic DevFrontend
- Bug confirmé côté frontend.
- Cause: au retour de layout, `CustomAgentChatView` savait reprendre via `sessionId`, mais pas via `conversationId` seul. Si le layout revenait avec conversation persistée mais sans session réattachable, la vue repartait vide et la reprise n'appliquait pas le scrollback.
- Correctif initial appliqué:
- cache UI ajouté par `conversationId`, en plus du cache par `sessionId`
- restauration initiale depuis le cache conversation quand `sessionId` manque
- reprise au retour avec `conversationId` connu et scrollback appliqué, sans relance froide implicite
- QA a trouvé un gap: la vue repeignait depuis le cache mais relançait encore `launchAgentChat` quand `sessionId` était nul dans les props.
- Correctif complémentaire livré:
- le cache conversation conserve aussi le `sessionId`
- le cache n'écrase plus une entrée utile par un état vide sans session
- au retour, si le cache conversation porte un `sessionId`, la vue réattache cette session et sort sans appeler `launchAgentChat`
- Fichiers modifiés:
- `frontend/src/features/agents/CustomAgentChatView.tsx`
- `frontend/src/features/agents/CustomAgentChatView.test.tsx`
- `frontend/src/features/layout/LayoutGrid.chat.test.tsx`
- Tests DevFrontend finaux:
- `npm test -- CustomAgentChatView.test.tsx` => OK (50)
- `npm test -- LayoutGrid.chat.test.tsx` => OK (14)
- `npm run typecheck` => OK
## Diagnostic DevBackend
- Pas de bug backend/app-tauri trouvé.
- `detach_agent_chat` préserve session + scrollback; `reattach_agent_chat` relit le scrollback; `set_active_layout` ne détruit pas les conversations/layouts; la perte apparente ne vient pas du backend selon les tests ciblés.
- Tests DevBackend:
- `cargo test -p app-tauri --test chat_bridge reattach` => OK (4)
- `cargo test -p app-tauri --test dto_layouts git_graph` => OK (2)
- `cargo test -p application relaunch_other_cell_with_conversation_id_rebinds` => OK (1)
- `cargo test -p application launch_other_cell_with_conversation_id_rebinds_no_respawn` => OK (1)
- `cargo test -p application attach_` => OK (5)
- `cargo test -p application --test layout_usecases` => OK (27)
- Conclusion: correction frontend, pas backend.
## QA rouge 2026-08-15
- `npm test -- CustomAgentChatView.test.tsx` => ROUGE sur le scénario `conversationId` connu / `sessionId` nul qui déclenchait encore `launchAgentChat`.
- Ce gap a été corrigé dans l'itération frontend suivante.

View File

@ -0,0 +1,17 @@
---
id: "6ea02306-17ba-4085-a8fb-df9e7d061e35"
number: 217
title: "Conversation disparaît parfois au switch layout agent vers Git Graph"
status: "qa"
priority: "high"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1786814259134
updatedAt: 1786815105743
version: 6
---
Investigation et correction du cas où une conversation disparaît parfois lorsqu'on passe du layout agent au layout Git Graph, puis retour vers le layout agent. Vérifier si le problème vient de la persistance UI, de la projection layout, ou d'un problème de réattache conversation/session.

View File

@ -1,3 +1,3 @@
{
"nextNumber": 217
"nextNumber": 218
}

View File

@ -2918,6 +2918,20 @@
"issueRef": "#216",
"path": "216",
"title": "CLI custom: plus de progress remontés après le premier Final",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1786813140292
},
{
"issueRef": "#217",
"path": "217",
"title": "Conversation disparaît parfois au switch layout agent vers Git Graph",
"status": "qa",
"priority": "high",
"sprint": null,
@ -2926,7 +2940,7 @@
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1786813039107
"updatedAt": 1786815105743
}
]
}

View File

@ -522,6 +522,85 @@ describe("CustomAgentChatView", () => {
expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
});
it("restores visible conversation from conversationId cache when layout returns before sessionId", async () => {
let scrollback: ReplyChunk[] = [
{ kind: "userPrompt" as const, text: "Initial prompt" },
{ kind: "final" as const, content: "Initial reply" },
];
const secondReattach = deferred<{ sessionId: string; scrollback: ReplyChunk[] }>();
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi
.fn()
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback,
}))
.mockImplementationOnce(() => secondReattach.promise),
detachAgentChat: vi.fn(async () => {}),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const props = {
projectId: "project-1",
agentId: "agent-1",
agentName: "Worker",
profile,
cwd: "/repo",
nodeId: "node-1",
sessionId: "chat-session-1" as string | null,
conversationId: "conversation-1",
onSessionId: vi.fn(),
onConversationId: vi.fn(),
};
const first = render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView {...props} />
</DIProvider>,
);
expect(await screen.findByText("Initial reply")).toBeTruthy();
first.unmount();
await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1"));
scrollback = [
{ kind: "userPrompt" as const, text: "Initial prompt" },
{ kind: "final" as const, content: "Initial reply" },
{ kind: "userPrompt" as const, text: "Question during graph view" },
{ kind: "final" as const, content: "Answer restored from replay" },
];
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView {...props} sessionId={null} />
</DIProvider>,
);
expect(screen.getByText("Initial reply")).toBeTruthy();
expect(agent.launchAgentChat).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true);
act(() => {
secondReattach.resolve({ sessionId: "chat-session-1", scrollback });
});
expect(await screen.findByText("Answer restored from replay")).toBeTruthy();
expect(screen.getByText("Question during graph view")).toBeTruthy();
expect(agent.launchAgentChat).not.toHaveBeenCalled();
});
it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => {
const agent = {
launchAgentChat: vi.fn(),

View File

@ -67,15 +67,18 @@ interface CachedChatView {
turns: ChatTurn[];
progressIndex: Map<string, ProgressIndexEntry>;
chunkCount: number;
sessionId?: string;
}
const chatSessionViewCache = new Map<string, CachedChatView>();
const chatConversationViewCache = new Map<string, CachedChatView>();
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
const COMPOSER_RESIZE_STEP = 12;
const FALLBACK_COMPOSER_HEIGHT = 64;
export function __clearCustomAgentChatViewCacheForTests() {
chatSessionViewCache.clear();
chatConversationViewCache.clear();
}
interface AttachmentDraft {
@ -310,6 +313,14 @@ function emptyChatViewState(): ChatViewState {
return { turns: [], progressIndex: new Map() };
}
function conversationCacheKey(
projectId: string,
agentId: string,
conversationId: string | null,
): string | null {
return conversationId ? `${projectId}::${agentId}::${conversationId}` : null;
}
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
const key = progressUpsertKey(progress);
const existingIndex = state.turns.findIndex(
@ -711,10 +722,27 @@ export function CustomAgentChatView({
}: CustomAgentChatViewProps) {
const { agent, profile: profileGateway, system } = useGateways();
const pluginRuntime = usePluginRuntime();
const [turns, setTurns] = useState<ChatTurn[]>(
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
const initialConversationCacheKey = conversationCacheKey(
projectId,
agentId,
conversationId,
);
const [turns, setTurns] = useState<ChatTurn[]>(
() =>
sessionId
? (chatSessionViewCache.get(sessionId)?.turns ?? [])
: initialConversationCacheKey
? (chatConversationViewCache.get(initialConversationCacheKey)?.turns ?? [])
: [],
);
const [progressIndex, setProgressIndex] = useState<Map<string, ProgressIndexEntry>>(
() =>
sessionId
? (chatSessionViewCache.get(sessionId)?.progressIndex ?? new Map())
: initialConversationCacheKey
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map())
: new Map(),
);
const [progressIndex, setProgressIndex] = useState<Map<string, ProgressIndexEntry>>(new Map());
const [currentSession, setCurrentSession] = useState(sessionId);
const [externalSessionId, setExternalSessionId] = useState(sessionId);
const [draft, setDraft] = useState("");
@ -724,7 +752,7 @@ export function CustomAgentChatView({
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
const [profileDialog, setProfileDialog] =
useState<ProfileCommandDialogState | null>(null);
const [opening, setOpening] = useState(Boolean(sessionId));
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
const [busy, setBusy] = useState(false);
const [stopping, setStopping] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -849,6 +877,10 @@ export function CustomAgentChatView({
() => (layoutId ? { projectId, layoutId, nodeId } : null),
[layoutId, nodeId, projectId],
);
const currentConversationCacheKey = useMemo(
() => conversationCacheKey(projectId, agentId, conversationId),
[agentId, conversationId, projectId],
);
const displayTurns = turns;
const clearPersistedDraft = useCallback(async () => {
@ -918,13 +950,27 @@ export function CustomAgentChatView({
);
useEffect(() => {
if (!currentSession) return;
chatSessionViewCache.set(currentSession, {
turns,
progressIndex,
chunkCount: scrollbackChunkCountRef.current,
});
}, [currentSession, progressIndex, turns]);
if (currentSession) {
chatSessionViewCache.set(currentSession, {
turns,
progressIndex,
chunkCount: scrollbackChunkCountRef.current,
sessionId: currentSession,
});
}
if (currentConversationCacheKey) {
const previous = chatConversationViewCache.get(currentConversationCacheKey);
const cachedSessionId = currentSession ?? previous?.sessionId;
if (cachedSessionId || turns.length > 0 || progressIndex.size > 0) {
chatConversationViewCache.set(currentConversationCacheKey, {
turns,
progressIndex,
chunkCount: scrollbackChunkCountRef.current,
...(cachedSessionId ? { sessionId: cachedSessionId } : {}),
});
}
}
}, [currentConversationCacheKey, currentSession, progressIndex, turns]);
useEffect(() => {
draftSaveSeqRef.current += 1;
@ -1216,6 +1262,15 @@ export function CustomAgentChatView({
setOpening(true);
setError(null);
try {
const localSession = sessionRef.current;
if (!externalSessionId && localSession) {
console.debug("[ticket149] openOrAttach:local-session-present:skip", {
timestamp: new Date().toISOString(),
execution,
currentSession: localSession,
});
return;
}
if (externalSessionId) {
const cached = chatSessionViewCache.get(externalSessionId);
if (cached) {
@ -1250,9 +1305,32 @@ export function CustomAgentChatView({
error: instrumentationError(e),
});
}
} else if (currentConversationCacheKey) {
const cached = chatConversationViewCache.get(currentConversationCacheKey);
if (cached) {
scrollbackChunkCountRef.current = cached.chunkCount;
applyChatViewState({
turns: cached.turns,
progressIndex: cached.progressIndex ?? new Map(),
});
if (cached.sessionId) {
const reattachedSessionId = await reattachStructuredSession(cached.sessionId, {
applyScrollback: true,
});
if (cancelled) return;
publishSessionId(reattachedSessionId);
console.debug("[ticket149] openOrAttach:reattach-conversation-cache:success", {
timestamp: new Date().toISOString(),
execution,
sessionId: reattachedSessionId,
});
return;
}
return;
}
}
await recoverStructuredSession({
applyScrollback: false,
applyScrollback: Boolean(conversationId),
retryAttachNotFound: true,
});
if (cancelled) return;
@ -1285,6 +1363,8 @@ export function CustomAgentChatView({
recoverStructuredSession,
publishSessionId,
applyChatViewState,
currentConversationCacheKey,
conversationId,
]);
const canSend = useMemo(

View File

@ -266,6 +266,71 @@ 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 () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
await layout.mutateLayout("p1", {
type: "setCellConversation",
target: leafId,
conversationId: "conversation-known",
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({
sessionId: "chat-resumed",
cellKind: "chat" as const,
});
const reattachChat = vi.spyOn(agent, "reattachAgentChat").mockResolvedValue({
sessionId: "chat-resumed",
scrollback: [
{ kind: "userPrompt", text: "known prompt" },
{ kind: "final", content: "known answer" },
],
});
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
expect(await screen.findByText("known answer")).toBeTruthy();
expect(screen.getByText("known prompt")).toBeTruthy();
expect(launchChat).toHaveBeenCalledWith(
"p1",
created.id,
expect.objectContaining({
nodeId: leafId,
conversationId: "conversation-known",
}),
);
expect(reattachChat).toHaveBeenCalledWith("chat-resumed", 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(screen.queryByText("Envoyez un message pour démarrer la conversation structurée."))
.toBeNull();
});
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();