fix(frontend): la conversation survit au redemarrage agent et a l'aller-retour projet — #291 (ProjectsView/WebWorkspace conservent le pointeur viewerConversation mais ne rendent le viewer que si sa conversation appartient au projet actif: le projet intermediaire affiche sa propre surface sans fuite, le retour au projet source remonte la vue avec la meme cle project/conversation et relit le transcript persiste; useConversationThread ne vide plus le buffer au changement de conversation cible, un contenu deja visible reste affiche pendant relecture ou echec; l'erreur de lecture devient "Impossible de recharger la conversation." avec bouton Reessayer branchant refreshTail sans retirer les tours visibles; QA verte reconfirmee par Git: ProjectsView.ls7 scenario "rehydrates the same conversation" (restart agent, switch alpha->beta->alpha, deux readPage identiques sur {projectId, conversationId}), ConversationViewer + useConversationThread 19/19, npm run build, git diff --check; reserves hors perimetre preexistantes: 3 echecs historiques ProjectsView.ls7 (helper panneau Work/open conversation) + 1 echec WebDesktopWorkspace (libelle anglais Project context); validation runtime AppImage/live restante; aucun push)
This commit is contained in:
@ -4,7 +4,7 @@
|
|||||||
* states, scroll-up "load older" trigger, and the back button (`onClose`).
|
* states, scroll-up "load older" trigger, and the back button (`onClose`).
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
@ -256,7 +256,12 @@ describe("ConversationViewer", () => {
|
|||||||
};
|
};
|
||||||
renderViewer({ conversation: failing });
|
renderViewer({ conversation: failing });
|
||||||
const alert = await screen.findByRole("alert");
|
const alert = await screen.findByRole("alert");
|
||||||
expect(alert.textContent).toContain("lecture impossible");
|
expect(alert.textContent).toContain(
|
||||||
|
"Impossible de recharger la conversation.",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(alert).getByRole("button", { name: "Réessayer" }),
|
||||||
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers a 'load older' control when more turns exist and triggers loadOlder", async () => {
|
it("offers a 'load older' control when more turns exist and triggers loadOlder", async () => {
|
||||||
|
|||||||
@ -288,12 +288,20 @@ export function ConversationViewer({
|
|||||||
|
|
||||||
{/* ── Error ── */}
|
{/* ── Error ── */}
|
||||||
{vm.error && (
|
{vm.error && (
|
||||||
<p
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="mx-4 mt-2 shrink-0 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
className="mx-4 mt-2 flex shrink-0 items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||||
>
|
>
|
||||||
{vm.error}
|
<span>Impossible de recharger la conversation.</span>
|
||||||
</p>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void vm.refreshTail()}
|
||||||
|
loading={vm.busy}
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Thread ── */}
|
{/* ── Thread ── */}
|
||||||
|
|||||||
@ -151,10 +151,9 @@ export function useConversationThread(
|
|||||||
|
|
||||||
// (Re)open whenever the target conversation changes.
|
// (Re)open whenever the target conversation changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBuffer([]);
|
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
void open();
|
void open();
|
||||||
}, [open, setBuffer]);
|
}, [open]);
|
||||||
|
|
||||||
return { turns, busy, error, hasMore, loadOlder, refreshTail };
|
return { turns, busy, error, hasMore, loadOlder, refreshTail };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* LS7 — `ProjectsView` integration: clicking a conversation in the Work panel swaps
|
* LS7 — `ProjectsView` integration: clicking a conversation in the Work panel swaps
|
||||||
* the terminal grid for the read-only `ConversationViewer`; the back button returns
|
* the terminal grid for the read-only `ConversationViewer`; the back button returns
|
||||||
* to the grid; switching project preserves the viewer; and with no conversation
|
* to the grid; switching away hides the viewer and returning rehydrates it from the
|
||||||
* open the terminal grid is shown unchanged (non-regression).
|
* persisted transcript; and with no conversation open the terminal grid is shown
|
||||||
|
* unchanged (non-regression).
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||||
@ -186,15 +187,17 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the viewer attached to its source project when the active project changes", async () => {
|
it("rehydrates the same conversation after an agent restart and a project roundtrip", async () => {
|
||||||
const project = new MockProjectGateway();
|
const project = new MockProjectGateway();
|
||||||
const alpha = await project.createProject("alpha", "/p/a");
|
const alpha = await project.createProject("alpha", "/p/a");
|
||||||
await project.createProject("beta", "/p/b");
|
await project.createProject("beta", "/p/b");
|
||||||
const reads: string[] = [];
|
const system = new MockSystemGateway();
|
||||||
|
const reads: Array<{ projectId: string; conversationId: string }> = [];
|
||||||
renderView(project, {
|
renderView(project, {
|
||||||
|
system,
|
||||||
conversation: {
|
conversation: {
|
||||||
readPage: async (projectId) => {
|
readPage: async (projectId, conversationId) => {
|
||||||
reads.push(projectId);
|
reads.push({ projectId, conversationId });
|
||||||
return {
|
return {
|
||||||
turns: [
|
turns: [
|
||||||
{
|
{
|
||||||
@ -225,24 +228,57 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
|||||||
}
|
}
|
||||||
await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2));
|
await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2));
|
||||||
|
|
||||||
// Make alpha the active tab, then open its conversation viewer.
|
// Make alpha the active tab, then open its persisted conversation from a
|
||||||
|
// completion event carrying the stable conversation pointer.
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
|
fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
|
||||||
openPanel("Work state");
|
system.emit({
|
||||||
|
type: "backgroundTaskChanged",
|
||||||
|
projectId: alpha.id,
|
||||||
|
agentId: "agent-1",
|
||||||
|
taskId: "task-rehydrate-291",
|
||||||
|
state: "completed",
|
||||||
|
conversationId: CONV_ID,
|
||||||
|
});
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
|
await screen.findByRole("button", { name: /Background task completed/i }),
|
||||||
);
|
);
|
||||||
await screen.findByRole("button", { name: "← Retour aux terminaux" });
|
await screen.findByRole("button", { name: "← Retour aux terminaux" });
|
||||||
|
await screen.findByText(`contenu du fil ${alpha.id}`);
|
||||||
|
|
||||||
// Switch the active project (top tab) ⇒ the viewer remains visible and
|
// Simulate the successful self-restart lifecycle. It must not replace the
|
||||||
// keeps reading the conversation from the project where it was opened.
|
// persisted conversation pointer with a fresh live buffer.
|
||||||
|
system.emit({
|
||||||
|
type: "agentRestartRequested",
|
||||||
|
agentId: "agent-1",
|
||||||
|
restartId: "restart-291",
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
|
system.emit({
|
||||||
|
type: "agentRestartCompleted",
|
||||||
|
agentId: "agent-1",
|
||||||
|
restartId: "restart-291",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switching away shows beta's own surface instead of leaking alpha's viewer.
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "beta" }));
|
fireEvent.click(screen.getByRole("tab", { name: "beta" }));
|
||||||
|
expect(await screen.findByTestId("layout-grid")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
// Returning remounts the viewer and reloads the persisted transcript with
|
||||||
|
// exactly the same project/conversation identity.
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("button", { name: "← Retour aux terminaux" }),
|
await screen.findByRole("button", { name: "← Retour aux terminaux" }),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(await screen.findByText(`contenu du fil ${alpha.id}`)).toBeTruthy();
|
expect(await screen.findByText(`contenu du fil ${alpha.id}`)).toBeTruthy();
|
||||||
expect(screen.queryByTestId("layout-grid")).toBeNull();
|
expect(screen.queryByTestId("layout-grid")).toBeNull();
|
||||||
expect(reads.every((projectId) => projectId === alpha.id)).toBe(true);
|
await waitFor(() => expect(reads).toHaveLength(2));
|
||||||
|
expect(reads).toEqual([
|
||||||
|
{ projectId: alpha.id, conversationId: CONV_ID },
|
||||||
|
{ projectId: alpha.id, conversationId: CONV_ID },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the viewer open when the active layout changes", async () => {
|
it("keeps the viewer open when the active layout changes", async () => {
|
||||||
|
|||||||
@ -247,6 +247,8 @@ export function ProjectsView() {
|
|||||||
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
||||||
|
|
||||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||||
|
const activeViewerConversation =
|
||||||
|
viewerConversation?.projectId === active?.id ? viewerConversation : null;
|
||||||
const projectNameById = useMemo(() => {
|
const projectNameById = useMemo(() => {
|
||||||
const names = new Map<string, string>();
|
const names = new Map<string, string>();
|
||||||
for (const project of vm.projects) names.set(project.id, project.name);
|
for (const project of vm.projects) names.set(project.id, project.name);
|
||||||
@ -924,11 +926,11 @@ export function ProjectsView() {
|
|||||||
projectId={active.id}
|
projectId={active.id}
|
||||||
onActiveLayoutChange={setActiveLayout}
|
onActiveLayoutChange={setActiveLayout}
|
||||||
/>
|
/>
|
||||||
{viewerConversation ? (
|
{activeViewerConversation ? (
|
||||||
<ConversationViewer
|
<ConversationViewer
|
||||||
key={`${viewerConversation.projectId}-${viewerConversation.conversationId}`}
|
key={`${activeViewerConversation.projectId}-${activeViewerConversation.conversationId}`}
|
||||||
projectId={viewerConversation.projectId}
|
projectId={activeViewerConversation.projectId}
|
||||||
conversationId={viewerConversation.conversationId}
|
conversationId={activeViewerConversation.conversationId}
|
||||||
onClose={() => setViewerConversation(null)}
|
onClose={() => setViewerConversation(null)}
|
||||||
onOpenConversation={openConversation}
|
onOpenConversation={openConversation}
|
||||||
/>
|
/>
|
||||||
@ -953,14 +955,6 @@ export function ProjectsView() {
|
|||||||
</AnnouncementsProvider>
|
</AnnouncementsProvider>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : viewerConversation ? (
|
|
||||||
<ConversationViewer
|
|
||||||
key={`${viewerConversation.projectId}-${viewerConversation.conversationId}`}
|
|
||||||
projectId={viewerConversation.projectId}
|
|
||||||
conversationId={viewerConversation.conversationId}
|
|
||||||
onClose={() => setViewerConversation(null)}
|
|
||||||
onOpenConversation={openConversation}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
||||||
<div className="w-full max-w-xl">
|
<div className="w-full max-w-xl">
|
||||||
|
|||||||
@ -279,9 +279,13 @@ function WebDesktopWorkspace({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveLayout(null);
|
setActiveLayout(null);
|
||||||
setViewerConversation(null);
|
|
||||||
}, [openId]);
|
}, [openId]);
|
||||||
|
|
||||||
|
const activeViewerConversation =
|
||||||
|
viewerConversation?.projectId === openProject?.id
|
||||||
|
? viewerConversation
|
||||||
|
: null;
|
||||||
|
|
||||||
function setPlacement(panel: WebDesktopPanelId, placement: Exclude<ViewPlacement, "detached">) {
|
function setPlacement(panel: WebDesktopPanelId, placement: Exclude<ViewPlacement, "detached">) {
|
||||||
setPlacements((prev) => {
|
setPlacements((prev) => {
|
||||||
const next: WebDesktopPlacements = { ...prev };
|
const next: WebDesktopPlacements = { ...prev };
|
||||||
@ -535,11 +539,11 @@ function WebDesktopWorkspace({
|
|||||||
projectId={openProject.id}
|
projectId={openProject.id}
|
||||||
onActiveLayoutChange={setActiveLayout}
|
onActiveLayoutChange={setActiveLayout}
|
||||||
/>
|
/>
|
||||||
{viewerConversation ? (
|
{activeViewerConversation ? (
|
||||||
<ConversationViewer
|
<ConversationViewer
|
||||||
key={`${viewerConversation.projectId}-${viewerConversation.conversationId}`}
|
key={`${activeViewerConversation.projectId}-${activeViewerConversation.conversationId}`}
|
||||||
projectId={viewerConversation.projectId}
|
projectId={activeViewerConversation.projectId}
|
||||||
conversationId={viewerConversation.conversationId}
|
conversationId={activeViewerConversation.conversationId}
|
||||||
onClose={() => setViewerConversation(null)}
|
onClose={() => setViewerConversation(null)}
|
||||||
onOpenConversation={openConversation}
|
onOpenConversation={openConversation}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user