fix(windows): fenêtres de panneau détachées suivent le projet en focus côté UI (#47)
Partie frontend. Ajoute un adaptateur focusedProject et un port dédié : la ViewWindow détachée n'est plus liée à un project_id figé, elle s'abonne à l'event focused-project émis par la fenêtre principale et affiche le panneau du projet courant. ProjectsView propage le focus ; le détachement crée une fenêtre panel-only. Couvert par les tests window/ViewWindow/focusedProject/ ProjectsView.focus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -12,6 +12,7 @@ import {
|
||||
MockAgentGateway,
|
||||
MockGitGateway,
|
||||
MockProfileGateway,
|
||||
MockFocusedProjectGateway,
|
||||
MockProjectGateway,
|
||||
MockSystemGateway,
|
||||
MockTemplateGateway,
|
||||
@ -24,7 +25,7 @@ import { ProjectsView } from "./ProjectsView";
|
||||
|
||||
async function renderWithProject() {
|
||||
const project = new MockProjectGateway();
|
||||
const created = await project.createProject("alpha", "/p/a");
|
||||
await project.createProject("alpha", "/p/a");
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const windowGateway = new MockWindowGateway();
|
||||
const gateways = {
|
||||
@ -36,6 +37,7 @@ async function renderWithProject() {
|
||||
git: new MockGitGateway(),
|
||||
workState: new MockWorkStateGateway(),
|
||||
window: windowGateway,
|
||||
focusedProject: new MockFocusedProjectGateway(),
|
||||
} as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
@ -46,7 +48,7 @@ async function renderWithProject() {
|
||||
await waitFor(() =>
|
||||
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
|
||||
);
|
||||
return { windowGateway, projectId: created.id };
|
||||
return { windowGateway };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -62,7 +64,7 @@ function pickPlacement(panelTitle: string, placementLabel: string) {
|
||||
|
||||
describe("ProjectsView detach to OS window (#23)", () => {
|
||||
it("« Fenêtre détachée » detaches a view: opens the OS window and marks the slot detached", async () => {
|
||||
const { windowGateway, projectId } = await renderWithProject();
|
||||
const { windowGateway } = await renderWithProject();
|
||||
|
||||
// A docked view is visible in the main window first…
|
||||
pickPlacement("Git", "Ancré à gauche");
|
||||
@ -72,7 +74,7 @@ describe("ProjectsView detach to OS window (#23)", () => {
|
||||
pickPlacement("Git", "Fenêtre détachée");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
|
||||
expect(windowGateway.open.has("git")).toBe(true),
|
||||
);
|
||||
// The slot is detached ⇒ nothing renders for Git in the main window.
|
||||
await waitFor(() =>
|
||||
@ -82,11 +84,11 @@ describe("ProjectsView detach to OS window (#23)", () => {
|
||||
});
|
||||
|
||||
it("re-toggles the placement out of detached when the OS window closes", async () => {
|
||||
const { windowGateway, projectId } = await renderWithProject();
|
||||
const { windowGateway } = await renderWithProject();
|
||||
|
||||
pickPlacement("Git", "Fenêtre détachée");
|
||||
await waitFor(() =>
|
||||
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
|
||||
expect(windowGateway.open.has("git")).toBe(true),
|
||||
);
|
||||
|
||||
// The « Fenêtre détachée » submenu leaf shows the active marker (●) while
|
||||
@ -103,7 +105,7 @@ describe("ProjectsView detach to OS window (#23)", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); // close
|
||||
|
||||
// The backend reports the OS window closed ⇒ the slot leaves "detached".
|
||||
windowGateway.simulateOsClose("git", projectId);
|
||||
windowGateway.simulateOsClose("git");
|
||||
openGitSubmenu();
|
||||
await waitFor(() =>
|
||||
expect(detachedLeaf().textContent).not.toContain("●"),
|
||||
|
||||
78
frontend/src/features/projects/ProjectsView.focus.test.tsx
Normal file
78
frontend/src/features/projects/ProjectsView.focus.test.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* #47 — `ProjectsView` is the single writer of the focused-project channel: it
|
||||
* publishes the active project (or `null` when none is active) so detached
|
||||
* panel-only windows can follow it. This drives the open → publish → close →
|
||||
* publish-null flow and asserts what reaches the {@link FocusedProjectGateway}.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, within, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockFocusedProjectGateway,
|
||||
MockGitGateway,
|
||||
MockProfileGateway,
|
||||
MockProjectGateway,
|
||||
MockSystemGateway,
|
||||
MockTemplateGateway,
|
||||
MockWindowGateway,
|
||||
MockWorkStateGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { FocusedProject, Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { ProjectsView } from "./ProjectsView";
|
||||
|
||||
async function renderView() {
|
||||
const project = new MockProjectGateway();
|
||||
const created = await project.createProject("alpha", "/p/a");
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
// Record every published focus so we can assert the sequence.
|
||||
const published: (FocusedProject | null)[] = [];
|
||||
await focusedProject.onFocusedProjectChanged((p) => published.push(p));
|
||||
const gateways = {
|
||||
system: new MockSystemGateway(),
|
||||
project,
|
||||
agent: agentGateway,
|
||||
profile: new MockProfileGateway(),
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
git: new MockGitGateway(),
|
||||
workState: new MockWorkStateGateway(),
|
||||
window: new MockWindowGateway(),
|
||||
focusedProject,
|
||||
} as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectsView />
|
||||
</DIProvider>,
|
||||
);
|
||||
return { focusedProject, published, projectId: created.id };
|
||||
}
|
||||
|
||||
describe("ProjectsView focus publishing (#47)", () => {
|
||||
it("publishes the active project on open and null when it is closed", async () => {
|
||||
const { focusedProject, published, projectId } = await renderView();
|
||||
|
||||
// Mount with no active project publishes a `null` focus.
|
||||
await waitFor(() => expect(published).toContainEqual(null));
|
||||
|
||||
// Open the project → publishes the { id, name, root } snapshot.
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
|
||||
await waitFor(() =>
|
||||
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(focusedProject.getFocusedProject()).resolves.toEqual({
|
||||
id: projectId,
|
||||
name: "alpha",
|
||||
root: "/p/a",
|
||||
}),
|
||||
);
|
||||
|
||||
// Close the tab → back to no active project → publishes `null`.
|
||||
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
|
||||
await waitFor(() =>
|
||||
expect(focusedProject.getFocusedProject()).resolves.toBeNull(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -102,7 +102,7 @@ function isTerminalBackgroundTaskEvent(
|
||||
|
||||
export function ProjectsView() {
|
||||
const vm = useProjects();
|
||||
const { system, window: windowGateway } = useGateways();
|
||||
const { system, window: windowGateway, focusedProject } = useGateways();
|
||||
const [name, setName] = useState("");
|
||||
const [root, setRoot] = useState("");
|
||||
// Placement of every open view (#22): each panel is "closed" (absent),
|
||||
@ -141,6 +141,17 @@ export function ProjectsView() {
|
||||
setViewerConversationId(null);
|
||||
}, [active?.id]);
|
||||
|
||||
// Publish the focused project (#47) so detached panel-only windows follow the
|
||||
// main window: they render this project, or an "open a project" shell when
|
||||
// none is active. Publish on EVERY change of `active` — including
|
||||
// switching-away to null — so a restored panel window never shows a stale
|
||||
// project. Optional-chained: unit tests may omit this gateway.
|
||||
useEffect(() => {
|
||||
void focusedProject?.setFocusedProject(
|
||||
active ? { id: active.id, name: active.name, root: active.root } : null,
|
||||
);
|
||||
}, [focusedProject, active?.id, active?.name, active?.root]);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
@ -227,15 +238,16 @@ export function ProjectsView() {
|
||||
});
|
||||
}
|
||||
|
||||
// Detach a view into its own OS window (#23): ask the backend to open the
|
||||
// window, then mark the slot "detached" so nothing renders for it in the main
|
||||
// window. Requires an active project (the window is project-scoped). On
|
||||
// failure the placement is left untouched (no ghost "detached" slot).
|
||||
// Detach a view into its own OS window (#23, panel-only in #47): ask the
|
||||
// backend to open the window, then mark the slot "detached" so nothing renders
|
||||
// for it in the main window. The window is panel-only and follows the focused
|
||||
// project (published above), so no project id is passed. Requires an active
|
||||
// project only so a detached window opens onto something rather than the empty
|
||||
// shell. On failure the placement is left untouched (no ghost "detached" slot).
|
||||
function detachPanel(panel: PanelId) {
|
||||
if (!active) return;
|
||||
const projectId = active.id;
|
||||
void windowGateway
|
||||
.openViewWindow(panel, projectId)
|
||||
.openViewWindow(panel)
|
||||
.then(() => setPlacement(panel, "detached"))
|
||||
.catch(() => {
|
||||
/* window failed to open; keep the current placement */
|
||||
|
||||
Reference in New Issue
Block a user