merge(web): CLI custom indisponible via l'interface web — #229 (QA verte)

This commit is contained in:
2026-08-20 15:10:59 +02:00
11 changed files with 296 additions and 36 deletions

View File

@ -0,0 +1,17 @@
---
issueRef: "#228"
version: 4
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1787229737762
---
## 2026-08-20
- Rebuild AppImage demandé par l'utilisateur.
- Ticket créé et passé en `inProgress`.
- Décision Git préparée avant tagging : branche `develop`, HEAD `1b8534f`, tag à créer après build vert `v0.18.0`.
- Frontend build exécuté avec succès.
- Bundle Tauri AppImage exécuté avec succès via `CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1`.
- Artefact produit : `/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`.
- Vérification artefact : taille `118745592` bytes, horodatage `2026-08-20 14:41:34 +0200`.
- Git a créé localement le tag annoté `v0.18.0` sur le commit `1b8534f` puis l'a vérifié avec `git tag --contains` et `git describe --tags`.
- Aucun push effectué.
- Hors périmètre volontaire : remplacement manuel de l'AppImage installée et relance d'IdeA.

View File

@ -0,0 +1,17 @@
---
id: "ce5b03a6-deaa-4b4d-9eff-49bff8e6a79c"
number: 228
title: "Rebuild AppImage Linux"
status: "closed"
priority: "medium"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1787229516097
updatedAt: 1787229737762
version: 4
---
Reconstruction de l'AppImage IdeA depuis le project root, puis tag de version du commit construit selon la règle Main (vx sur main, vy sur develop, vz sur autres branches).

View File

@ -0,0 +1,13 @@
---
issueRef: "#229"
version: 4
updatedBy: {"kind":"agent","agent_id":"1ff94b51-3e17-4a39-8543-7715d1ff0f80"}
updatedAt: 1787231430402
---
2026-08-20
- Symptôme utilisateur: via l'interface web, le profil CLI custom affiche "Le CLI custom n'a pas pu démarrer. Vérifiez la configuration du profil ou utilisez la TUI native".
- Ticket créé car aucun ticket ouvert ne correspondait.
- UX: ne pas masquer l'erreur réelle sur web/mobile; afficher un échec d'ouverture avec détail exploitable, action Réessayer, fallback TUI secondaire.
- Architect: root cause probable = contrat cassé côté web-server. Le frontend web lance `cellKind: chat`, mais le web-server rejette encore les sessions structurées avec `UNSUPPORTED`; le message générique frontend masque la vraie cause.
- Git: branche locale décidée `fix/229-web-custom-cli-startup`, basée sur `develop` (tip `1b8534f`), créée sans checkout.
- À implémenter: backend web-server pour honorer `agent.launch` structuré sur web + frontend pour exposer le diagnostic utile au lieu du message générique.

View File

@ -0,0 +1,17 @@
---
id: "a993f30b-b128-4b26-94ba-6301279c1bd4"
number: 229
title: "CLI custom indisponible via l'interface web"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"1ff94b51-3e17-4a39-8543-7715d1ff0f80"}
createdAt: 1787230509856
updatedAt: 1787231430402
version: 4
---
Le profil CLI custom ne demarre pas depuis l'interface web et affiche: "le cli custom n'a pas pu demarrer. Verifiez la configuration du profil ou utilisez la TUI native". A diagnostiquer et corriger selon le cycle UX/Architect/Dev/QA.

View File

@ -1,3 +1,3 @@
{ {
"nextNumber": 228 "nextNumber": 230
} }

View File

@ -3056,6 +3056,34 @@
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
}, },
"updatedAt": 1787227664912 "updatedAt": 1787227664912
},
{
"issueRef": "#228",
"path": "228",
"title": "Rebuild AppImage Linux",
"status": "closed",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1787229737762
},
{
"issueRef": "#229",
"path": "229",
"title": "CLI custom indisponible via l'interface web",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1787231430402
} }
] ]
} }

View File

@ -1559,11 +1559,19 @@ async fn send_launch_agent_attached(
owned: &Arc<Mutex<Vec<(SessionId, u64)>>>, owned: &Arc<Mutex<Vec<(SessionId, u64)>>>,
output: application::LaunchAgentOutput, output: application::LaunchAgentOutput,
) -> Result<(), ErrorDto> { ) -> Result<(), ErrorDto> {
if output.structured.is_some() { if let Some(structured) = output.structured.as_ref() {
return Err(ErrorDto { tx.send(ServerFrame::chat_attached(
code: "UNSUPPORTED".to_owned(), &frame.id,
message: "structured agent sessions do not stream over the PTY websocket".to_owned(), structured,
}); output.assigned_conversation_id.as_deref(),
output.engine_session_id.as_deref(),
))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
return Ok(());
} }
let session_id = output.session.id; let session_id = output.session.id;
let dto = TerminalSessionDto::from(output); let dto = TerminalSessionDto::from(output);
@ -5430,6 +5438,28 @@ impl ServerFrame {
} }
} }
fn chat_attached(
request_id: &str,
session: &application::StructuredSessionDescriptor,
assigned_conversation_id: Option<&str>,
engine_session_id: Option<&str>,
) -> Self {
Self {
kind: "chat.attached".to_owned(),
reply_to: Some(request_id.to_owned()),
payload: json!({
"sessionId": session.session_id.to_string(),
"session": {
"sessionId": session.session_id.to_string(),
"nodeId": session.node_id.to_string(),
},
"cellKind": "chat",
"assignedConversationId": assigned_conversation_id,
"engineSessionId": engine_session_id,
}),
}
}
fn output(session_id: SessionId, seq: u64, bytes: Vec<u8>) -> Self { fn output(session_id: SessionId, seq: u64, bytes: Vec<u8>) -> Self {
Self { Self {
kind: "terminal.output".to_owned(), kind: "terminal.output".to_owned(),
@ -7043,7 +7073,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn websocket_launch_agent_structured_is_unsupported() { async fn websocket_launch_agent_structured_emits_chat_attached() {
let state = state(); let state = state();
let (tx, mut rx) = mpsc::channel(4); let (tx, mut rx) = mpsc::channel(4);
let owned = Arc::new(Mutex::new(Vec::new())); let owned = Arc::new(Mutex::new(Vec::new()));
@ -7073,25 +7103,25 @@ mod tests {
profile: None, profile: None,
}; };
if let Err(err) = send_launch_agent_attached(&frame, &state, &tx, &owned, output).await { send_launch_agent_attached(&frame, &state, &tx, &owned, output)
tx.send(ServerFrame::error(
Some(frame.id),
payload_session_id(&frame.payload),
err.code,
err.message,
))
.await .await
.unwrap(); .unwrap();
} let attached = recv_server_frame(&mut rx).await;
let error = recv_server_frame(&mut rx).await;
assert_eq!(error.kind, "error"); assert_eq!(attached.kind, "chat.attached");
assert_eq!(error.reply_to.as_deref(), Some("agent-structured")); assert_eq!(attached.reply_to.as_deref(), Some("agent-structured"));
assert_eq!(error.payload["code"], "UNSUPPORTED"); assert_eq!(attached.payload["sessionId"], session_id.to_string());
assert_eq!( assert_eq!(
error.payload["message"], attached.payload["session"]["sessionId"],
"structured agent sessions do not stream over the PTY websocket" session_id.to_string()
); );
assert_eq!(attached.payload["session"]["nodeId"], node_id.to_string());
assert_eq!(attached.payload["cellKind"], "chat");
assert_eq!(
attached.payload["assignedConversationId"],
"pair-conversation"
);
assert_eq!(attached.payload["engineSessionId"], "engine-session");
assert_eq!(state.ws_pty_bridge.active_sessions(), 0); assert_eq!(state.ws_pty_bridge.active_sessions(), 0);
} }

View File

@ -89,6 +89,98 @@ describe("CustomAgentChatView", () => {
__resetProjectCliInteractionActivityForTests(); __resetProjectCliInteractionActivityForTests();
}); });
it("shows the technical open failure on mobile with retry and native TUI fallback", async () => {
const openNativeTui = vi.fn();
const agent = {
launchAgentChat: vi
.fn()
.mockRejectedValueOnce(new Error("spawn codex ENOENT"))
.mockResolvedValueOnce({ sessionId: "chat-session-1" }),
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={null}
mobile
onOpenNativeTui={openNativeTui}
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
const alert = await screen.findByRole("alert");
expect(within(alert).getByText("Le CLI custom n'a pas pu démarrer.")).toBeTruthy();
expect(within(alert).getByText("Cause: spawn codex ENOENT")).toBeTruthy();
expect(screen.queryByText(/Vérifiez la configuration du profil/)).toBeNull();
fireEvent.click(within(alert).getByRole("button", { name: "Ouvrir en TUI native" }));
expect(openNativeTui).toHaveBeenCalledTimes(1);
fireEvent.click(within(alert).getByRole("button", { name: "Réessayer" }));
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
it("uses the same technical open failure on desktop", async () => {
const agent = {
launchAgentChat: vi.fn(async () => {
throw new Error("permission denied: /opt/codex");
}),
reattachAgentChat: vi.fn(),
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={null}
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
const alert = await screen.findByRole("alert");
expect(within(alert).getByText("Cause: permission denied: /opt/codex")).toBeTruthy();
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Ouvrir en TUI native" })).toBeNull();
});
it("repaints attachment labels from user prompt scrollback", async () => { it("repaints attachment labels from user prompt scrollback", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(), launchAgentChat: vi.fn(),

View File

@ -52,6 +52,7 @@ export interface CustomAgentChatViewProps {
onComposerHeightPxChange?: (heightPx: number) => void; onComposerHeightPxChange?: (heightPx: number) => void;
draftScope?: ChatDraftScope | null; draftScope?: ChatDraftScope | null;
mobile?: boolean; mobile?: boolean;
onOpenNativeTui?: () => void;
onSessionId: (sessionId: string | null) => void; onSessionId: (sessionId: string | null) => void;
onConversationId: (conversationId: string | null) => void; onConversationId: (conversationId: string | null) => void;
} }
@ -118,6 +119,11 @@ interface SlashCommandInvocation {
arguments: unknown[]; arguments: unknown[];
} }
interface ChatErrorState {
message: string;
openFailure: boolean;
}
function describe(e: unknown): string { function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) { if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message); return String((e as GatewayError).message);
@ -125,9 +131,9 @@ function describe(e: unknown): string {
return String(e); return String(e);
} }
function describeOpenFailure(e: unknown, mobile: boolean): string { function describeOpenFailure(e: unknown): string {
if (!mobile) return describe(e); const message = describe(e).trim();
return "Le CLI custom n'a pas pu démarrer. Vérifiez la configuration du profil ou utilisez la TUI native."; return message || "Cause technique non disponible.";
} }
function instrumentationError(e: unknown): Record<string, unknown> { function instrumentationError(e: unknown): Record<string, unknown> {
@ -734,6 +740,7 @@ export function CustomAgentChatView({
onComposerHeightPxChange, onComposerHeightPxChange,
draftScope: explicitDraftScope, draftScope: explicitDraftScope,
mobile = false, mobile = false,
onOpenNativeTui,
onSessionId, onSessionId,
onConversationId, onConversationId,
}: CustomAgentChatViewProps) { }: CustomAgentChatViewProps) {
@ -770,9 +777,10 @@ export function CustomAgentChatView({
const [profileDialog, setProfileDialog] = const [profileDialog, setProfileDialog] =
useState<ProfileCommandDialogState | null>(null); useState<ProfileCommandDialogState | null>(null);
const [opening, setOpening] = useState(Boolean(sessionId || conversationId)); const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
const [openRetryNonce, setOpenRetryNonce] = useState(0);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [stopping, setStopping] = useState(false); const [stopping, setStopping] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<ChatErrorState | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null); const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(null); const chatRootRef = useRef<HTMLDivElement | null>(null);
@ -1371,7 +1379,9 @@ export function CustomAgentChatView({
sessionId: externalSessionId, sessionId: externalSessionId,
error: instrumentationError(e), error: instrumentationError(e),
}); });
if (!cancelled) setError(describeOpenFailure(e, mobile)); if (!cancelled) {
setError({ message: describeOpenFailure(e), openFailure: true });
}
} finally { } finally {
if (!cancelled) setOpening(false); if (!cancelled) setOpening(false);
} }
@ -1390,7 +1400,7 @@ export function CustomAgentChatView({
applyChatViewState, applyChatViewState,
currentConversationCacheKey, currentConversationCacheKey,
conversationId, conversationId,
mobile, openRetryNonce,
]); ]);
const canSend = useMemo( const canSend = useMemo(
@ -1490,7 +1500,10 @@ export function CustomAgentChatView({
async function executeSlashCommand(invocation: SlashCommandInvocation) { async function executeSlashCommand(invocation: SlashCommandInvocation) {
if (!agent.executeSlashCommand) { if (!agent.executeSlashCommand) {
setError(`Commande ${invocation.name} indisponible dans ce runtime.`); setError({
message: `Commande ${invocation.name} indisponible dans ce runtime.`,
openFailure: false,
});
return; return;
} }
setDraft(""); setDraft("");
@ -1546,7 +1559,7 @@ export function CustomAgentChatView({
} }
} catch (e) { } catch (e) {
const message = `Commande ${invocation.name} échouée: ${describe(e)}`; const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
setError(message); setError({ message, openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }], turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }],
progressIndex: prev.progressIndex, progressIndex: prev.progressIndex,
@ -1633,7 +1646,7 @@ export function CustomAgentChatView({
); );
} }
} catch (e) { } catch (e) {
setError(describe(e)); setError({ message: describe(e), openFailure: false });
} }
} }
@ -1704,7 +1717,7 @@ export function CustomAgentChatView({
} catch (recoveryError) { } catch (recoveryError) {
finishInteraction(); finishInteraction();
setBusy(false); setBusy(false);
setError(describe(recoveryError)); setError({ message: describe(recoveryError), openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [ turns: [
...prev.turns, ...prev.turns,
@ -1717,7 +1730,7 @@ export function CustomAgentChatView({
} }
finishInteraction(); finishInteraction();
setBusy(false); setBusy(false);
setError(describe(e)); setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }], turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }],
progressIndex: prev.progressIndex, progressIndex: prev.progressIndex,
@ -1779,7 +1792,7 @@ export function CustomAgentChatView({
}); });
return; return;
} }
setError(describe(e)); setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [ turns: [
...prev.turns, ...prev.turns,
@ -1821,9 +1834,40 @@ export function CustomAgentChatView({
</p> </p>
)} )}
{error && ( {error && (
<p role="alert" className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger"> <div
{error} role="alert"
</p> className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger"
>
{error.openFailure ? (
<div className="flex min-w-0 flex-col gap-2">
<div className="font-medium">Le CLI custom n'a pas pu démarrer.</div>
<div className="break-words">
Cause: {error.message}
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="primary"
disabled={opening}
onClick={() => setOpenRetryNonce((value) => value + 1)}
>
Réessayer
</Button>
{onOpenNativeTui && (
<Button
size="sm"
variant="secondary"
onClick={onOpenNativeTui}
>
Ouvrir en TUI native
</Button>
)}
</div>
</div>
) : (
error.message
)}
</div>
)} )}
{profileDialog && ( {profileDialog && (
<div <div

View File

@ -1501,6 +1501,7 @@ function LeafView({
composerHeightPx={customChatComposerHeight} composerHeightPx={customChatComposerHeight}
composerDefaultHeightPx={CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT} composerDefaultHeightPx={CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT}
onComposerHeightPxChange={setCustomChatComposerHeight} onComposerHeightPxChange={setCustomChatComposerHeight}
onOpenNativeTui={() => requestMode("tui")}
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)} onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
onConversationId={(cid) => void vm.setCellConversation(id, cid)} onConversationId={(cid) => void vm.setCellConversation(id, cid)}
/> />

View File

@ -208,6 +208,7 @@ export function WebAgentCell({
conversationId={customConversationId} conversationId={customConversationId}
draftScope={draftScope} draftScope={draftScope}
mobile mobile
onOpenNativeTui={() => setMode("tui")}
onSessionId={setCustomSessionId} onSessionId={setCustomSessionId}
onConversationId={setCustomConversationId} onConversationId={setCustomConversationId}
/> />