Files
IdeA/frontend/src/features/workstate/ProjectWorkStatePanel.tsx
Blomios 1a1429f10e feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
2026-09-11 21:53:56 +02:00

983 lines
34 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Read-only project work-state panel: one row per agent, showing live/offline
* and idle/busy state from the backend read-model, plus the current input queue.
*/
import { Component, type ReactNode, useEffect, useRef, useState } from "react";
import type {
AgentTicketState,
AgentWorkState,
BackgroundCompletion,
ConversationPreviewStatus,
ConversationWorkSummary,
InboxItem,
LeafCell,
} from "@/domain";
import type { BackgroundTaskAttachment, StoppedLiveAgent } from "@/ports";
import { useGateways } from "@/app/di";
import { leaves } from "@/features/layout/layout";
import { useLayout } from "@/features/layout/useLayout";
import type { LayoutViewModel } from "@/features/layout/useLayout";
import { Button, EntryTimestamp, Panel, Spinner, cn } from "@/shared";
import { useProjectWorkState } from "./useProjectWorkState";
import { useI18n } from "@/i18n";
type Translator = (french: string, english: string) => string;
export interface ProjectWorkStatePanelProps {
projectId: string;
/** Opens the read-only transcript viewer for a conversation pair (LS7). */
onOpenConversation?: (conversationId: string) => void;
}
interface WorkStatePanelErrorBoundaryProps {
children: ReactNode;
}
interface WorkStatePanelErrorBoundaryState {
error: Error | null;
retryKey: number;
}
export class WorkStatePanelErrorBoundary extends Component<
WorkStatePanelErrorBoundaryProps,
WorkStatePanelErrorBoundaryState
> {
state: WorkStatePanelErrorBoundaryState = { error: null, retryKey: 0 };
static getDerivedStateFromError(error: Error): Partial<WorkStatePanelErrorBoundaryState> {
return { error };
}
private retry = () => {
this.setState(({ retryKey }) => ({ error: null, retryKey: retryKey + 1 }));
};
render() {
if (this.state.error) {
return <WorkStateError error={this.state.error} onRetry={this.retry} />;
}
return <div key={this.state.retryKey}>{this.props.children}</div>;
}
}
function WorkStateError({ error, onRetry }: { error: Error; onRetry: () => void }) {
const { tr } = useI18n();
return (
<Panel title={tr("Travail", "Work")}>
<div role="alert" className="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
<p className="font-medium">{tr("Le panneau Travail n'a pas pu s'afficher.", "Work panel failed to render.")}</p>
<p className="mt-1 text-xs text-danger/80">{error.message}</p>
<Button size="sm" variant="ghost" className="mt-2" onClick={onRetry}>{tr("Réessayer", "Retry")}</Button>
</div>
</Panel>
);
}
function shortTicket(ticket: string): string {
return ticket.length <= 8 ? ticket : ticket.slice(0, 8);
}
function formatStatus(status: AgentTicketState["status"], tr: Translator): string {
return status === "inProgress" ? tr("En cours", "In progress") : tr("En attente", "Queued");
}
function formatTaskStatus(status: BackgroundCompletion["status"], tr: Translator): string {
switch (status) {
case "running":
return tr("En cours", "Running");
case "cancelling":
return tr("Annulation…", "Cancelling");
case "completed":
return tr("Terminée", "Completed");
case "failed":
return tr("Échec", "Failed");
case "cancelFailed":
return tr("Échec de l'annulation", "Cancel failed");
case "cancelled":
return tr("Annulée", "Cancelled");
case "delivered":
return tr("Livrée", "Delivered");
case "pending":
return tr("En attente", "Pending");
}
}
/** Terminal (finished) task states that carry a result summary (ticket #5). */
function isTerminalTaskStatus(status: BackgroundCompletion["status"]): boolean {
return (
status === "completed" ||
status === "failed" ||
status === "cancelFailed" ||
status === "cancelled"
);
}
function taskStatusClass(status: BackgroundCompletion["status"]): string {
if (status === "running" || status === "pending" || status === "cancelling") {
return "bg-warning/15 text-warning";
}
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
if (status === "failed" || status === "cancelFailed" || status === "cancelled") {
return "bg-danger/10 text-danger";
}
return "bg-raised text-muted";
}
function canCancelTask(status: BackgroundCompletion["status"]): boolean {
return status === "running" || status === "pending" || status === "cancelling";
}
function canRetryTask(status: BackgroundCompletion["status"]): boolean {
return status === "failed" || status === "cancelFailed" || status === "cancelled";
}
type AgentStopUiState = "idle" | "stopping" | "stopped" | "incomplete";
function agentStatusLabel(
live: boolean,
busy: boolean,
runtimeState: AgentWorkState["runtimeState"],
stopState: AgentStopUiState,
tr: Translator,
): string {
if (stopState === "stopping" || runtimeState === "stopping") {
return tr("Arrêt en cours…", "Stopping…");
}
if (stopState === "incomplete") return tr("Arrêt incomplet", "Incomplete stop");
if (stopState === "stopped" || runtimeState === "stopped") return tr("Arrêté", "Stopped");
if (busy) return tr("Occupé", "Busy");
return live ? tr("Inactif", "Idle") : tr("Hors ligne", "Offline");
}
function agentStatusClass(
live: boolean,
busy: boolean,
runtimeState: AgentWorkState["runtimeState"],
stopState: AgentStopUiState,
): string {
if (stopState === "incomplete") return "bg-danger/10 text-danger";
if (
stopState === "stopping" ||
stopState === "stopped" ||
runtimeState === "stopping" ||
runtimeState === "stopped"
) {
return "bg-raised text-muted";
}
if (busy) return "bg-warning/15 text-warning";
if (live) return "bg-success/10 text-success";
return "bg-raised text-muted";
}
function backgroundTaskSummary(tasks: BackgroundCompletion[], tr: Translator): string | null {
if (tasks.length === 0) return null;
const running = tasks.filter((task) => canCancelTask(task.status)).length;
const failed = tasks.filter((task) => canRetryTask(task.status)).length;
if (running > 0 && failed > 0) return tr(`${tasks.length} tâches de fond (${running} actives, ${failed} en échec)`, `${tasks.length} background tasks (${running} active, ${failed} failed)`);
if (running > 0) return tr(`${running} tâche${running > 1 ? "s" : ""} de fond active${running > 1 ? "s" : ""}`, `${running} background task${running > 1 ? "s" : ""} active`);
if (failed > 0) return tr(`${failed} tâche${failed > 1 ? "s" : ""} de fond à vérifier`, `${failed} background task${failed > 1 ? "s" : ""} need attention`);
return tr(`${tasks.length} tâche${tasks.length > 1 ? "s" : ""} de fond`, `${tasks.length} background task${tasks.length > 1 ? "s" : ""}`);
}
function compactIntent(text: string | null): string | null {
const normalized = text?.replace(/\s+/g, " ").trim();
if (!normalized) return null;
return normalized.length <= 44 ? normalized : `${normalized.slice(0, 41)}...`;
}
function compactIndicator({
tickets,
inboxDepth,
backgroundTasks,
}: {
tickets: AgentTicketState[];
inboxDepth: number;
backgroundTasks: BackgroundCompletion[];
}): string | null {
const queuedTickets = tickets.filter((ticket) => ticket.status === "queued").length;
if (queuedTickets > 0) return `+${queuedTickets}`;
if (inboxDepth > 0) return `${inboxDepth} inbox`;
if (backgroundTasks.length > 0) return `${backgroundTasks.length} bg`;
return null;
}
function requesterLabel(ticket: AgentTicketState, tr: Translator): string {
if (ticket.source.kind === "human") return tr("Humain", "Human");
const id = shortTicket(ticket.source.agentId);
return ticket.requesterLabel.trim()
? `${ticket.requesterLabel} (${id})`
: `Agent ${id}`;
}
function summaryLabel(status: ConversationPreviewStatus): string {
switch (status) {
case "ready":
return "Summary";
case "missing":
return "No summary";
case "partial":
return "Partial";
case "unavailable":
return "Unavailable";
}
}
function summaryText(summary: ConversationWorkSummary, tr: Translator): string | null {
if (summary.objectivePreview) return `${tr("Objectif :", "Goal:")} ${summary.objectivePreview}`;
if (summary.summaryPreview) return summary.summaryPreview;
const lastTurn = (summary.recentTurns ?? []).at(-1);
return lastTurn ? `${tr("Dernier :", "Last:")} ${lastTurn.textPreview}` : null;
}
function goToCell(nodeId: string): void {
if (typeof document === "undefined") return;
const el = document.querySelector<HTMLElement>(`[data-node-id="${nodeId}"]`);
if (!el) return;
el.scrollIntoView?.({ block: "nearest", inline: "nearest" });
const previous = el.style.outline;
el.style.outline = "2px solid var(--color-primary, #5b9bd5)";
window.setTimeout(() => {
el.style.outline = previous;
}, 1200);
}
async function copyToClipboard(text: string): Promise<void> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
throw new Error("Clipboard unavailable");
}
function ConversationSummaryLine({
summary,
onOpenConversation,
}: {
summary: ConversationWorkSummary;
onOpenConversation?: (conversationId: string) => void;
}) {
const { tr } = useI18n();
const text = summaryText(summary, tr);
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
if (!text) return null;
return (
<div className="mt-1 min-w-0 pl-1 text-xs text-muted">
<div className="flex min-w-0 items-start gap-2">
<span
className={cn(
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
summary.status === "ready"
? "bg-success/10 text-success"
: "bg-raised text-muted",
)}
>
{tr(summary.status === "ready" ? "Résumé" : summary.status === "missing" ? "Aucun résumé" : summary.status === "partial" ? "Partiel" : "Indisponible", summaryLabel(summary.status))}
</span>
{onOpenConversation ? (
<button
type="button"
aria-label={tr(`ouvrir la conversation ${summary.conversationId}`, `open conversation ${summary.conversationId}`)}
className="min-w-0 flex-1 break-words text-left hover:text-content hover:underline"
onClick={() => onOpenConversation(summary.conversationId)}
>
{text}
</button>
) : (
<span className="min-w-0 flex-1 break-words">{text}</span>
)}
<button
type="button"
className="shrink-0 rounded px-1 py-0.5 text-[11px] text-muted hover:bg-raised hover:text-content"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? tr("Masquer", "Hide") : tr("Afficher", "View")}
</button>
<button
type="button"
className="shrink-0 rounded px-1 py-0.5 text-[11px] text-muted hover:bg-raised hover:text-content disabled:opacity-50"
disabled={!text}
onClick={() => {
void copyToClipboard(text).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1000);
});
}}
>
{copied ? tr("Copié", "Copied") : tr("Copier", "Copy")}
</button>
</div>
{expanded && (
<div
aria-label={tr(`détails de la conversation ${summary.conversationId}`, `conversation ${summary.conversationId} details`)}
className="mt-1 space-y-1 border-l border-border pl-2"
>
{summary.objectivePreview && (
<p>
<span className="font-medium text-content">{tr("Objectif :", "Goal:")}</span>{" "}
{summary.objectivePreview}
</p>
)}
{summary.summaryPreview && (
<p>
<span className="font-medium text-content">{tr("Résumé :", "Summary:")}</span>{" "}
{summary.summaryPreview}
</p>
)}
{(summary.recentTurns ?? []).length > 0 && (
<ul className="space-y-0.5">
{(summary.recentTurns ?? []).map((turn) => (
<li key={`${turn.atMs}-${turn.role}-${turn.textPreview}`}>
<span className="inline-flex items-baseline gap-1">
<span className="font-medium text-content">{turn.role}:</span>
<EntryTimestamp atMs={turn.atMs} />
</span>{" "}
{turn.textPreview}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
function TicketRow({
ticket,
summary,
onOpenConversation,
}: {
ticket: AgentTicketState;
summary?: ConversationWorkSummary;
onOpenConversation?: (conversationId: string) => void;
}) {
const { tr } = useI18n();
const truncated = ticket.taskLen > ticket.taskPreview.length;
return (
<li className="min-w-0 text-xs text-muted">
<div className="flex min-w-0 items-start gap-2">
<span
className={cn(
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
ticket.status === "inProgress"
? "bg-warning/15 text-warning"
: "bg-raised text-muted",
)}
>
#{ticket.position + 1} {formatStatus(ticket.status, tr)}
</span>
<span className="min-w-0 flex-1">
<span className="font-medium text-content">{requesterLabel(ticket, tr)}</span>
<span className="text-muted"> · </span>
<span className="break-words">{ticket.taskPreview}</span>
{truncated && (
<span
aria-label={tr(`${ticket.taskLen - ticket.taskPreview.length} caractères supplémentaires`, `${ticket.taskLen - ticket.taskPreview.length} more characters`)}
className="text-muted"
>
{" "}
+{ticket.taskLen - ticket.taskPreview.length}
</span>
)}
</span>
<code
aria-label={`ticket ${shortTicket(ticket.ticketId)}`}
title={ticket.ticketId}
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
>
{shortTicket(ticket.ticketId)}
</code>
</div>
{summary && (
<ConversationSummaryLine
summary={summary}
onOpenConversation={onOpenConversation}
/>
)}
</li>
);
}
function InboxRow({ item }: { item: InboxItem }) {
return (
<li className="min-w-0 text-xs text-muted">
<div className="flex min-w-0 items-start gap-2">
<span className="mt-0.5 shrink-0 rounded-full bg-raised px-1.5 py-0.5 font-medium text-muted">
{item.kind}
</span>
<span className="min-w-0 flex-1 break-words">{item.body}</span>
<code
title={item.id}
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
>
{shortTicket(item.id)}
</code>
</div>
</li>
);
}
function BackgroundTaskRow({
task,
onRefresh,
}: {
task: BackgroundCompletion;
onRefresh: () => Promise<void>;
}) {
const { tr } = useI18n();
const { workState } = useGateways();
const [open, setOpen] = useState(false);
const [liveOpen, setLiveOpen] = useState(false);
const [liveOutput, setLiveOutput] = useState("");
const [actionBusy, setActionBusy] = useState(false);
const [liveBusy, setLiveBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const attachmentRef = useRef<BackgroundTaskAttachment | null>(null);
const attachSeqRef = useRef(0);
const hasOutput = Boolean(task.stdoutTail || task.stderrTail);
const canCancel = canCancelTask(task.status);
const canRetry = canRetryTask(task.status);
const canAttachLive = canCancel;
function detachLive(): void {
attachSeqRef.current += 1;
attachmentRef.current?.detach();
attachmentRef.current = null;
setLiveOpen(false);
}
useEffect(() => {
return () => {
attachSeqRef.current += 1;
attachmentRef.current?.detach();
attachmentRef.current = null;
};
}, [task.taskId]);
useEffect(() => {
if (!canAttachLive && attachmentRef.current) detachLive();
}, [canAttachLive]);
async function runAction(
action: (taskId: string) => Promise<void>,
): Promise<void> {
setActionBusy(true);
setMessage(null);
try {
await action(task.taskId);
await onRefresh();
} catch (e) {
setMessage(e instanceof Error ? e.message : String(e));
} finally {
setActionBusy(false);
}
}
async function toggleLive(): Promise<void> {
if (liveOpen || attachmentRef.current) {
detachLive();
return;
}
const seq = attachSeqRef.current + 1;
attachSeqRef.current = seq;
setLiveBusy(true);
setMessage(null);
setLiveOutput("");
try {
const decoder = new TextDecoder();
const attachment = await workState.attachBackgroundTask(task.taskId, (bytes) => {
setLiveOutput((prev) => prev + decoder.decode(bytes));
});
if (seq !== attachSeqRef.current) {
attachment.detach();
return;
}
attachmentRef.current = attachment;
setLiveOutput(decoder.decode(attachment.scrollback));
setLiveOpen(true);
} catch (e) {
setMessage(e instanceof Error ? e.message : String(e));
} finally {
if (seq === attachSeqRef.current) setLiveBusy(false);
}
}
return (
<li className="min-w-0 text-xs text-muted">
<div className="flex min-w-0 items-start gap-2">
<span
className={cn(
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
taskStatusClass(task.status),
)}
>
{formatTaskStatus(task.status, tr)}
</span>
<span className="min-w-0 flex-1">
<span className="font-medium text-content">{task.kind}</span>
{task.exitCode !== null && (
<span className="text-muted"> · {tr("sortie", "exit")} {task.exitCode}</span>
)}
</span>
<Button
size="sm"
variant="ghost"
disabled={!canCancel || actionBusy}
loading={actionBusy}
onClick={() =>
void runAction((id) => workState.cancelBackgroundTask(id))
}
>
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
variant="ghost"
disabled={!canAttachLive || liveBusy}
loading={liveBusy}
onClick={() => void toggleLive()}
>
{liveOpen ? tr("Détacher", "Detach") : tr("Direct", "Live")}
</Button>
<Button
size="sm"
variant="ghost"
disabled={!hasOutput}
onClick={() => setOpen((v) => !v)}
>
{tr("Sortie", "Output")}
</Button>
<Button
size="sm"
variant="ghost"
disabled={!canRetry || actionBusy}
loading={actionBusy}
onClick={() =>
void runAction((id) => workState.retryBackgroundTask(id))
}
>
{tr("Réessayer", "Retry")}
</Button>
<code
title={task.taskId}
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
>
{shortTicket(task.taskId)}
</code>
</div>
{task.summary && isTerminalTaskStatus(task.status) && (
<p
aria-label={tr(`résumé de la tâche ${shortTicket(task.taskId)}`, `task ${shortTicket(task.taskId)} summary`)}
title={task.summary}
className="mt-1 truncate pl-1 text-[11px] text-muted"
>
{task.summary}
</p>
)}
{message && (
<p role="alert" className="mt-1 text-xs text-danger">
{message}
</p>
)}
{open && hasOutput && (
<pre
aria-label={tr(`sortie de la tâche ${shortTicket(task.taskId)}`, `task ${shortTicket(task.taskId)} output`)}
className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-border bg-canvas p-2 text-[11px] text-muted"
>
{task.stdoutTail ? `stdout\n${task.stdoutTail}` : ""}
{task.stdoutTail && task.stderrTail ? "\n\n" : ""}
{task.stderrTail ? `stderr\n${task.stderrTail}` : ""}
</pre>
)}
{liveOpen && (
<pre
aria-label={tr(`sortie directe de la tâche ${shortTicket(task.taskId)}`, `task ${shortTicket(task.taskId)} live output`)}
className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-border bg-canvas p-2 text-[11px] text-muted"
>
{liveOutput || tr("Aucune sortie directe pour le moment.", "No live output yet.")}
</pre>
)}
</li>
);
}
function AgentRow({
agent,
conversations,
visibleLeaves,
layout,
projectId,
onRefresh,
onOpenConversation,
}: {
agent: AgentWorkState;
conversations: Map<string, ConversationWorkSummary>;
visibleLeaves: LeafCell[];
layout: LayoutViewModel;
projectId: string;
onRefresh: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
}) {
const { agent: agentGateway } = useGateways();
const { tr } = useI18n();
const [message, setMessage] = useState<string | null>(null);
const [actionBusy, setActionBusy] = useState(false);
const [stopState, setStopState] = useState<AgentStopUiState>("idle");
const [detailsOpen, setDetailsOpen] = useState(false);
const [backgroundOpen, setBackgroundOpen] = useState(false);
const live = agent.live !== undefined;
const busyState = agent.busy ?? { state: "idle" };
const busy = busyState.state === "busy";
const tickets = [...(agent.tickets ?? [])].sort((a, b) => a.position - b.position);
const inbox = [...(agent.inbox ?? [])].sort((a, b) => a.createdAtMs - b.createdAtMs);
const backgroundTasks = [...(agent.backgroundTasks ?? [])].sort(
(a, b) => b.updatedAtMs - a.updatedAtMs || a.taskId.localeCompare(b.taskId),
);
const inboxDepth = Math.max(agent.inboxDepth ?? 0, inbox.length, tickets.length);
const visibleNodeIds = new Set(visibleLeaves.map((leaf) => leaf.id));
const liveVisible = live && visibleNodeIds.has(agent.live!.nodeId);
const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent);
const currentTurn =
busy || tickets.some((ticket) => ticket.status === "inProgress");
const activeTicket = tickets.find((ticket) => ticket.status === "inProgress");
const statusLabel = agentStatusLabel(live, busy, agent.runtimeState, stopState, tr);
const stopLabel =
stopState === "stopping" || agent.runtimeState === "stopping"
? tr("Arrêt en cours…", "Stopping…")
: stopState === "incomplete"
? tr("Réessayer l’arrêt", "Retry stopping")
: stopState === "stopped" || agent.runtimeState === "stopped"
? tr("Arrêté", "Stopped")
: tr("Arrêter", "Stop");
const taskSummary = backgroundTaskSummary(backgroundTasks, tr);
const activityLine = compactIntent(
activeTicket?.taskPreview ??
(busyState.state === "busy" ? `Ticket ${shortTicket(busyState.ticket)}` : null),
);
const indicator = compactIndicator({ tickets, inboxDepth, backgroundTasks });
const hasActions = Boolean(agent.live);
const hasDetails =
hasActions ||
tickets.length > 0 ||
inbox.length > 0 ||
backgroundTasks.length > 0 ||
message ||
(agent.live && !liveVisible && !attachTarget);
async function refreshAfterAction(): Promise<void> {
await onRefresh();
}
async function attach(): Promise<void> {
if (!agent.live || !attachTarget || !agentGateway?.attachLiveAgent) return;
setActionBusy(true);
setMessage(null);
try {
const attached = await agentGateway.attachLiveAgent(
projectId,
agent.agentId,
attachTarget.id,
);
await layout.attachLiveAgentToCell(
attachTarget.id,
agent.agentId,
attached.sessionId,
);
await refreshAfterAction();
goToCell(attachTarget.id);
} catch (e) {
setMessage(e instanceof Error ? e.message : String(e));
} finally {
setActionBusy(false);
}
}
async function stop(confirm = true): Promise<void> {
if (!agent.live || !agentGateway) return;
const warning = currentTurn
? tr(" Le tour en cours sera interrompu.", " The current turn will be interrupted.")
: "";
if (confirm && !window.confirm(`${tr("Arrêter", "Stop")} ${agent.name} ?${warning}`)) return;
setActionBusy(true);
setStopState("stopping");
setMessage(null);
let stopped: StoppedLiveAgent;
try {
stopped = await agentGateway.stopLiveAgent(
projectId,
agent.agentId,
"Arrêt demandé depuis Travail",
);
} catch {
setStopState("incomplete");
setMessage(tr("Certains travaux sont encore actifs.", "Some work is still active."));
setActionBusy(false);
return;
}
// The stop response is the backend confirmation. Keep the row non-busy
// while its authoritative live-state snapshot is being refreshed.
setStopState("stopped");
try {
if (visibleNodeIds.has(agent.live.nodeId)) {
const leaf = visibleLeaves.find((candidate) => candidate.id === agent.live?.nodeId);
const stoppedSessionIds = new Set(stopped.stopped.map((runtime) => runtime.sessionId));
if (!leaf?.session || stoppedSessionIds.has(leaf.session) || stopped.alreadyStopped) {
await layout.setSession(agent.live.nodeId, null);
}
}
await refreshAfterAction();
} catch (e) {
setMessage(
`${tr("Arrêt confirmé. Impossible d’actualiser l’affichage :", "Stop confirmed. Unable to refresh the display:")} ${
e instanceof Error ? e.message : String(e)
}`,
);
} finally {
setActionBusy(false);
}
}
return (
<li className="py-2 first:pt-0 last:pb-0">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 truncate text-sm font-semibold text-content">
{agent.name}
</span>
{activityLine && (
<span
className="min-w-0 truncate text-xs text-muted"
title={activeTicket?.taskPreview ?? activityLine}
>
{activityLine}
</span>
)}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{indicator && (
<span className="rounded-full bg-raised px-1.5 py-0.5 text-[11px] font-medium text-muted">
{indicator}
</span>
)}
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
agentStatusClass(live, busy, agent.runtimeState, stopState),
)}
>
{statusLabel}
</span>
{hasDetails && (
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-muted hover:bg-raised hover:text-content"
aria-expanded={detailsOpen}
aria-controls={`work-agent-details-${agent.agentId}`}
onClick={() => setDetailsOpen((value) => !value)}
>
{detailsOpen ? tr("Masquer", "Hide") : tr("Détails", "Details")}
</button>
)}
</span>
</div>
{detailsOpen && (
<div
id={`work-agent-details-${agent.agentId}`}
className="mt-2 space-y-2 border-l border-border pl-3"
>
{agent.live && (
<div className="flex min-w-0 flex-wrap gap-1.5">
{agent.live && liveVisible && (
<Button
size="sm"
variant="ghost"
onClick={() => goToCell(agent.live!.nodeId)}
>
{tr("Ouvrir", "Open")}
</Button>
)}
{agent.live && !liveVisible && (
<Button
size="sm"
variant="ghost"
disabled={!attachTarget || !agentGateway?.attachLiveAgent}
loading={actionBusy}
title={
attachTarget
? undefined
: tr("Aucune cellule visible vide disponible", "No empty visible cell available")
}
onClick={() => void attach()}
>
{tr("Attacher", "Attach")}
</Button>
)}
{agent.live && (
<Button
size="sm"
variant="danger"
aria-label={stopLabel}
loading={stopState === "stopping"}
disabled={
!agentGateway ||
actionBusy ||
stopState === "stopped" ||
agent.runtimeState === "stopping" ||
agent.runtimeState === "stopped"
}
onClick={() => void stop(stopState !== "incomplete")}
>
{stopLabel}
</Button>
)}
</div>
)}
{agent.live && !liveVisible && !attachTarget && (
<p className="text-xs text-muted">{tr("Aucune cellule visible vide.", "No empty visible cell.")}</p>
)}
{message && (
<p role="alert" className="text-xs text-danger">
{message}
</p>
)}
{tickets.length > 0 && (
<ul
aria-label={`${tr("Tickets de", "Tickets for")} ${agent.name}`}
className="flex flex-col gap-1"
>
{tickets.map((ticket) => (
<TicketRow
key={ticket.ticketId}
ticket={ticket}
summary={conversations.get(ticket.conversationId)}
onOpenConversation={onOpenConversation}
/>
))}
</ul>
)}
{inbox.length > 0 && (
<div>
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">
{tr("Boîte de réception", "Inbox")}
</p>
<ul aria-label={`${tr("Boîte de réception de", "Inbox for")} ${agent.name}`} className="mt-1 flex flex-col gap-1">
{inbox.map((item) => (
<InboxRow key={item.id} item={item} />
))}
</ul>
</div>
)}
{backgroundTasks.length > 0 && (
<div>
<button
type="button"
className="text-xs font-medium text-muted hover:text-content hover:underline"
aria-expanded={backgroundOpen}
aria-controls={`work-agent-background-${agent.agentId}`}
onClick={() => setBackgroundOpen((value) => !value)}
>
{backgroundOpen ? tr("Masquer les tâches en arrière-plan", "Hide background tasks") : taskSummary}
</button>
{backgroundOpen && (
<ul
id={`work-agent-background-${agent.agentId}`}
aria-label={`${tr("Tâches de fond de", "Background tasks for")} ${agent.name}`}
className="mt-1 flex flex-col gap-1"
>
{backgroundTasks.map((task) => (
<BackgroundTaskRow
key={task.taskId}
task={task}
onRefresh={refreshAfterAction}
/>
))}
</ul>
)}
</div>
)}
</div>
)}
</li>
);
}
function ProjectWorkStatePanelContent({
projectId,
onOpenConversation,
}: ProjectWorkStatePanelProps) {
const { tr } = useI18n();
const vm = useProjectWorkState(projectId);
const layoutVm = useLayout(projectId);
const agents = vm.state?.agents ?? [];
const visibleLeaves = layoutVm.layout ? leaves(layoutVm.layout) : [];
const conversations = new Map(
(vm.state?.conversations ?? []).map((summary) => [
summary.conversationId,
summary,
]),
);
return (
<Panel
title={tr("Travail", "Work")}
actions={
<Button
size="sm"
variant="ghost"
onClick={() => void vm.refresh()}
loading={vm.busy}
>
{tr("Rafraîchir", "Refresh")}
</Button>
}
>
{vm.error && (
<p
role="alert"
className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{vm.busy && vm.state === null ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>{tr("Chargement de l'état de travail…", "Loading work state…")}</span>
</div>
) : agents.length === 0 ? (
<p className="text-sm text-muted">{tr("Aucun état de travail d'agent.", "No agent work state.")}</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{agents.map((agent) => (
<AgentRow
key={agent.agentId}
agent={agent}
conversations={conversations}
visibleLeaves={visibleLeaves}
layout={layoutVm}
projectId={projectId}
onRefresh={vm.refresh}
onOpenConversation={onOpenConversation}
/>
))}
</ul>
)}
</Panel>
);
}
export function ProjectWorkStatePanel(props: ProjectWorkStatePanelProps) {
return (
<WorkStatePanelErrorBoundary>
<ProjectWorkStatePanelContent {...props} />
</WorkStatePanelErrorBoundary>
);
}