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.project.root,
|
||||||
input.node_id,
|
input.node_id,
|
||||||
size,
|
size,
|
||||||
launch_conversation_id.is_some(),
|
transcript_restore_expected(&session_plan),
|
||||||
&spec.env,
|
&spec.env,
|
||||||
None,
|
None,
|
||||||
structured_policy.as_ref(),
|
structured_policy.as_ref(),
|
||||||
@ -3651,6 +3651,14 @@ pub(crate) enum ReattachDecision {
|
|||||||
Refuse { node_id: NodeId },
|
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 {
|
impl ReattachDecision {
|
||||||
/// Decides reattach vs refuse for an already-live agent. See [`reattach_decision`].
|
/// Decides reattach vs refuse for an already-live agent. See [`reattach_decision`].
|
||||||
#[must_use]
|
#[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]
|
#[test]
|
||||||
fn codex_pty_first_launch_pairs_workspace_mode_with_project_and_temp_roots() {
|
fn codex_pty_first_launch_pairs_workspace_mode_with_project_and_temp_roots() {
|
||||||
let mut spec = spawn_spec(Vec::new());
|
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 () => {
|
it("opens plugin layout windows through the backend command", async () => {
|
||||||
invoke.mockResolvedValueOnce({
|
invoke.mockResolvedValueOnce({
|
||||||
label: "view-plugin-layout",
|
label: "view-plugin-layout",
|
||||||
@ -98,6 +110,11 @@ describe("TauriWindowGateway (#23)", () => {
|
|||||||
expect(handler).toHaveBeenCalledTimes(1);
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
expect(handler).toHaveBeenCalledWith({ panel: "git" });
|
expect(handler).toHaveBeenCalledWith({ panel: "git" });
|
||||||
|
|
||||||
|
raw?.({
|
||||||
|
payload: { kind: "closed", panel: "context", label: "view-context" },
|
||||||
|
});
|
||||||
|
expect(handler).toHaveBeenLastCalledWith({ panel: "projectSettings" });
|
||||||
|
|
||||||
off();
|
off();
|
||||||
expect(unlisten).toHaveBeenCalled();
|
expect(unlisten).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -25,6 +25,17 @@ import type {
|
|||||||
/** The single Tauri event carrying detached-window lifecycle transitions. */
|
/** The single Tauri event carrying detached-window lifecycle transitions. */
|
||||||
const VIEW_WINDOW_LIFECYCLE = "view-window://lifecycle";
|
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
|
* Backend lifecycle payload (discriminated on `kind`). Ticket #47 dropped the
|
||||||
* `projectId` field — a detached window is panel-only and follows the focused
|
* `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> {
|
async openViewWindow(panel: string): Promise<void> {
|
||||||
// Panel-only window (#47): no project id — the window follows the focused
|
// Panel-only window (#47): no project id — the window follows the focused
|
||||||
// project published on `focused-project://changed`.
|
// project published on `focused-project://changed`.
|
||||||
await invoke("open_view_window", { panel });
|
await invoke("open_view_window", { panel: toBackendPanel(panel) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async openPluginLayoutWindow(
|
async openPluginLayoutWindow(
|
||||||
@ -52,13 +63,17 @@ export class TauriWindowGateway implements WindowGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async closeViewWindow(panel: string): Promise<void> {
|
async closeViewWindow(panel: string): Promise<void> {
|
||||||
await invoke("close_view_window", { panel });
|
await invoke("close_view_window", { panel: toBackendPanel(panel) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async listOpenViewWindows(): Promise<ViewWindowSnapshot[]> {
|
async listOpenViewWindows(): Promise<ViewWindowSnapshot[]> {
|
||||||
// Backend serialises the snapshots in camelCase (panel/label/visible), so the
|
// Backend serialises the snapshots in camelCase (panel/label/visible), so the
|
||||||
// wire shape matches the port type directly.
|
// 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(
|
async onViewWindowClosed(
|
||||||
@ -70,7 +85,7 @@ export class TauriWindowGateway implements WindowGateway {
|
|||||||
VIEW_WINDOW_LIFECYCLE,
|
VIEW_WINDOW_LIFECYCLE,
|
||||||
(e) => {
|
(e) => {
|
||||||
if (e.payload.kind !== "closed") return;
|
if (e.payload.kind !== "closed") return;
|
||||||
handler({ panel: e.payload.panel });
|
handler({ panel: fromBackendPanel(e.payload.panel) });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return unlisten;
|
return unlisten;
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
* project itself.
|
* project itself.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
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 { DIProvider } from "@/app/di";
|
||||||
import {
|
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", () => {
|
it("accepts the detachable MCP panel", () => {
|
||||||
expect(parseViewWindowParams("?panel=mcp")).toEqual({
|
expect(parseViewWindowParams("?panel=mcp")).toEqual({
|
||||||
kind: "panel",
|
kind: "panel",
|
||||||
@ -155,6 +162,42 @@ describe("PluginLayoutWindow (#143)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ViewWindow (#47)", () => {
|
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 () => {
|
it("shows the 'open a project' shell when no project is focused", async () => {
|
||||||
const system = new MockSystemGateway();
|
const system = new MockSystemGateway();
|
||||||
const gateways = {
|
const gateways = {
|
||||||
|
|||||||
@ -19,13 +19,18 @@
|
|||||||
* and crucially no `openProject()` — opening a project is the main window's job.
|
* 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 { CustomPluginLayoutCell } from "@/domain";
|
||||||
import type { FocusedProject } from "@/ports";
|
import type { FocusedProject } from "@/ports";
|
||||||
import { Panel } from "@/shared";
|
import { Panel } from "@/shared";
|
||||||
import { PANEL_TITLE, type PanelId } from "@/features/projects";
|
import { PANEL_TITLE, type PanelId } from "@/features/projects";
|
||||||
import { ViewPanelBody, type ViewPanelId } from "@/features/projects";
|
import { ViewPanelBody, type ViewPanelId } from "@/features/projects";
|
||||||
|
import {
|
||||||
|
onProjectThemeChanged,
|
||||||
|
projectThemeStyle,
|
||||||
|
validThemeColor,
|
||||||
|
} from "@/features/projects/projectTheme";
|
||||||
import {
|
import {
|
||||||
PluginLayoutCellView,
|
PluginLayoutCellView,
|
||||||
PluginRuntimeProvider,
|
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;
|
if (!panel || !DETACHABLE.has(panel)) return null;
|
||||||
return { kind: "panel", panel: panel as ViewPanelId };
|
return { kind: "panel", panel: panel as ViewPanelId };
|
||||||
}
|
}
|
||||||
@ -108,12 +114,14 @@ export interface ViewWindowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ViewWindow({ panel }: ViewWindowProps) {
|
export function ViewWindow({ panel }: ViewWindowProps) {
|
||||||
const { focusedProject } = useGateways();
|
const { focusedProject, project } = useGateways();
|
||||||
// The focused project this window follows. `undefined` = not yet resolved
|
// The focused project this window follows. `undefined` = not yet resolved
|
||||||
// (initial read in flight); `null` = resolved, no project focused.
|
// (initial read in flight); `null` = resolved, no project focused.
|
||||||
const [focus, setFocus] = useState<FocusedProject | null | undefined>(
|
const [focus, setFocus] = useState<FocusedProject | null | undefined>(
|
||||||
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:
|
// 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).
|
// the window is a pure follower of the main window's focus (#47).
|
||||||
@ -142,8 +150,42 @@ export function ViewWindow({ panel }: ViewWindowProps) {
|
|||||||
};
|
};
|
||||||
}, [focusedProject]);
|
}, [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 (
|
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">
|
<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">
|
<h1 className="text-sm font-semibold text-content">
|
||||||
{PANEL_TITLE[panel]}
|
{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 () => {
|
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => {
|
||||||
const agent = {
|
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) => ({
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
sessionId,
|
sessionId,
|
||||||
scrollback: [],
|
scrollback: [],
|
||||||
|
transcriptUnavailableReason: "missingTranscript" as const,
|
||||||
})),
|
})),
|
||||||
sendAgentChat: vi.fn(async () => {}),
|
sendAgentChat: vi.fn(async () => {}),
|
||||||
cancelAgentChat: 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();
|
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 () => {
|
it("treats emptyConversation as a normal fresh conversation", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(async () => ({
|
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 drafts = new Map<string, string>();
|
||||||
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
|
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
|
||||||
const agent = {
|
const agent = {
|
||||||
@ -5023,7 +5050,8 @@ describe("CustomAgentChatView", () => {
|
|||||||
renderView();
|
renderView();
|
||||||
|
|
||||||
await screen.findByDisplayValue("draft with file[Image 1]");
|
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 () => {
|
it("clears the persisted draft when sending the composer", async () => {
|
||||||
|
|||||||
@ -89,6 +89,8 @@ interface CachedChatView {
|
|||||||
|
|
||||||
const chatSessionViewCache = new Map<string, CachedChatView>();
|
const chatSessionViewCache = new Map<string, CachedChatView>();
|
||||||
const chatConversationViewCache = 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 EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
|
||||||
const COMPOSER_RESIZE_STEP = 12;
|
const COMPOSER_RESIZE_STEP = 12;
|
||||||
const FALLBACK_COMPOSER_HEIGHT = 64;
|
const FALLBACK_COMPOSER_HEIGHT = 64;
|
||||||
@ -97,6 +99,7 @@ const COMPOSER_HISTORY_LIMIT = 100;
|
|||||||
export function __clearCustomAgentChatViewCacheForTests() {
|
export function __clearCustomAgentChatViewCacheForTests() {
|
||||||
chatSessionViewCache.clear();
|
chatSessionViewCache.clear();
|
||||||
chatConversationViewCache.clear();
|
chatConversationViewCache.clear();
|
||||||
|
chatAttachmentDraftCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AttachmentDraft {
|
interface AttachmentDraft {
|
||||||
@ -108,6 +111,22 @@ interface AttachmentDraft {
|
|||||||
token?: string;
|
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 {
|
interface ProgressIndexEntry {
|
||||||
firstOccurredAtMs: number;
|
firstOccurredAtMs: number;
|
||||||
sequence: number;
|
sequence: number;
|
||||||
@ -963,8 +982,16 @@ export function CustomAgentChatView({
|
|||||||
);
|
);
|
||||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||||
const [externalSessionId, setExternalSessionId] = 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 [draft, setDraft] = useState("");
|
||||||
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
|
const [attachments, setAttachments] = useState<AttachmentDraft[]>(() =>
|
||||||
|
initialAttachmentCacheKey
|
||||||
|
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
|
||||||
|
: [],
|
||||||
|
);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
|
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
|
||||||
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
||||||
@ -1008,6 +1035,7 @@ export function CustomAgentChatView({
|
|||||||
const placeComposerAtEndAfterHistoryRef = useRef(false);
|
const placeComposerAtEndAfterHistoryRef = useRef(false);
|
||||||
const attachmentsRef = useRef<AttachmentDraft[]>([]);
|
const attachmentsRef = useRef<AttachmentDraft[]>([]);
|
||||||
attachmentsRef.current = attachments;
|
attachmentsRef.current = attachments;
|
||||||
|
const attachmentCacheKeyRef = useRef(initialAttachmentCacheKey);
|
||||||
const imageTokenSeqRef = useRef(0);
|
const imageTokenSeqRef = useRef(0);
|
||||||
const mountedRef = useRef(false);
|
const mountedRef = useRef(false);
|
||||||
const openOrAttachCountRef = useRef(0);
|
const openOrAttachCountRef = useRef(0);
|
||||||
@ -1111,6 +1139,7 @@ export function CustomAgentChatView({
|
|||||||
if (explicitDraftScope) return explicitDraftScope;
|
if (explicitDraftScope) return explicitDraftScope;
|
||||||
return layoutId && nodeId ? { projectId, layoutId, nodeId } : null;
|
return layoutId && nodeId ? { projectId, layoutId, nodeId } : null;
|
||||||
}, [explicitDraftScope, layoutId, nodeId, projectId]);
|
}, [explicitDraftScope, layoutId, nodeId, projectId]);
|
||||||
|
const attachmentCacheKey = attachmentDraftCacheKey(draftScope);
|
||||||
const currentConversationCacheKey = useMemo(
|
const currentConversationCacheKey = useMemo(
|
||||||
() => conversationCacheKey(projectId, agentId, conversationId),
|
() => conversationCacheKey(projectId, agentId, conversationId),
|
||||||
[agentId, conversationId, projectId],
|
[agentId, conversationId, projectId],
|
||||||
@ -1284,6 +1313,27 @@ export function CustomAgentChatView({
|
|||||||
writeComposerHistory(composerHistoryKey, composerHistory);
|
writeComposerHistory(composerHistoryKey, composerHistory);
|
||||||
}, [composerHistory, composerHistoryKey]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
void refreshRequesterDelegation();
|
void refreshRequesterDelegation();
|
||||||
}, [refreshRequesterDelegation]);
|
}, [refreshRequesterDelegation]);
|
||||||
@ -1928,19 +1978,11 @@ export function CustomAgentChatView({
|
|||||||
!opening,
|
!opening,
|
||||||
[supported, draft, attachments.length, effectiveBusy, opening],
|
[supported, draft, attachments.length, effectiveBusy, opening],
|
||||||
);
|
);
|
||||||
const hasLinkedConversation = Boolean(
|
|
||||||
conversationId ||
|
|
||||||
transcriptUnavailableReason === "missingTranscript" ||
|
|
||||||
transcriptUnavailableReason === "conversationNotFound" ||
|
|
||||||
requesterDelegation.currentAgentBusy ||
|
|
||||||
restart ||
|
|
||||||
delegationActive,
|
|
||||||
);
|
|
||||||
const unavailableLinkedTranscript =
|
const unavailableLinkedTranscript =
|
||||||
displayTurns.length === 0 &&
|
displayTurns.length === 0 &&
|
||||||
hasLinkedConversation &&
|
(transcriptUnavailableReason === "missingTranscript" ||
|
||||||
transcriptUnavailableReason !== "emptyConversation"
|
transcriptUnavailableReason === "conversationNotFound")
|
||||||
? transcriptUnavailableReason ?? "missingTranscript"
|
? transcriptUnavailableReason
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
function unlinkUnavailableConversation() {
|
function unlinkUnavailableConversation() {
|
||||||
|
|||||||
@ -31,6 +31,30 @@ describe("ProjectSettingsPanel", () => {
|
|||||||
window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, changed);
|
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", () => {
|
it("keeps strict colours and raises dark accents to readable contrast", () => {
|
||||||
expect(validThemeColor("#12abEF")).toBe("#12ABEF");
|
expect(validThemeColor("#12abEF")).toBe("#12ABEF");
|
||||||
expect(validThemeColor("red")).toBeNull();
|
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 { useGateways } from "@/app/di";
|
||||||
import { Button, Panel, Spinner } from "@/shared";
|
import { Button, Panel, Spinner } from "@/shared";
|
||||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
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 }) {
|
export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||||
const { project } = useGateways();
|
const { project } = useGateways();
|
||||||
const [themeColor, setThemeColor] = useState<string | null>(null);
|
const [themeColor, setThemeColor] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(true);
|
const [busy, setBusy] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
void load();
|
||||||
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);
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
loadSequence.current += 1;
|
||||||
};
|
};
|
||||||
}, [project, projectId]);
|
}, [load]);
|
||||||
|
|
||||||
async function save(next: string | null) {
|
async function save(next: string | null) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -42,11 +49,7 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
|||||||
const saved = await project.updateProjectSettings(projectId, { themeColor: next });
|
const saved = await project.updateProjectSettings(projectId, { themeColor: next });
|
||||||
const normalized = validThemeColor(saved.themeColor);
|
const normalized = validThemeColor(saved.themeColor);
|
||||||
setThemeColor(normalized);
|
setThemeColor(normalized);
|
||||||
window.dispatchEvent(
|
publishProjectThemeChanged({ projectId, themeColor: normalized });
|
||||||
new CustomEvent(PROJECT_THEME_CHANGED_EVENT, {
|
|
||||||
detail: { projectId, themeColor: normalized },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
setError("Impossible d’enregistrer la couleur du projet.");
|
setError("Impossible d’enregistrer la couleur du projet.");
|
||||||
} finally {
|
} finally {
|
||||||
@ -74,7 +77,14 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{busy && <Spinner size={14} />}
|
{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>
|
</Panel>
|
||||||
|
|
||||||
<section aria-labelledby="project-context-settings-title">
|
<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();
|
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 () => {
|
it("keeps the panel in place and surfaces a retry when detaching fails", async () => {
|
||||||
const { windowGateway } = await renderWithProject();
|
const { windowGateway } = await renderWithProject();
|
||||||
const openViewWindow = vi
|
const openViewWindow = vi
|
||||||
|
|||||||
@ -16,14 +16,12 @@
|
|||||||
* with its own internal section nav — it has no `PanelId` and is outside the
|
* with its own internal section nav — it has no `PanelId` and is outside the
|
||||||
* `viewPlacement` model, unlike every panel below.
|
* `viewPlacement` model, unlike every panel below.
|
||||||
*
|
*
|
||||||
* The former left sidebar is gone: every panel (context, work, tickets, agents,
|
* The former left sidebar is gone: operational panels are reached from the
|
||||||
* templates, skills, permissions, memory, git, projects) is reached from the
|
|
||||||
* single **Panneaux** menu (#26), whose per-panel submenu picks the placement
|
* single **Panneaux** menu (#26), whose per-panel submenu picks the placement
|
||||||
* directly — closed / floating / docked left/right / detached OS window — and
|
* directly — closed / floating / docked left/right / detached OS window — and
|
||||||
* shows it in a chrome-level {@link FloatingWindow} or {@link DockRegion}.
|
* shows it in a chrome-level {@link FloatingWindow} or {@link DockRegion}.
|
||||||
* Project creation/switching is the **Projects** panel, opened by the tab-bar
|
* Project creation/switching stays available from the tab-bar "+" and project
|
||||||
* "+" (or `Panneaux → Projects`) and, when no project is open, shown inline in
|
* context is part of **Paramètres du projet**; neither is duplicated in the menu.
|
||||||
* the welcome area (so it is always reachable).
|
|
||||||
*
|
*
|
||||||
* Pure presentation: all behaviour comes from {@link useProjects}. Styling via
|
* Pure presentation: all behaviour comes from {@link useProjects}. Styling via
|
||||||
* `@/shared`; no inline styles beyond z-index tokens, no `invoke()`.
|
* `@/shared`; no inline styles beyond z-index tokens, no `invoke()`.
|
||||||
@ -37,6 +35,7 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ErrorInfo,
|
type ErrorInfo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@ -81,8 +80,11 @@ import { useProjectActivity } from "./useProjectActivity";
|
|||||||
import { ProjectTabs } from "./ProjectTabs";
|
import { ProjectTabs } from "./ProjectTabs";
|
||||||
import { ProjectActionsMenu } from "./ProjectActionsMenu";
|
import { ProjectActionsMenu } from "./ProjectActionsMenu";
|
||||||
import { RemoveProjectDialog } from "./RemoveProjectDialog";
|
import { RemoveProjectDialog } from "./RemoveProjectDialog";
|
||||||
import { PROJECT_THEME_CHANGED_EVENT } from "./ProjectSettingsPanel";
|
import {
|
||||||
import { projectThemeStyle, validThemeColor } from "./projectTheme";
|
onProjectThemeChanged,
|
||||||
|
projectThemeStyle,
|
||||||
|
validThemeColor,
|
||||||
|
} from "./projectTheme";
|
||||||
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
|
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
|
||||||
import {
|
import {
|
||||||
PANEL_TITLE,
|
PANEL_TITLE,
|
||||||
@ -281,6 +283,7 @@ export function ProjectsView() {
|
|||||||
const [projectThemeColors, setProjectThemeColors] = useState<
|
const [projectThemeColors, setProjectThemeColors] = useState<
|
||||||
Record<string, string | null>
|
Record<string, string | null>
|
||||||
>({});
|
>({});
|
||||||
|
const projectThemeRevisionRef = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||||
const openProjectThemeKey = vm.openTabs.map((tab) => tab.id).join("\u0000");
|
const openProjectThemeKey = vm.openTabs.map((tab) => tab.id).join("\u0000");
|
||||||
@ -316,17 +319,33 @@ export function ProjectsView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const revisions = Object.fromEntries(
|
||||||
|
vm.openTabs.map((tab) => [tab.id, projectThemeRevisionRef.current[tab.id] ?? 0]),
|
||||||
|
);
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
vm.openTabs.map(async (tab) => {
|
vm.openTabs.map(async (tab) => {
|
||||||
try {
|
try {
|
||||||
const settings = await project.readProjectSettings(tab.id);
|
const settings = await project.readProjectSettings(tab.id);
|
||||||
return [tab.id, validThemeColor(settings.themeColor)] as const;
|
return [tab.id, validThemeColor(settings.themeColor)] as const;
|
||||||
} catch {
|
} catch {
|
||||||
return [tab.id, null] as const;
|
return [tab.id, undefined] as const;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
).then((entries) => {
|
).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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@ -334,18 +353,14 @@ export function ProjectsView() {
|
|||||||
}, [project, openProjectThemeKey]);
|
}, [project, openProjectThemeKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = (event: Event) => {
|
return onProjectThemeChanged((detail) => {
|
||||||
const detail = (
|
projectThemeRevisionRef.current[detail.projectId] =
|
||||||
event as CustomEvent<{ projectId: string; themeColor: string | null }>
|
(projectThemeRevisionRef.current[detail.projectId] ?? 0) + 1;
|
||||||
).detail;
|
|
||||||
if (!detail?.projectId) return;
|
|
||||||
setProjectThemeColors((current) => ({
|
setProjectThemeColors((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[detail.projectId]: validThemeColor(detail.themeColor),
|
[detail.projectId]: validThemeColor(detail.themeColor),
|
||||||
}));
|
}));
|
||||||
};
|
});
|
||||||
window.addEventListener(PROJECT_THEME_CHANGED_EVENT, update);
|
|
||||||
return () => window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, update);
|
|
||||||
}, []);
|
}, []);
|
||||||
const projectNameById = useMemo(() => {
|
const projectNameById = useMemo(() => {
|
||||||
const names = new Map<string, string>();
|
const names = new Map<string, string>();
|
||||||
@ -645,10 +660,10 @@ export function ProjectsView() {
|
|||||||
// the placement actions directly (closed / floating / docked left/right /
|
// the placement actions directly (closed / floating / docked left/right /
|
||||||
// detached OS window). One place to pick the panel *and* its mode; the current
|
// 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
|
// 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[] = [
|
const panelOrder: PanelId[] = [
|
||||||
"projectSettings",
|
"projectSettings",
|
||||||
"context",
|
|
||||||
"work",
|
"work",
|
||||||
"scheduledTasks",
|
"scheduledTasks",
|
||||||
"tickets",
|
"tickets",
|
||||||
@ -659,7 +674,6 @@ export function ProjectsView() {
|
|||||||
"permissions",
|
"permissions",
|
||||||
"memory",
|
"memory",
|
||||||
"git",
|
"git",
|
||||||
"projects",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Placement actions for one panel, marking the active slot. `projects` is
|
// Placement actions for one panel, marking the active slot. `projects` is
|
||||||
|
|||||||
@ -1,6 +1,52 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
export const DEFAULT_PROJECT_COLOR = "#4C8DFF";
|
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 {
|
export function validThemeColor(value: string | null | undefined): string | null {
|
||||||
return value && /^#[0-9A-Fa-f]{6}$/.test(value) ? value.toUpperCase() : null;
|
return value && /^#[0-9A-Fa-f]{6}$/.test(value) ? value.toUpperCase() : null;
|
||||||
|
|||||||
@ -83,6 +83,11 @@ function tablist() {
|
|||||||
* "Agents", "Git", "Projects", …).
|
* "Agents", "Git", "Projects", …).
|
||||||
*/
|
*/
|
||||||
function openPanel(panelTitle: string) {
|
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: "Panneaux" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: panelTitle }));
|
fireEvent.click(screen.getByRole("button", { name: panelTitle }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Flottant" }));
|
fireEvent.click(screen.getByRole("button", { name: "Flottant" }));
|
||||||
@ -95,6 +100,45 @@ function openMenuItem(menu: string, item: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ProjectsView (with MockProjectGateway)", () => {
|
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 () => {
|
it("silently restores retained projects in their persisted order", async () => {
|
||||||
const project = new MockProjectGateway();
|
const project = new MockProjectGateway();
|
||||||
const alpha = await project.createProject("alpha", "/p/a");
|
const alpha = await project.createProject("alpha", "/p/a");
|
||||||
@ -593,6 +637,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
|
|
||||||
await screen.findByText("/p/a");
|
await screen.findByText("/p/a");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||||
|
await screen.findByRole("tab", { name: "alpha" });
|
||||||
openPanel("Project context");
|
openPanel("Project context");
|
||||||
|
|
||||||
const editor = (await screen.findByLabelText(
|
const editor = (await screen.findByLabelText(
|
||||||
|
|||||||
@ -89,19 +89,15 @@ describe("WebWorkspace desktop layout", () => {
|
|||||||
expect(screen.queryByRole("button", { name: /detached/i })).toBeNull();
|
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());
|
renderPairedWebApp(await seededGateways());
|
||||||
fireEvent.click(await screen.findByText("Demo"));
|
fireEvent.click(await screen.findByText("Demo"));
|
||||||
await screen.findByTestId("layout-grid");
|
await screen.findByTestId("layout-grid");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||||
const menu = await screen.findByRole("menu", { name: "Panneaux" });
|
const menu = await screen.findByRole("menu", { name: "Panneaux" });
|
||||||
fireEvent.click(within(menu).getByRole("button", { name: "Project context" }));
|
expect(within(menu).queryByRole("button", { name: "Contexte projet" })).toBeNull();
|
||||||
const submenu = await screen.findByRole("menu", { name: "Project context" });
|
expect(within(menu).queryByRole("button", { name: "Projets" })).toBeNull();
|
||||||
fireEvent.click(within(submenu).getByRole("button", { name: "Ancré à gauche" }));
|
expect(screen.getByRole("button", { name: "Projets" })).toBeTruthy();
|
||||||
|
|
||||||
expect(await screen.findByLabelText("left dock")).toBeTruthy();
|
|
||||||
expect(await screen.findByLabelText("Project context panel")).toBeTruthy();
|
|
||||||
expect(screen.getByTestId("layout-grid")).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -332,7 +332,6 @@ function WebDesktopWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const panelOrder: WebDesktopPanelId[] = [
|
const panelOrder: WebDesktopPanelId[] = [
|
||||||
"context",
|
|
||||||
"work",
|
"work",
|
||||||
"scheduledTasks",
|
"scheduledTasks",
|
||||||
"tickets",
|
"tickets",
|
||||||
@ -345,7 +344,6 @@ function WebDesktopWorkspace({
|
|||||||
"memory",
|
"memory",
|
||||||
"git",
|
"git",
|
||||||
"files",
|
"files",
|
||||||
"projects",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function placementSubmenu(panel: WebDesktopPanelId): MenuBarItem[] {
|
function placementSubmenu(panel: WebDesktopPanelId): MenuBarItem[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user