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:
@ -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)(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user