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.
983 lines
34 KiB
TypeScript
983 lines
34 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|