merge(frontend,application): lot utilisateur #315 — détachement Paramètres projet, couleurs projet, restauration conversation/images custom CLI, menus Panneaux (QA verte)
This commit is contained in:
@ -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());
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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]}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user