feat(cli): commande /profile + sélection de profil avec reset de session confirmé — #164 (QA verte)
Ajoute /profile comme commande slash first-class dont la sémantique de reset
de session reste explicite et testable, séparée de la palette générique :
- domain + application: /profile est enregistrée et renvoie un effet
SlashCommandEffect::ProfileSwitch { session_id }. La commande ne reset pas
directement : elle délègue l'effet au frontend pour imposer une confirmation.
- frontend (CustomAgentChatView): à l'exécution de /profile, ouverture d'un
popup de sélection de profil informant que la validation reset la session,
avec confirmation explicite (confirmedReset) requise avant changeAgentProfile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -58,6 +58,14 @@ interface AttachmentDraft {
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
interface ProfileCommandDialogState {
|
||||
profiles: AgentProfile[];
|
||||
selectedProfileId: string;
|
||||
confirmedReset: boolean;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
@ -319,7 +327,7 @@ export function CustomAgentChatView({
|
||||
onSessionId,
|
||||
onConversationId,
|
||||
}: CustomAgentChatViewProps) {
|
||||
const { agent, system } = useGateways();
|
||||
const { agent, profile: profileGateway, system } = useGateways();
|
||||
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
||||
@ -328,6 +336,8 @@ export function CustomAgentChatView({
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
|
||||
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
||||
const [profileDialog, setProfileDialog] =
|
||||
useState<ProfileCommandDialogState | null>(null);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -448,7 +458,13 @@ export function CustomAgentChatView({
|
||||
);
|
||||
|
||||
const recoverStructuredSession = useCallback(
|
||||
async (options: { applyScrollback?: boolean; retryAttachNotFound?: boolean } = {}) => {
|
||||
async (
|
||||
options: {
|
||||
applyScrollback?: boolean;
|
||||
retryAttachNotFound?: boolean;
|
||||
conversationId?: string | null;
|
||||
} = {},
|
||||
) => {
|
||||
if (!agent.launchAgentChat) throw new Error("Structured launch unavailable");
|
||||
console.debug("[ticket149] recoverStructuredSession:start", {
|
||||
timestamp: new Date().toISOString(),
|
||||
@ -462,7 +478,10 @@ export function CustomAgentChatView({
|
||||
cwd,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
conversationId: conversationId ?? undefined,
|
||||
conversationId:
|
||||
options.conversationId === undefined
|
||||
? conversationId ?? undefined
|
||||
: options.conversationId ?? undefined,
|
||||
nodeId,
|
||||
};
|
||||
console.debug("[ticket149] recoverStructuredSession:launchAgentChat:start", {
|
||||
@ -641,6 +660,102 @@ export function CustomAgentChatView({
|
||||
[supported, draft, attachments.length, busy, opening],
|
||||
);
|
||||
|
||||
async function openProfileCommandFlow() {
|
||||
if (!agent.executeSlashCommand) {
|
||||
setError("Commande /profile indisponible dans ce runtime.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const sid =
|
||||
currentSession ??
|
||||
(await recoverStructuredSession({
|
||||
applyScrollback: false,
|
||||
retryAttachNotFound: true,
|
||||
}));
|
||||
const result = await agent.executeSlashCommand("/profile", { sessionId: sid });
|
||||
if (result.effect.kind !== "profileSwitch") {
|
||||
throw new Error("La commande /profile n'a pas renvoyé le flow profil.");
|
||||
}
|
||||
const availableProfiles = (await profileGateway.listProfiles()).filter(
|
||||
(candidate) => Boolean(candidate.structuredAdapter),
|
||||
);
|
||||
if (availableProfiles.length === 0) {
|
||||
throw new Error("Aucun profil compatible avec la CLI custom n'est configuré.");
|
||||
}
|
||||
setDraft("");
|
||||
setProfileDialog({
|
||||
profiles: availableProfiles,
|
||||
selectedProfileId:
|
||||
availableProfiles.find((candidate) => candidate.id !== profile.id)?.id ??
|
||||
availableProfiles[0].id,
|
||||
confirmedReset: false,
|
||||
busy: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmProfileCommandChange() {
|
||||
if (!profileDialog) return;
|
||||
if (!agent.changeAgentProfile) {
|
||||
setProfileDialog((prev) =>
|
||||
prev ? { ...prev, error: "Changement de profil indisponible." } : prev,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const selected = profileDialog.profiles.find(
|
||||
(candidate) => candidate.id === profileDialog.selectedProfileId,
|
||||
);
|
||||
if (!selected) return;
|
||||
|
||||
setProfileDialog((prev) => (prev ? { ...prev, busy: true, error: null } : prev));
|
||||
try {
|
||||
const { relaunchedSession } = await agent.changeAgentProfile(
|
||||
projectId,
|
||||
agentId,
|
||||
selected.id,
|
||||
24,
|
||||
80,
|
||||
);
|
||||
setTurns([]);
|
||||
setCurrentSession(null);
|
||||
publishSessionId(null);
|
||||
onConversationIdRef.current(null);
|
||||
|
||||
if (relaunchedSession?.sessionId) {
|
||||
try {
|
||||
setCurrentSession(relaunchedSession.sessionId);
|
||||
publishSessionId(relaunchedSession.sessionId);
|
||||
await reattachStructuredSession(relaunchedSession.sessionId, {
|
||||
applyScrollback: false,
|
||||
});
|
||||
} catch {
|
||||
await recoverStructuredSession({
|
||||
applyScrollback: false,
|
||||
retryAttachNotFound: true,
|
||||
conversationId: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await recoverStructuredSession({
|
||||
applyScrollback: false,
|
||||
retryAttachNotFound: true,
|
||||
conversationId: null,
|
||||
});
|
||||
}
|
||||
|
||||
setTurns([{ role: "tool", label: `Profil changé vers ${selected.name}.` }]);
|
||||
setProfileDialog(null);
|
||||
} catch (e) {
|
||||
setProfileDialog((prev) =>
|
||||
prev ? { ...prev, busy: false, error: describe(e) } : prev,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickAttachment() {
|
||||
const path = await system.pickFile();
|
||||
if (path) {
|
||||
@ -685,6 +800,10 @@ export function CustomAgentChatView({
|
||||
async function send() {
|
||||
const text = draft.trim();
|
||||
if (!canSend || !agent.sendAgentChat) return;
|
||||
if (text === "/profile") {
|
||||
await openProfileCommandFlow();
|
||||
return;
|
||||
}
|
||||
const outgoingAttachments = attachments;
|
||||
const attachmentInputs = outgoingAttachments.map((item) => item.input);
|
||||
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
||||
@ -797,6 +916,101 @@ export function CustomAgentChatView({
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{profileDialog && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`profile-command-title-${nodeId}`}
|
||||
className="m-3 rounded-md border border-border bg-surface p-3 shadow-lg"
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
id={`profile-command-title-${nodeId}`}
|
||||
className="text-sm font-semibold text-content"
|
||||
>
|
||||
Changer de profil
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Valider ce changement va réinitialiser la session courante. L'historique de cette CLI custom sera perdu.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={profileDialog.busy}
|
||||
onClick={() => setProfileDialog(null)}
|
||||
>
|
||||
Fermer
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 flex-col gap-2">
|
||||
<label className="flex min-w-0 flex-col gap-1 text-xs text-muted">
|
||||
Profil
|
||||
<select
|
||||
aria-label="profil cible"
|
||||
value={profileDialog.selectedProfileId}
|
||||
disabled={profileDialog.busy}
|
||||
className="rounded-md border border-border bg-raised px-2 py-1 text-sm text-content"
|
||||
onChange={(event) =>
|
||||
setProfileDialog((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
selectedProfileId: event.target.value,
|
||||
confirmedReset: false,
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
>
|
||||
{profileDialog.profiles.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{candidate.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-xs text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={profileDialog.confirmedReset}
|
||||
disabled={profileDialog.busy}
|
||||
onChange={(event) =>
|
||||
setProfileDialog((prev) =>
|
||||
prev ? { ...prev, confirmedReset: event.target.checked } : prev,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>Je confirme que la session courante sera reset.</span>
|
||||
</label>
|
||||
{profileDialog.error && (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{profileDialog.error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={profileDialog.busy}
|
||||
onClick={() => setProfileDialog(null)}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={!profileDialog.confirmedReset || profileDialog.busy}
|
||||
loading={profileDialog.busy}
|
||||
onClick={() => void confirmProfileCommandChange()}
|
||||
>
|
||||
Reset et changer le profil
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
|
||||
Reference in New Issue
Block a user