From 45f819e1e85ede438d280699246de4f41b6b7ec3 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 11 Sep 2026 11:27:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(frontend,application):=20lot=20utilisateur?= =?UTF-8?q?=20#315=20=E2=80=94=20d=C3=A9tachement=20Param=C3=A8tres=20proj?= =?UTF-8?q?et,=20couleurs=20projet=20fiables,=20restauration=20conversatio?= =?UTF-8?q?n=20custom=20CLI,=20images=20post-aller-retour=20projet,=20retr?= =?UTF-8?q?ait=20menus=20Projects/Project=20Context=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - détachement: le panneau Paramètres du projet devient détachable (mapping historique panel=context → projectSettings dans parseViewWindowParams, DETACHABLE/ViewPanelBody alignés, ProjectsView ne court-circuitant plus le refus); Retry d'échec de détachement préservé - couleurs projet: projectTheme publie idea:project-theme-changed localement + BroadcastChannel inter-webviews (fenêtres OS détachées), garde de révision par projet dans ProjectsView/ViewWindow contre les lectures settings en course — plus de flash/retour au bleu par défaut - conversation custom CLI: transcript_restore_expected ne considère plus l'absence de scrollback volatile comme une restauration échouée quand la session rouvre en SessionPlan::None (continuation vide valide); CustomAgentChatView n'affiche plus le faux « conversation liée n'a pas pu être restaurée » - images: conservation des images de conversation après changement de projet puis retour - menus Panneaux: retrait des entrées Projects et Project Context (panelOrder WebWorkspace, création/switch via tab-bar +, contexte projet fusionné dans Paramètres du projet) - QA verte: tests frontend ciblés window/ViewWindow/ProjectSettingsPanel/ProjectsView.detach/projects/CustomAgentChatView/WebDesktopWorkspace + cargo test -p application lifecycle transcript_restore_expected; fichiers .ideai/* (tickets 308-315, index/counter, agents/test, chat-drafts, system-permissions, project-settings.json, attachments/) et scripts/ hors périmètre préservés en worktree; aucun push --- crates/application/src/agent/lifecycle.rs | 21 +++++- frontend/src/adapters/window.test.ts | 17 +++++ frontend/src/adapters/window.ts | 23 +++++-- frontend/src/app/ViewWindow.test.tsx | 45 ++++++++++++- frontend/src/app/ViewWindow.tsx | 50 ++++++++++++-- .../agents/CustomAgentChatView.test.tsx | 34 +++++++++- .../features/agents/CustomAgentChatView.tsx | 66 +++++++++++++++---- .../projects/ProjectSettingsPanel.test.tsx | 24 +++++++ .../projects/ProjectSettingsPanel.tsx | 64 ++++++++++-------- .../projects/ProjectsView.detach.test.tsx | 13 ++++ .../src/features/projects/ProjectsView.tsx | 54 +++++++++------ .../src/features/projects/projectTheme.ts | 46 +++++++++++++ .../src/features/projects/projects.test.tsx | 45 +++++++++++++ .../features/web/WebDesktopWorkspace.test.tsx | 12 ++-- frontend/src/features/web/WebWorkspace.tsx | 2 - 15 files changed, 434 insertions(+), 82 deletions(-) diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index fa9eea4b..4b96f89b 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -2752,7 +2752,7 @@ impl LaunchAgent { &input.project.root, input.node_id, size, - launch_conversation_id.is_some(), + transcript_restore_expected(&session_plan), &spec.env, None, structured_policy.as_ref(), @@ -3651,6 +3651,14 @@ pub(crate) enum ReattachDecision { Refuse { node_id: NodeId }, } +/// A missing volatile scrollback is only a failed restoration when the provider +/// was actually asked to resume an engine session. A logical conversation id can +/// also reopen with [`SessionPlan::None`] when no provider resumable was persisted; +/// that is a valid empty continuation, notably for custom structured CLIs. +fn transcript_restore_expected(session_plan: &SessionPlan) -> bool { + matches!(session_plan, SessionPlan::Resume { .. }) +} + impl ReattachDecision { /// Decides reattach vs refuse for an already-live agent. See [`reattach_decision`]. #[must_use] @@ -4712,6 +4720,17 @@ mod tests { } } + #[test] + fn transcript_restoration_is_expected_only_for_an_effective_provider_resume() { + assert!(transcript_restore_expected(&SessionPlan::Resume { + conversation_id: "engine-session".to_owned(), + })); + assert!(!transcript_restore_expected(&SessionPlan::None)); + assert!(!transcript_restore_expected(&SessionPlan::Assign { + conversation_id: "new-session".to_owned(), + })); + } + #[test] fn codex_pty_first_launch_pairs_workspace_mode_with_project_and_temp_roots() { let mut spec = spawn_spec(Vec::new()); diff --git a/frontend/src/adapters/window.test.ts b/frontend/src/adapters/window.test.ts index 61f65a77..458e4990 100644 --- a/frontend/src/adapters/window.test.ts +++ b/frontend/src/adapters/window.test.ts @@ -29,6 +29,18 @@ describe("TauriWindowGateway (#23)", () => { }); }); + it("maps project settings to the backend's historical context window id", async () => { + const gw = new TauriWindowGateway(); + await gw.openViewWindow("projectSettings"); + expect(invoke).toHaveBeenLastCalledWith("open_view_window", { + panel: "context", + }); + await gw.closeViewWindow("projectSettings"); + expect(invoke).toHaveBeenLastCalledWith("close_view_window", { + panel: "context", + }); + }); + it("opens plugin layout windows through the backend command", async () => { invoke.mockResolvedValueOnce({ label: "view-plugin-layout", @@ -98,6 +110,11 @@ describe("TauriWindowGateway (#23)", () => { expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith({ panel: "git" }); + raw?.({ + payload: { kind: "closed", panel: "context", label: "view-context" }, + }); + expect(handler).toHaveBeenLastCalledWith({ panel: "projectSettings" }); + off(); expect(unlisten).toHaveBeenCalled(); }); diff --git a/frontend/src/adapters/window.ts b/frontend/src/adapters/window.ts index 739d77e9..bdb3381f 100644 --- a/frontend/src/adapters/window.ts +++ b/frontend/src/adapters/window.ts @@ -25,6 +25,17 @@ import type { /** The single Tauri event carrying detached-window lifecycle transitions. */ const VIEW_WINDOW_LIFECYCLE = "view-window://lifecycle"; +// The backend keeps the historical `context` window identity. Project context +// now lives inside `projectSettings`, so the adapter owns the compatibility +// alias while the UI consistently uses the current panel id. +function toBackendPanel(panel: string): string { + return panel === "projectSettings" ? "context" : panel; +} + +function fromBackendPanel(panel: string): string { + return panel === "context" ? "projectSettings" : panel; +} + /** * Backend lifecycle payload (discriminated on `kind`). Ticket #47 dropped the * `projectId` field — a detached window is panel-only and follows the focused @@ -40,7 +51,7 @@ export class TauriWindowGateway implements WindowGateway { async openViewWindow(panel: string): Promise { // Panel-only window (#47): no project id — the window follows the focused // project published on `focused-project://changed`. - await invoke("open_view_window", { panel }); + await invoke("open_view_window", { panel: toBackendPanel(panel) }); } async openPluginLayoutWindow( @@ -52,13 +63,17 @@ export class TauriWindowGateway implements WindowGateway { } async closeViewWindow(panel: string): Promise { - await invoke("close_view_window", { panel }); + await invoke("close_view_window", { panel: toBackendPanel(panel) }); } async listOpenViewWindows(): Promise { // Backend serialises the snapshots in camelCase (panel/label/visible), so the // wire shape matches the port type directly. - return invoke("list_open_view_windows"); + const snapshots = await invoke("list_open_view_windows"); + return snapshots.map((snapshot) => ({ + ...snapshot, + panel: fromBackendPanel(snapshot.panel), + })); } async onViewWindowClosed( @@ -70,7 +85,7 @@ export class TauriWindowGateway implements WindowGateway { VIEW_WINDOW_LIFECYCLE, (e) => { if (e.payload.kind !== "closed") return; - handler({ panel: e.payload.panel }); + handler({ panel: fromBackendPanel(e.payload.panel) }); }, ); return unlisten; diff --git a/frontend/src/app/ViewWindow.test.tsx b/frontend/src/app/ViewWindow.test.tsx index 175f0280..6ceef10c 100644 --- a/frontend/src/app/ViewWindow.test.tsx +++ b/frontend/src/app/ViewWindow.test.tsx @@ -6,7 +6,7 @@ * project itself. */ import { describe, it, expect } from "vitest"; -import { render, screen, waitFor, act } from "@testing-library/react"; +import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; import { DIProvider } from "@/app/di"; import { @@ -44,6 +44,13 @@ describe("parseViewWindowParams (#47)", () => { }); }); + it("maps the historical context window URL to project settings", () => { + expect(parseViewWindowParams("?panel=context")).toEqual({ + kind: "panel", + panel: "projectSettings", + }); + }); + it("accepts the detachable MCP panel", () => { expect(parseViewWindowParams("?panel=mcp")).toEqual({ kind: "panel", @@ -155,6 +162,42 @@ describe("PluginLayoutWindow (#143)", () => { }); describe("ViewWindow (#47)", () => { + it("applies and updates the focused project colour in a detached settings window", async () => { + const system = new MockSystemGateway(); + const project = new MockProjectGateway(); + const created = await project.createProject("alpha", "/p/a"); + await project.updateProjectSettings(created.id, { themeColor: "#AA3366" }); + const focusedProject = new MockFocusedProjectGateway(); + await focusedProject.setFocusedProject({ + id: created.id, + name: "alpha", + root: "/p/a", + }); + const gateways = { + system, + project, + agent: new MockAgentGateway(), + ticket: new MockTicketGateway(system), + focusedProject, + } as unknown as Gateways; + + const { container } = render( + + + , + ); + const root = container.firstElementChild as HTMLElement; + await waitFor(() => + expect(root.style.getPropertyValue("--project-theme-color")).toBe("#AA3366"), + ); + + const picker = await screen.findByLabelText("Couleur du projet"); + fireEvent.change(picker, { target: { value: "#22aa66" } }); + await waitFor(() => + expect(root.style.getPropertyValue("--project-theme-color")).toBe("#22AA66"), + ); + }); + it("shows the 'open a project' shell when no project is focused", async () => { const system = new MockSystemGateway(); const gateways = { diff --git a/frontend/src/app/ViewWindow.tsx b/frontend/src/app/ViewWindow.tsx index e5b732d1..7a4dc015 100644 --- a/frontend/src/app/ViewWindow.tsx +++ b/frontend/src/app/ViewWindow.tsx @@ -19,13 +19,18 @@ * and crucially no `openProject()` — opening a project is the main window's job. */ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { CustomPluginLayoutCell } from "@/domain"; import type { FocusedProject } from "@/ports"; import { Panel } from "@/shared"; import { PANEL_TITLE, type PanelId } from "@/features/projects"; import { ViewPanelBody, type ViewPanelId } from "@/features/projects"; +import { + onProjectThemeChanged, + projectThemeStyle, + validThemeColor, +} from "@/features/projects/projectTheme"; import { PluginLayoutCellView, PluginRuntimeProvider, @@ -98,7 +103,8 @@ export function parseViewWindowParams( }; } - const panel = params.get("panel"); + const rawPanel = params.get("panel"); + const panel = rawPanel === "context" ? "projectSettings" : rawPanel; if (!panel || !DETACHABLE.has(panel)) return null; return { kind: "panel", panel: panel as ViewPanelId }; } @@ -108,12 +114,14 @@ export interface ViewWindowProps { } export function ViewWindow({ panel }: ViewWindowProps) { - const { focusedProject } = useGateways(); + const { focusedProject, project } = useGateways(); // The focused project this window follows. `undefined` = not yet resolved // (initial read in flight); `null` = resolved, no project focused. const [focus, setFocus] = useState( undefined, ); + const [themeColors, setThemeColors] = useState>({}); + const themeRevisionRef = useRef>({}); // Read the current focus at mount, then track changes. Never opens a project: // the window is a pure follower of the main window's focus (#47). @@ -142,8 +150,42 @@ export function ViewWindow({ panel }: ViewWindowProps) { }; }, [focusedProject]); + useEffect(() => { + if (!focus) return; + let cancelled = false; + const revision = themeRevisionRef.current[focus.id] ?? 0; + void project.readProjectSettings(focus.id).then((settings) => { + if ( + !cancelled && + (themeRevisionRef.current[focus.id] ?? 0) === revision + ) { + setThemeColors((current) => ({ + ...current, + [focus.id]: validThemeColor(settings.themeColor), + })); + } + }).catch(() => { + // Keep the last known colour; settings content owns its own retry state. + }); + return () => { + cancelled = true; + }; + }, [focus?.id, project]); + + useEffect(() => onProjectThemeChanged(({ projectId, themeColor }) => { + themeRevisionRef.current[projectId] = + (themeRevisionRef.current[projectId] ?? 0) + 1; + setThemeColors((current) => ({ + ...current, + [projectId]: validThemeColor(themeColor), + })); + }), []); + return ( -
+

{PANEL_TITLE[panel]} diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 9e7f2d64..a1fe0392 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -199,10 +199,14 @@ describe("CustomAgentChatView", () => { it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => { const agent = { - launchAgentChat: vi.fn(async () => ({ sessionId: "fresh-session" })), + launchAgentChat: vi.fn(async () => ({ + sessionId: "fresh-session", + transcriptUnavailableReason: "missingTranscript" as const, + })), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], + transcriptUnavailableReason: "missingTranscript" as const, })), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), @@ -253,6 +257,29 @@ describe("CustomAgentChatView", () => { expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy(); }); + it("keeps an empty linked custom CLI continuation usable without explicit transcript loss", async () => { + const agent = { + launchAgentChat: vi.fn(async () => ({ sessionId: "valid-session" })), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + render( + null) } } as unknown as Gateways}> + + , + ); + + expect( + await screen.findByText("Envoyez un message pour démarrer la conversation structurée."), + ).toBeTruthy(); + expect(screen.queryByText("La conversation liée n’a pas pu être restaurée.")).toBeNull(); + }); + it("treats emptyConversation as a normal fresh conversation", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ @@ -4944,7 +4971,7 @@ describe("CustomAgentChatView", () => { ); }); - it("does not persist attachments with the restored draft", async () => { + it("keeps attachment drafts in memory across project unmount and return", async () => { const drafts = new Map(); const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" }; const agent = { @@ -5023,7 +5050,8 @@ describe("CustomAgentChatView", () => { renderView(); await screen.findByDisplayValue("draft with file[Image 1]"); - expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull(); + expect(screen.getByText("Fichier joint: not-persisted.png")).toBeTruthy(); + expect(screen.getByTestId("attachment-preview-not-persisted.png")).toBeTruthy(); }); it("clears the persisted draft when sending the composer", async () => { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 4c4e36bf..29544975 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -89,6 +89,8 @@ interface CachedChatView { const chatSessionViewCache = new Map(); const chatConversationViewCache = new Map(); +const chatAttachmentDraftCache = new Map(); +const CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT = 24; const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse."; const COMPOSER_RESIZE_STEP = 12; const FALLBACK_COMPOSER_HEIGHT = 64; @@ -97,6 +99,7 @@ const COMPOSER_HISTORY_LIMIT = 100; export function __clearCustomAgentChatViewCacheForTests() { chatSessionViewCache.clear(); chatConversationViewCache.clear(); + chatAttachmentDraftCache.clear(); } interface AttachmentDraft { @@ -108,6 +111,22 @@ interface AttachmentDraft { token?: string; } +function attachmentDraftCacheKey(scope: ChatDraftScope | null): string | null { + return scope + ? `${scope.projectId}\u0000${scope.layoutId}\u0000${scope.nodeId}` + : null; +} + +function cacheAttachmentDraft(key: string, attachments: AttachmentDraft[]): void { + chatAttachmentDraftCache.delete(key); + if (attachments.length > 0) chatAttachmentDraftCache.set(key, attachments); + while (chatAttachmentDraftCache.size > CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT) { + const oldest = chatAttachmentDraftCache.keys().next().value as string | undefined; + if (!oldest) break; + chatAttachmentDraftCache.delete(oldest); + } +} + interface ProgressIndexEntry { firstOccurredAtMs: number; sequence: number; @@ -963,8 +982,16 @@ export function CustomAgentChatView({ ); const [currentSession, setCurrentSession] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId); + const initialDraftScope = explicitDraftScope ?? ( + layoutId && nodeId ? { projectId, layoutId, nodeId } : null + ); + const initialAttachmentCacheKey = attachmentDraftCacheKey(initialDraftScope); const [draft, setDraft] = useState(""); - const [attachments, setAttachments] = useState([]); + const [attachments, setAttachments] = useState(() => + initialAttachmentCacheKey + ? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? []) + : [], + ); const [slashCommands, setSlashCommands] = useState([]); const [slashMenuOpen, setSlashMenuOpen] = useState(false); const [slashActiveIndex, setSlashActiveIndex] = useState(0); @@ -1008,6 +1035,7 @@ export function CustomAgentChatView({ const placeComposerAtEndAfterHistoryRef = useRef(false); const attachmentsRef = useRef([]); attachmentsRef.current = attachments; + const attachmentCacheKeyRef = useRef(initialAttachmentCacheKey); const imageTokenSeqRef = useRef(0); const mountedRef = useRef(false); const openOrAttachCountRef = useRef(0); @@ -1111,6 +1139,7 @@ export function CustomAgentChatView({ if (explicitDraftScope) return explicitDraftScope; return layoutId && nodeId ? { projectId, layoutId, nodeId } : null; }, [explicitDraftScope, layoutId, nodeId, projectId]); + const attachmentCacheKey = attachmentDraftCacheKey(draftScope); const currentConversationCacheKey = useMemo( () => conversationCacheKey(projectId, agentId, conversationId), [agentId, conversationId, projectId], @@ -1284,6 +1313,27 @@ export function CustomAgentChatView({ writeComposerHistory(composerHistoryKey, composerHistory); }, [composerHistory, composerHistoryKey]); + // Attachments can be large, so keep draft bytes only in this renderer's + // bounded lifetime instead of serialising base64 into localStorage. The + // project/layout/node scope mirrors the persisted text draft contract. + useEffect(() => { + const key = attachmentCacheKeyRef.current; + if (key) cacheAttachmentDraft(key, attachments); + }, [attachments]); + + useEffect(() => { + const previousKey = attachmentCacheKeyRef.current; + if (previousKey) { + cacheAttachmentDraft(previousKey, attachmentsRef.current); + } + attachmentCacheKeyRef.current = attachmentCacheKey; + setAttachments( + attachmentCacheKey + ? (chatAttachmentDraftCache.get(attachmentCacheKey) ?? []) + : [], + ); + }, [attachmentCacheKey]); + useEffect(() => { void refreshRequesterDelegation(); }, [refreshRequesterDelegation]); @@ -1928,19 +1978,11 @@ export function CustomAgentChatView({ !opening, [supported, draft, attachments.length, effectiveBusy, opening], ); - const hasLinkedConversation = Boolean( - conversationId || - transcriptUnavailableReason === "missingTranscript" || - transcriptUnavailableReason === "conversationNotFound" || - requesterDelegation.currentAgentBusy || - restart || - delegationActive, - ); const unavailableLinkedTranscript = displayTurns.length === 0 && - hasLinkedConversation && - transcriptUnavailableReason !== "emptyConversation" - ? transcriptUnavailableReason ?? "missingTranscript" + (transcriptUnavailableReason === "missingTranscript" || + transcriptUnavailableReason === "conversationNotFound") + ? transcriptUnavailableReason : null; function unlinkUnavailableConversation() { diff --git a/frontend/src/features/projects/ProjectSettingsPanel.test.tsx b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx index 76ef9ee0..5eb7724c 100644 --- a/frontend/src/features/projects/ProjectSettingsPanel.test.tsx +++ b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx @@ -31,6 +31,30 @@ describe("ProjectSettingsPanel", () => { window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, changed); }); + it("clears a transient load error and retries the current project", async () => { + const gateways = createMockGateways(); + const project = await gateways.project.createProject("Alpha", "/alpha"); + const read = vi + .spyOn(gateways.project, "readProjectSettings") + .mockRejectedValueOnce(new Error("temporary")) + .mockResolvedValue({ themeColor: "#22AA66" }); + render( + + + , + ); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Impossible de charger"); + fireEvent.click(screen.getByRole("button", { name: "Réessayer" })); + + await waitFor(() => expect(read).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); + expect((screen.getByLabelText("Couleur du projet") as HTMLInputElement).value).toBe( + "#22aa66", + ); + }); + it("keeps strict colours and raises dark accents to readable contrast", () => { expect(validThemeColor("#12abEF")).toBe("#12ABEF"); expect(validThemeColor("red")).toBeNull(); diff --git a/frontend/src/features/projects/ProjectSettingsPanel.tsx b/frontend/src/features/projects/ProjectSettingsPanel.tsx index 6a29f6b7..2fe9a022 100644 --- a/frontend/src/features/projects/ProjectSettingsPanel.tsx +++ b/frontend/src/features/projects/ProjectSettingsPanel.tsx @@ -1,39 +1,46 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useGateways } from "@/app/di"; import { Button, Panel, Spinner } from "@/shared"; import { ProjectContextPanel } from "./ProjectContextPanel"; -import { DEFAULT_PROJECT_COLOR, validThemeColor } from "./projectTheme"; +import { + DEFAULT_PROJECT_COLOR, + publishProjectThemeChanged, + validThemeColor, +} from "./projectTheme"; -export const PROJECT_THEME_CHANGED_EVENT = "idea:project-theme-changed"; +export { PROJECT_THEME_CHANGED_EVENT } from "./projectTheme"; export function ProjectSettingsPanel({ projectId }: { projectId: string }) { const { project } = useGateways(); const [themeColor, setThemeColor] = useState(null); const [busy, setBusy] = useState(true); const [error, setError] = useState(null); + const loadSequence = useRef(0); + + const load = useCallback(async () => { + const sequence = ++loadSequence.current; + setBusy(true); + setError(null); + setThemeColor(null); + try { + const settings = await project.readProjectSettings(projectId); + if (loadSequence.current !== sequence) return; + setThemeColor(validThemeColor(settings.themeColor)); + } catch { + if (loadSequence.current !== sequence) return; + setError("Impossible de charger les paramètres du projet."); + } finally { + if (loadSequence.current === sequence) setBusy(false); + } + }, [project, projectId]); useEffect(() => { - let cancelled = false; - setBusy(true); - project - .readProjectSettings(projectId) - .then((settings) => { - if (!cancelled) { - setThemeColor(validThemeColor(settings.themeColor)); - setError(null); - } - }) - .catch(() => { - if (!cancelled) setError("Impossible de charger les paramètres du projet."); - }) - .finally(() => { - if (!cancelled) setBusy(false); - }); + void load(); return () => { - cancelled = true; + loadSequence.current += 1; }; - }, [project, projectId]); + }, [load]); async function save(next: string | null) { setBusy(true); @@ -42,11 +49,7 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) { const saved = await project.updateProjectSettings(projectId, { themeColor: next }); const normalized = validThemeColor(saved.themeColor); setThemeColor(normalized); - window.dispatchEvent( - new CustomEvent(PROJECT_THEME_CHANGED_EVENT, { - detail: { projectId, themeColor: normalized }, - }), - ); + publishProjectThemeChanged({ projectId, themeColor: normalized }); } catch { setError("Impossible d’enregistrer la couleur du projet."); } finally { @@ -74,7 +77,14 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {

{busy && } - {error &&

{error}

} + {error && ( +
+ {error} + +
+ )}
diff --git a/frontend/src/features/projects/ProjectsView.detach.test.tsx b/frontend/src/features/projects/ProjectsView.detach.test.tsx index 4175b8ab..d51adcbe 100644 --- a/frontend/src/features/projects/ProjectsView.detach.test.tsx +++ b/frontend/src/features/projects/ProjectsView.detach.test.tsx @@ -92,6 +92,19 @@ describe("ProjectsView detach to OS window (#23)", () => { expect(screen.queryByRole("dialog", { name: "MCP" })).toBeNull(); }); + it("detaches project settings through the same retry-safe window path", async () => { + const { windowGateway } = await renderWithProject(); + + pickPlacement("Paramètres du projet", "Fenêtre détachée"); + + await waitFor(() => + expect(windowGateway.open.has("projectSettings")).toBe(true), + ); + expect( + screen.queryByRole("dialog", { name: "Paramètres du projet" }), + ).toBeNull(); + }); + it("keeps the panel in place and surfaces a retry when detaching fails", async () => { const { windowGateway } = await renderWithProject(); const openViewWindow = vi diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 70b593be..df4c0d92 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -16,14 +16,12 @@ * with its own internal section nav — it has no `PanelId` and is outside the * `viewPlacement` model, unlike every panel below. * - * The former left sidebar is gone: every panel (context, work, tickets, agents, - * templates, skills, permissions, memory, git, projects) is reached from the + * The former left sidebar is gone: operational panels are reached from the * single **Panneaux** menu (#26), whose per-panel submenu picks the placement * directly — closed / floating / docked left/right / detached OS window — and * shows it in a chrome-level {@link FloatingWindow} or {@link DockRegion}. - * Project creation/switching is the **Projects** panel, opened by the tab-bar - * "+" (or `Panneaux → Projects`) and, when no project is open, shown inline in - * the welcome area (so it is always reachable). + * Project creation/switching stays available from the tab-bar "+" and project + * context is part of **Paramètres du projet**; neither is duplicated in the menu. * * Pure presentation: all behaviour comes from {@link useProjects}. Styling via * `@/shared`; no inline styles beyond z-index tokens, no `invoke()`. @@ -37,6 +35,7 @@ import { Component, useEffect, useMemo, + useRef, useState, type ErrorInfo, type ReactNode, @@ -81,8 +80,11 @@ import { useProjectActivity } from "./useProjectActivity"; import { ProjectTabs } from "./ProjectTabs"; import { ProjectActionsMenu } from "./ProjectActionsMenu"; import { RemoveProjectDialog } from "./RemoveProjectDialog"; -import { PROJECT_THEME_CHANGED_EVENT } from "./ProjectSettingsPanel"; -import { projectThemeStyle, validThemeColor } from "./projectTheme"; +import { + onProjectThemeChanged, + projectThemeStyle, + validThemeColor, +} from "./projectTheme"; import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody"; import { PANEL_TITLE, @@ -281,6 +283,7 @@ export function ProjectsView() { const [projectThemeColors, setProjectThemeColors] = useState< Record >({}); + const projectThemeRevisionRef = useRef>({}); const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; const openProjectThemeKey = vm.openTabs.map((tab) => tab.id).join("\u0000"); @@ -316,17 +319,33 @@ export function ProjectsView() { useEffect(() => { let cancelled = false; + const revisions = Object.fromEntries( + vm.openTabs.map((tab) => [tab.id, projectThemeRevisionRef.current[tab.id] ?? 0]), + ); void Promise.all( vm.openTabs.map(async (tab) => { try { const settings = await project.readProjectSettings(tab.id); return [tab.id, validThemeColor(settings.themeColor)] as const; } catch { - return [tab.id, null] as const; + return [tab.id, undefined] as const; } }), ).then((entries) => { - if (!cancelled) setProjectThemeColors(Object.fromEntries(entries)); + if (cancelled) return; + setProjectThemeColors((current) => { + const next = { ...current }; + for (const [projectId, themeColor] of entries) { + if ( + themeColor !== undefined && + (projectThemeRevisionRef.current[projectId] ?? 0) === + revisions[projectId] + ) { + next[projectId] = themeColor; + } + } + return next; + }); }); return () => { cancelled = true; @@ -334,18 +353,14 @@ export function ProjectsView() { }, [project, openProjectThemeKey]); useEffect(() => { - const update = (event: Event) => { - const detail = ( - event as CustomEvent<{ projectId: string; themeColor: string | null }> - ).detail; - if (!detail?.projectId) return; + return onProjectThemeChanged((detail) => { + projectThemeRevisionRef.current[detail.projectId] = + (projectThemeRevisionRef.current[detail.projectId] ?? 0) + 1; setProjectThemeColors((current) => ({ ...current, [detail.projectId]: validThemeColor(detail.themeColor), })); - }; - window.addEventListener(PROJECT_THEME_CHANGED_EVENT, update); - return () => window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, update); + }); }, []); const projectNameById = useMemo(() => { const names = new Map(); @@ -645,10 +660,10 @@ export function ProjectsView() { // the placement actions directly (closed / floating / docked left/right / // detached OS window). One place to pick the panel *and* its mode; the current // placement is marked in the submenu (and the parent entry shows ● when the - // panel is open somewhere). Order per UX: content panels first, Projects last. + // panel is open somewhere). Projects and the standalone context entry are + // deliberately absent: their surfaces remain reachable through dedicated UI. const panelOrder: PanelId[] = [ "projectSettings", - "context", "work", "scheduledTasks", "tickets", @@ -659,7 +674,6 @@ export function ProjectsView() { "permissions", "memory", "git", - "projects", ]; // Placement actions for one panel, marking the active slot. `projects` is diff --git a/frontend/src/features/projects/projectTheme.ts b/frontend/src/features/projects/projectTheme.ts index 4baa8735..ca1136cc 100644 --- a/frontend/src/features/projects/projectTheme.ts +++ b/frontend/src/features/projects/projectTheme.ts @@ -1,6 +1,52 @@ import type { CSSProperties } from "react"; export const DEFAULT_PROJECT_COLOR = "#4C8DFF"; +export const PROJECT_THEME_CHANGED_EVENT = "idea:project-theme-changed"; +const PROJECT_THEME_CHANNEL = "idea:project-theme"; + +export interface ProjectThemeChanged { + projectId: string; + themeColor: string | null; +} + +let projectThemeChannel: BroadcastChannel | null | undefined; + +function sharedThemeChannel(): BroadcastChannel | null { + if (projectThemeChannel !== undefined) return projectThemeChannel; + projectThemeChannel = + typeof BroadcastChannel === "undefined" + ? null + : new BroadcastChannel(PROJECT_THEME_CHANNEL); + return projectThemeChannel; +} + +/** Publishes a lightweight colour update in this window and sibling webviews. */ +export function publishProjectThemeChanged(change: ProjectThemeChanged): void { + window.dispatchEvent( + new CustomEvent(PROJECT_THEME_CHANGED_EVENT, { detail: change }), + ); + sharedThemeChannel()?.postMessage(change); +} + +/** Tracks project colour updates locally and across detached Tauri webviews. */ +export function onProjectThemeChanged( + handler: (change: ProjectThemeChanged) => void, +): () => void { + const onWindowEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.projectId) handler(detail); + }; + const onChannelMessage = (event: MessageEvent) => { + if (event.data?.projectId) handler(event.data); + }; + const channel = sharedThemeChannel(); + window.addEventListener(PROJECT_THEME_CHANGED_EVENT, onWindowEvent); + channel?.addEventListener("message", onChannelMessage); + return () => { + window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, onWindowEvent); + channel?.removeEventListener("message", onChannelMessage); + }; +} export function validThemeColor(value: string | null | undefined): string | null { return value && /^#[0-9A-Fa-f]{6}$/.test(value) ? value.toUpperCase() : null; diff --git a/frontend/src/features/projects/projects.test.tsx b/frontend/src/features/projects/projects.test.tsx index ea368da8..621ccc59 100644 --- a/frontend/src/features/projects/projects.test.tsx +++ b/frontend/src/features/projects/projects.test.tsx @@ -83,6 +83,11 @@ function tablist() { * "Agents", "Git", "Projects", …). */ function openPanel(panelTitle: string) { + if (panelTitle === "Projects") { + fireEvent.click(screen.getByRole("button", { name: "open projects panel" })); + return; + } + if (panelTitle === "Project context") panelTitle = "Paramètres du projet"; fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); fireEvent.click(screen.getByRole("button", { name: panelTitle })); fireEvent.click(screen.getByRole("button", { name: "Flottant" })); @@ -95,6 +100,45 @@ function openMenuItem(menu: string, item: string) { } describe("ProjectsView (with MockProjectGateway)", () => { + it("keeps Projects and Project context out of Panneaux", async () => { + renderView(); + await waitForIdle(); + fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); + const menu = screen.getByRole("menu", { name: "Panneaux" }); + expect(within(menu).queryByRole("button", { name: "Projects" })).toBeNull(); + expect(within(menu).queryByRole("button", { name: "Project context" })).toBeNull(); + expect(screen.getByRole("button", { name: "open projects panel" })).toBeTruthy(); + }); + + it("applies each saved project colour again when switching tabs", async () => { + const project = new MockProjectGateway(); + const alpha = await project.createProject("alpha", "/p/a"); + const beta = await project.createProject("beta", "/p/b"); + await project.updateProjectSettings(alpha.id, { themeColor: "#AA3366" }); + await project.updateProjectSettings(beta.id, { themeColor: "#22AA66" }); + renderView(project); + + await screen.findByText("/p/a"); + fireEvent.click(screen.getAllByRole("button", { name: "Open" })[0]); + const scope = await screen.findByTestId("active-project-theme-scope"); + await waitFor(() => + expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#AA3366"), + ); + + openPanel("Projects"); + const betaRow = screen + .getAllByRole("listitem") + .find((item) => within(item).queryByText("beta"))!; + fireEvent.click(within(betaRow).getByRole("button", { name: "Open" })); + await waitFor(() => + expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#22AA66"), + ); + + fireEvent.click(screen.getByRole("tab", { name: "alpha" })); + await waitFor(() => + expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#AA3366"), + ); + }); it("silently restores retained projects in their persisted order", async () => { const project = new MockProjectGateway(); const alpha = await project.createProject("alpha", "/p/a"); @@ -593,6 +637,7 @@ describe("ProjectsView (with MockProjectGateway)", () => { await screen.findByText("/p/a"); fireEvent.click(screen.getByRole("button", { name: "Open" })); + await screen.findByRole("tab", { name: "alpha" }); openPanel("Project context"); const editor = (await screen.findByLabelText( diff --git a/frontend/src/features/web/WebDesktopWorkspace.test.tsx b/frontend/src/features/web/WebDesktopWorkspace.test.tsx index 3f680310..8c029e1f 100644 --- a/frontend/src/features/web/WebDesktopWorkspace.test.tsx +++ b/frontend/src/features/web/WebDesktopWorkspace.test.tsx @@ -89,19 +89,15 @@ describe("WebWorkspace desktop layout", () => { expect(screen.queryByRole("button", { name: /detached/i })).toBeNull(); }); - it("can dock a panel to the left from the menu bar while keeping the grid in the main area", async () => { + it("omits Projects and Project context from Panneaux while keeping project access", async () => { renderPairedWebApp(await seededGateways()); fireEvent.click(await screen.findByText("Demo")); await screen.findByTestId("layout-grid"); fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); const menu = await screen.findByRole("menu", { name: "Panneaux" }); - fireEvent.click(within(menu).getByRole("button", { name: "Project context" })); - const submenu = await screen.findByRole("menu", { name: "Project context" }); - fireEvent.click(within(submenu).getByRole("button", { name: "Ancré à gauche" })); - - expect(await screen.findByLabelText("left dock")).toBeTruthy(); - expect(await screen.findByLabelText("Project context panel")).toBeTruthy(); - expect(screen.getByTestId("layout-grid")).toBeTruthy(); + expect(within(menu).queryByRole("button", { name: "Contexte projet" })).toBeNull(); + expect(within(menu).queryByRole("button", { name: "Projets" })).toBeNull(); + expect(screen.getByRole("button", { name: "Projets" })).toBeTruthy(); }); }); diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index af563650..7c1ddb8b 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -332,7 +332,6 @@ function WebDesktopWorkspace({ } const panelOrder: WebDesktopPanelId[] = [ - "context", "work", "scheduledTasks", "tickets", @@ -345,7 +344,6 @@ function WebDesktopWorkspace({ "memory", "git", "files", - "projects", ]; function placementSubmenu(panel: WebDesktopPanelId): MenuBarItem[] {