feat(backend,frontend): lot utilisateur #346 #347 #348 — exécution workspace plugin contrôlée par l'hôte, réordonnancement des onglets projet par glisser-déposer, Final restauré après stop d'agent CLI custom (QA verte)

#346 — capacités plugin SCM: le manifeste permissions.workspaceExec
(contrat SDK 8ff0f47) est appliqué côté hôte — domain PluginWorkspaceExec
Rule/Effect + audit sink, port WorkspaceToolRunner, adapter
LocalWorkspaceToolRunner sandboxé (concurrence 4, timeout, bornes
stdout/stderr, résolution bare executable sans shell), service
application + DTO backend + commandes app-tauri, gateway frontend
execTool exposée au runtime plugin — fin des lectures ad hoc du store
interne .git par les plugins.
#347 — onglets projet réordonnables par glisser-déposer (Tabs DnD,
onReorder, persistance de l'ordre via useProjects).
#348 — stop d'agent CLI custom: le message Final vert réapparaît au tour
suivant (override terminal stop armé correctement, reset de
l'interaction, refresh de la délégation requester).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 12:23:44 +02:00
parent 2b0d82efa3
commit 561c50f6ed
28 changed files with 1761 additions and 51 deletions

View File

@ -1564,6 +1564,7 @@ describe("CustomAgentChatView", () => {
it("cancels only the current request and keeps the structured session usable", async () => {
let resolveCancel: (result: CancelAgentChatResult) => void = () => {};
let firstTurnChunk: (chunk: ReplyChunk) => void = () => {};
let reattachedChunk: (chunk: ReplyChunk) => void = () => {};
let rejectFirstTurn: (reason: unknown) => void = () => {};
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
@ -1581,10 +1582,12 @@ describe("CustomAgentChatView", () => {
});
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
reattachAgentChat: vi.fn(
async (sessionId: string, onChunk: (chunk: ReplyChunk) => void) => {
reattachedChunk = onChunk;
return { sessionId, scrollback: [] };
},
),
sendAgentChat: vi
.fn()
.mockImplementationOnce((_sessionId: string, _prompt: string, onChunk) => {
@ -1594,6 +1597,41 @@ describe("CustomAgentChatView", () => {
});
})
.mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => {
reattachedChunk({ kind: "final", content: "Stale cancelled answer" });
onChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "tool",
stage: "completed",
label: "web_search",
provider: "codex",
nativeEvent: "item.completed",
},
});
onChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "message",
stage: "completed",
label: "message intermédiaire",
text: "Complete intermediate answer",
provider: "codex",
nativeEvent: "item.completed",
},
});
onChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "turn",
stage: "completed",
label: "tour terminé côté provider",
provider: "codex",
nativeEvent: "turn.completed",
},
});
onChunk({ kind: "final", content: "Next answer" });
}),
stopLiveAgent: vi.fn(),
@ -1729,7 +1767,11 @@ describe("CustomAgentChatView", () => {
"next turn",
expect.any(Function),
);
expect(await screen.findByText("web_search")).toBeTruthy();
expect(await screen.findByText("Complete intermediate answer")).toBeTruthy();
expect(await screen.findByText("Next answer")).toBeTruthy();
expect(screen.queryByText("Stale cancelled answer")).toBeNull();
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
});
it.each(["alreadyCancelled", "alreadyCompleted"] as const)(

View File

@ -1503,10 +1503,17 @@ export function CustomAgentChatView({
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
stopTerminalOverrideRef.current = true;
setStopTerminalOverride(true);
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
setBusy(false);
void refreshRequesterDelegation();
}
},
[projectId, updateChatViewState],
[projectId, refreshRequesterDelegation, updateChatViewState],
);
useEffect(() => {
@ -2315,7 +2322,6 @@ export function CustomAgentChatView({
const attachmentLabels = outgoingAttachments.map((item) => item.label);
const displayText = text || "Pièce jointe";
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
ignoreUnscopedChunksAfterStopRef.current = false;
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
activeInteractionIdRef.current = interactionId;

View File

@ -25,6 +25,7 @@ function renderTabs(props: Partial<React.ComponentProps<typeof ProjectTabs>> = {
activeTabId={props.activeTabId ?? "a"}
onSelect={props.onSelect ?? vi.fn()}
onClose={props.onClose ?? vi.fn()}
onReorder={props.onReorder}
projectsPanelOpen={props.projectsPanelOpen ?? false}
onOpenProjectsPanel={onOpenProjectsPanel}
activityByProjectId={props.activityByProjectId}
@ -94,3 +95,44 @@ describe("ProjectTabs project activity dots (#159)", () => {
expect(screen.getByTitle(/beta.*échange en cours/i)).toBeTruthy();
});
});
describe("ProjectTabs reordering (#347)", () => {
it("reorders tabs horizontally by drag-and-drop without selecting or closing them", () => {
const onReorder = vi.fn();
const onSelect = vi.fn();
const onClose = vi.fn();
renderTabs({ onReorder, onSelect, onClose });
const alpha = screen.getByRole("tab", { name: /alpha/ });
const beta = screen.getByRole("tab", { name: /beta/ });
const alphaContainer = alpha.parentElement!;
const betaContainer = beta.parentElement!;
const data = new Map<string, string>();
const dataTransfer = {
effectAllowed: "none",
dropEffect: "none",
setData: (type: string, value: string) => data.set(type, value),
getData: (type: string) => data.get(type) ?? "",
};
fireEvent.dragStart(betaContainer, { dataTransfer });
fireEvent.dragOver(alphaContainer, { dataTransfer });
fireEvent.drop(alphaContainer, { dataTransfer, clientX: 0 });
expect(onReorder).toHaveBeenCalledWith(["b", "a"]);
expect(onSelect).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it("moves the focused tab with Alt+Arrow and announces the change", () => {
const onReorder = vi.fn();
renderTabs({ onReorder });
fireEvent.keyDown(screen.getByRole("tab", { name: /alpha/ }), {
key: "ArrowRight",
altKey: true,
});
expect(onReorder).toHaveBeenCalledWith(["b", "a"]);
expect(screen.getByText("alpha déplacé.")).toBeTruthy();
});
});

View File

@ -21,6 +21,7 @@ export interface ProjectTabsProps {
activeTabId: string | null;
onSelect: (id: string) => void;
onClose: (id: string) => void;
onReorder?: (orderedIds: string[]) => void;
/** Whether the Projects panel is currently open (in any placement). */
projectsPanelOpen: boolean;
/** Open the Projects panel (floating) — create/switch project. */
@ -36,6 +37,7 @@ export function ProjectTabs({
activeTabId,
onSelect,
onClose,
onReorder,
projectsPanelOpen,
onOpenProjectsPanel,
activityByProjectId = {},
@ -72,6 +74,7 @@ export function ProjectTabs({
value={activeTabId}
onSelect={onSelect}
onClose={onClose}
onReorder={onReorder}
/>
)}
{/* Add-project affordance (#46): always a bordered `secondary` button so

View File

@ -1071,6 +1071,7 @@ export function ProjectsView() {
activeTabId={vm.activeTabId}
onSelect={(id) => vm.activateTab(id)}
onClose={(id) => void vm.closeTab(id)}
onReorder={(orderedIds) => void vm.reorderTabs(orderedIds)}
activityByProjectId={projectActivity}
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
onOpenProjectsPanel={() => {

View File

@ -3,7 +3,7 @@
* real `DIProvider`. Covers create → list/tab, open → tab, close → remove,
* duplicate root (INVALID) and unknown id (NOT_FOUND) error handling.
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import {
render,
screen,
@ -76,6 +76,10 @@ function tablist() {
return screen.getByRole("tablist");
}
function tabLabels() {
return within(tablist()).getAllByRole("tab").map((tab) => tab.textContent?.trim());
}
/**
* Opens a panel floating via the single « Panneaux » menu (#26): click the menu,
* click the panel entry (opens its placement submenu), then pick « Flottant ».
@ -165,6 +169,56 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect(screen.queryByRole("status")).toBeNull();
});
it("reorders project tabs, persists the order, and preserves the active tab", async () => {
const project = new MockProjectGateway();
const alpha = await project.createProject("alpha", "/p/a");
const beta = await project.createProject("beta", "/p/b");
project.setOpenProjectRestoreOrder([alpha.id, beta.id]);
const saveOrder = vi.spyOn(project, "saveOpenProjectRestoreOrder");
renderView(project);
await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"]));
expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(screen.getByRole("tab", { name: "beta" }), {
key: "ArrowLeft",
altKey: true,
});
await waitFor(() => expect(tabLabels()).toEqual(["beta", "alpha"]));
expect(saveOrder).toHaveBeenCalledWith([beta.id, alpha.id]);
expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true");
await expect(project.readOpenProjectRestoreState()).resolves.toMatchObject({
entries: [
expect.objectContaining({ projectId: beta.id, position: 0 }),
expect.objectContaining({ projectId: alpha.id, position: 1 }),
],
});
});
it("rolls a tab reorder back when persistence fails", async () => {
const project = new MockProjectGateway();
const alpha = await project.createProject("alpha", "/p/a");
const beta = await project.createProject("beta", "/p/b");
project.setOpenProjectRestoreOrder([alpha.id, beta.id]);
let rejectSave: (reason: unknown) => void = () => {};
vi.spyOn(project, "saveOpenProjectRestoreOrder").mockImplementationOnce(
() => new Promise((_resolve, reject) => { rejectSave = reject; }),
);
renderView(project);
await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"]));
fireEvent.keyDown(screen.getByRole("tab", { name: "beta" }), {
key: "ArrowLeft",
altKey: true,
});
await waitFor(() => expect(tabLabels()).toEqual(["beta", "alpha"]));
rejectSave({ code: "STORE", message: "could not persist tab order" });
await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"]));
expect(screen.getByRole("alert").textContent).toContain("could not persist tab order");
expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true");
});
it("shows one restore warning, details its cause, and retries at the retained position", async () => {
const project = new MockProjectGateway();
const alpha = await project.createProject("alpha", "/p/a");

View File

@ -40,6 +40,8 @@ export interface ProjectsViewModel {
openProject: (projectId: string) => Promise<void>;
/** Closes a tab (does not delete the project from the registry). */
closeTab: (projectId: string) => Promise<void>;
/** Reorders open tabs optimistically and persists their restore order. */
reorderTabs: (orderedProjectIds: string[]) => Promise<void>;
/** Removes a project from IdeA's registry without deleting its files. */
removeProject: (projectId: string) => Promise<void>;
/** Retries one failed restore, or every failure when omitted. */
@ -68,6 +70,9 @@ export function useProjects(): ProjectsViewModel {
const [restoreFailures, setRestoreFailures] = useState<OpenProjectRestoreFailure[]>([]);
const [restoreNotificationVisible, setRestoreNotificationVisible] = useState(false);
const initializationStarted = useRef(false);
const openTabsRef = useRef<Project[]>([]);
const reorderInFlight = useRef(false);
openTabsRef.current = openTabs;
const refresh = useCallback(async () => {
setBusy(true);
@ -174,6 +179,43 @@ export function useProjects(): ProjectsViewModel {
[project],
);
const reorderTabs = useCallback(
async (orderedProjectIds: string[]) => {
if (reorderInFlight.current) return;
const previous = openTabsRef.current;
if (
orderedProjectIds.length !== previous.length ||
new Set(orderedProjectIds).size !== previous.length ||
orderedProjectIds.some((id) => !previous.some((tab) => tab.id === id))
) return;
if (orderedProjectIds.every((id, index) => id === previous[index]?.id)) return;
const byId = new Map(previous.map((tab) => [tab.id, tab]));
const next = orderedProjectIds.map((id) => byId.get(id)!);
reorderInFlight.current = true;
openTabsRef.current = next;
setOpenTabs(next);
setError(null);
try {
await project.saveOpenProjectRestoreOrder(orderedProjectIds);
} catch (e) {
setOpenTabs((current) => {
const currentById = new Map(current.map((tab) => [tab.id, tab]));
const restored = previous
.filter((tab) => currentById.has(tab.id))
.map((tab) => currentById.get(tab.id)!);
const previousIds = new Set(previous.map((tab) => tab.id));
const merged = [...restored, ...current.filter((tab) => !previousIds.has(tab.id))];
openTabsRef.current = merged;
return merged;
});
setError(describe(e));
} finally {
reorderInFlight.current = false;
}
},
[project],
);
const reconcileRemovedProject = useCallback(
(projectId: string, nextProjects: Project[]) => {
setProjects(nextProjects);
@ -290,6 +332,7 @@ export function useProjects(): ProjectsViewModel {
createProject,
openProject,
closeTab,
reorderTabs,
removeProject,
retryRestore,
removeFromRestore,