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"
},
"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)>>>,
output: application::LaunchAgentOutput,
) -> Result<(), ErrorDto> {
if output.structured.is_some() {
return Err(ErrorDto {
code: "UNSUPPORTED".to_owned(),
message: "structured agent sessions do not stream over the PTY websocket".to_owned(),
});
if let Some(structured) = output.structured.as_ref() {
tx.send(ServerFrame::chat_attached(
&frame.id,
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 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 {
Self {
kind: "terminal.output".to_owned(),
@ -7043,7 +7073,7 @@ mod tests {
}
#[tokio::test]
async fn websocket_launch_agent_structured_is_unsupported() {
async fn websocket_launch_agent_structured_emits_chat_attached() {
let state = state();
let (tx, mut rx) = mpsc::channel(4);
let owned = Arc::new(Mutex::new(Vec::new()));
@ -7073,25 +7103,25 @@ mod tests {
profile: None,
};
if let Err(err) = send_launch_agent_attached(&frame, &state, &tx, &owned, output).await {
tx.send(ServerFrame::error(
Some(frame.id),
payload_session_id(&frame.payload),
err.code,
err.message,
))
send_launch_agent_attached(&frame, &state, &tx, &owned, output)
.await
.unwrap();
}
let error = recv_server_frame(&mut rx).await;
let attached = recv_server_frame(&mut rx).await;
assert_eq!(error.kind, "error");
assert_eq!(error.reply_to.as_deref(), Some("agent-structured"));
assert_eq!(error.payload["code"], "UNSUPPORTED");
assert_eq!(attached.kind, "chat.attached");
assert_eq!(attached.reply_to.as_deref(), Some("agent-structured"));
assert_eq!(attached.payload["sessionId"], session_id.to_string());
assert_eq!(
error.payload["message"],
"structured agent sessions do not stream over the PTY websocket"
attached.payload["session"]["sessionId"],
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);
}

View File

@ -89,6 +89,98 @@ describe("CustomAgentChatView", () => {
__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 () => {
const agent = {
launchAgentChat: vi.fn(),

View File

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

View File

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

View File

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