fix(frontend,application): lot utilisateur #315 — détachement Paramètres projet, couleurs projet fiables, restauration conversation custom CLI, images post-aller-retour projet, retrait menus Projects/Project Context (QA verte)

- 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
This commit is contained in:
2026-09-11 11:27:09 +02:00
parent 352ec2d8b7
commit 45f819e1e8
15 changed files with 434 additions and 82 deletions

View File

@ -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());

View File

@ -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();
});

View File

@ -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<void> {
// 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<void> {
await invoke("close_view_window", { panel });
await invoke("close_view_window", { panel: toBackendPanel(panel) });
}
async listOpenViewWindows(): Promise<ViewWindowSnapshot[]> {
// Backend serialises the snapshots in camelCase (panel/label/visible), so the
// wire shape matches the port type directly.
return invoke<ViewWindowSnapshot[]>("list_open_view_windows");
const snapshots = await invoke<ViewWindowSnapshot[]>("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;

View File

@ -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(
<DIProvider gateways={gateways}>
<ViewWindow panel="projectSettings" />
</DIProvider>,
);
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 = {

View File

@ -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<FocusedProject | null | undefined>(
undefined,
);
const [themeColors, setThemeColors] = useState<Record<string, string | null>>({});
const themeRevisionRef = useRef<Record<string, number>>({});
// 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 (
<div className="flex h-full flex-col bg-canvas text-content">
<div
className="flex h-full flex-col bg-canvas text-content"
style={projectThemeStyle(focus ? themeColors[focus.id] : null)}
>
<header className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-sm font-semibold text-content">
{PANEL_TITLE[panel]}

View File

@ -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(
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="valid-conversation" onSessionId={vi.fn()} onConversationId={vi.fn()} />
</DIProvider>,
);
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<string, string>();
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 () => {

View File

@ -89,6 +89,8 @@ interface CachedChatView {
const chatSessionViewCache = new Map<string, CachedChatView>();
const chatConversationViewCache = new Map<string, CachedChatView>();
const chatAttachmentDraftCache = new Map<string, AttachmentDraft[]>();
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<AttachmentDraft[]>([]);
const [attachments, setAttachments] = useState<AttachmentDraft[]>(() =>
initialAttachmentCacheKey
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
: [],
);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
@ -1008,6 +1035,7 @@ export function CustomAgentChatView({
const placeComposerAtEndAfterHistoryRef = useRef(false);
const attachmentsRef = useRef<AttachmentDraft[]>([]);
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() {

View File

@ -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(
<DIProvider gateways={gateways}>
<ProjectSettingsPanel projectId={project.id} />
</DIProvider>,
);
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();

View File

@ -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<string | null>(null);
const [busy, setBusy] = useState(true);
const [error, setError] = useState<string | null>(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 }) {
</Button>
</div>
{busy && <Spinner size={14} />}
{error && <p role="alert" className="text-sm text-danger">{error}</p>}
{error && (
<div role="alert" className="flex flex-wrap items-center gap-2 text-sm text-danger">
<span>{error}</span>
<Button size="sm" variant="secondary" onClick={() => void load()}>
Réessayer
</Button>
</div>
)}
</Panel>
<section aria-labelledby="project-context-settings-title">

View File

@ -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

View File

@ -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<string, string | null>
>({});
const projectThemeRevisionRef = useRef<Record<string, number>>({});
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<string, string>();
@ -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

View File

@ -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<ProjectThemeChanged>).detail;
if (detail?.projectId) handler(detail);
};
const onChannelMessage = (event: MessageEvent<ProjectThemeChanged>) => {
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;

View File

@ -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(

View File

@ -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();
});
});

View File

@ -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[] {