fix(frontend,application): lot utilisateur #315 — détachement Paramètres projet, couleurs projet fiables, restauration conversation custom CLI, images post-aller-retour projet, retrait menus Projects/Project Context (QA verte)
- détachement: le panneau Paramètres du projet devient détachable (mapping historique panel=context → projectSettings dans parseViewWindowParams, DETACHABLE/ViewPanelBody alignés, ProjectsView ne court-circuitant plus le refus); Retry d'échec de détachement préservé - couleurs projet: projectTheme publie idea:project-theme-changed localement + BroadcastChannel inter-webviews (fenêtres OS détachées), garde de révision par projet dans ProjectsView/ViewWindow contre les lectures settings en course — plus de flash/retour au bleu par défaut - conversation custom CLI: transcript_restore_expected ne considère plus l'absence de scrollback volatile comme une restauration échouée quand la session rouvre en SessionPlan::None (continuation vide valide); CustomAgentChatView n'affiche plus le faux « conversation liée n'a pas pu être restaurée » - images: conservation des images de conversation après changement de projet puis retour - menus Panneaux: retrait des entrées Projects et Project Context (panelOrder WebWorkspace, création/switch via tab-bar +, contexte projet fusionné dans Paramètres du projet) - QA verte: tests frontend ciblés window/ViewWindow/ProjectSettingsPanel/ProjectsView.detach/projects/CustomAgentChatView/WebDesktopWorkspace + cargo test -p application lifecycle transcript_restore_expected; fichiers .ideai/* (tickets 308-315, index/counter, agents/test, chat-drafts, system-permissions, project-settings.json, attachments/) et scripts/ hors périmètre préservés en worktree; aucun push
This commit is contained in:
@ -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