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

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

View File

@ -199,10 +199,14 @@ describe("CustomAgentChatView", () => {
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({ sessionId: "fresh-session" })),
launchAgentChat: vi.fn(async () => ({
sessionId: "fresh-session",
transcriptUnavailableReason: "missingTranscript" as const,
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
transcriptUnavailableReason: "missingTranscript" as const,
})),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
@ -253,6 +257,29 @@ describe("CustomAgentChatView", () => {
expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy();
});
it("keeps an empty linked custom CLI continuation usable without explicit transcript loss", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({ sessionId: "valid-session" })),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="valid-conversation" onSessionId={vi.fn()} onConversationId={vi.fn()} />
</DIProvider>,
);
expect(
await screen.findByText("Envoyez un message pour démarrer la conversation structurée."),
).toBeTruthy();
expect(screen.queryByText("La conversation liée n’a pas pu être restaurée.")).toBeNull();
});
it("treats emptyConversation as a normal fresh conversation", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
@ -4944,7 +4971,7 @@ describe("CustomAgentChatView", () => {
);
});
it("does not persist attachments with the restored draft", async () => {
it("keeps attachment drafts in memory across project unmount and return", async () => {
const drafts = new Map<string, string>();
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
const agent = {
@ -5023,7 +5050,8 @@ describe("CustomAgentChatView", () => {
renderView();
await screen.findByDisplayValue("draft with file[Image 1]");
expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull();
expect(screen.getByText("Fichier joint: not-persisted.png")).toBeTruthy();
expect(screen.getByTestId("attachment-preview-not-persisted.png")).toBeTruthy();
});
it("clears the persisted draft when sending the composer", async () => {

View File

@ -89,6 +89,8 @@ interface CachedChatView {
const chatSessionViewCache = new Map<string, CachedChatView>();
const chatConversationViewCache = new Map<string, CachedChatView>();
const chatAttachmentDraftCache = new Map<string, AttachmentDraft[]>();
const CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT = 24;
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
const COMPOSER_RESIZE_STEP = 12;
const FALLBACK_COMPOSER_HEIGHT = 64;
@ -97,6 +99,7 @@ const COMPOSER_HISTORY_LIMIT = 100;
export function __clearCustomAgentChatViewCacheForTests() {
chatSessionViewCache.clear();
chatConversationViewCache.clear();
chatAttachmentDraftCache.clear();
}
interface AttachmentDraft {
@ -108,6 +111,22 @@ interface AttachmentDraft {
token?: string;
}
function attachmentDraftCacheKey(scope: ChatDraftScope | null): string | null {
return scope
? `${scope.projectId}\u0000${scope.layoutId}\u0000${scope.nodeId}`
: null;
}
function cacheAttachmentDraft(key: string, attachments: AttachmentDraft[]): void {
chatAttachmentDraftCache.delete(key);
if (attachments.length > 0) chatAttachmentDraftCache.set(key, attachments);
while (chatAttachmentDraftCache.size > CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT) {
const oldest = chatAttachmentDraftCache.keys().next().value as string | undefined;
if (!oldest) break;
chatAttachmentDraftCache.delete(oldest);
}
}
interface ProgressIndexEntry {
firstOccurredAtMs: number;
sequence: number;
@ -963,8 +982,16 @@ export function CustomAgentChatView({
);
const [currentSession, setCurrentSession] = useState(sessionId);
const [externalSessionId, setExternalSessionId] = useState(sessionId);
const initialDraftScope = explicitDraftScope ?? (
layoutId && nodeId ? { projectId, layoutId, nodeId } : null
);
const initialAttachmentCacheKey = attachmentDraftCacheKey(initialDraftScope);
const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
const [attachments, setAttachments] = useState<AttachmentDraft[]>(() =>
initialAttachmentCacheKey
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
: [],
);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
@ -1008,6 +1035,7 @@ export function CustomAgentChatView({
const placeComposerAtEndAfterHistoryRef = useRef(false);
const attachmentsRef = useRef<AttachmentDraft[]>([]);
attachmentsRef.current = attachments;
const attachmentCacheKeyRef = useRef(initialAttachmentCacheKey);
const imageTokenSeqRef = useRef(0);
const mountedRef = useRef(false);
const openOrAttachCountRef = useRef(0);
@ -1111,6 +1139,7 @@ export function CustomAgentChatView({
if (explicitDraftScope) return explicitDraftScope;
return layoutId && nodeId ? { projectId, layoutId, nodeId } : null;
}, [explicitDraftScope, layoutId, nodeId, projectId]);
const attachmentCacheKey = attachmentDraftCacheKey(draftScope);
const currentConversationCacheKey = useMemo(
() => conversationCacheKey(projectId, agentId, conversationId),
[agentId, conversationId, projectId],
@ -1284,6 +1313,27 @@ export function CustomAgentChatView({
writeComposerHistory(composerHistoryKey, composerHistory);
}, [composerHistory, composerHistoryKey]);
// Attachments can be large, so keep draft bytes only in this renderer's
// bounded lifetime instead of serialising base64 into localStorage. The
// project/layout/node scope mirrors the persisted text draft contract.
useEffect(() => {
const key = attachmentCacheKeyRef.current;
if (key) cacheAttachmentDraft(key, attachments);
}, [attachments]);
useEffect(() => {
const previousKey = attachmentCacheKeyRef.current;
if (previousKey) {
cacheAttachmentDraft(previousKey, attachmentsRef.current);
}
attachmentCacheKeyRef.current = attachmentCacheKey;
setAttachments(
attachmentCacheKey
? (chatAttachmentDraftCache.get(attachmentCacheKey) ?? [])
: [],
);
}, [attachmentCacheKey]);
useEffect(() => {
void refreshRequesterDelegation();
}, [refreshRequesterDelegation]);
@ -1928,19 +1978,11 @@ export function CustomAgentChatView({
!opening,
[supported, draft, attachments.length, effectiveBusy, opening],
);
const hasLinkedConversation = Boolean(
conversationId ||
transcriptUnavailableReason === "missingTranscript" ||
transcriptUnavailableReason === "conversationNotFound" ||
requesterDelegation.currentAgentBusy ||
restart ||
delegationActive,
);
const unavailableLinkedTranscript =
displayTurns.length === 0 &&
hasLinkedConversation &&
transcriptUnavailableReason !== "emptyConversation"
? transcriptUnavailableReason ?? "missingTranscript"
(transcriptUnavailableReason === "missingTranscript" ||
transcriptUnavailableReason === "conversationNotFound")
? transcriptUnavailableReason
: null;
function unlinkUnavailableConversation() {