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() {
|
||||
|
||||
Reference in New Issue
Block a user