feat(backend,frontend): lot utilisateur #310 #311 #312 #313 — horodatage conversation, stop agent robuste, préférence terminal par défaut, paramètres projet (QA verte)

- #310: horodatage de tous les messages et événements de conversation visibles — composant partagé EntryTimestamp, timestamps propagés dans le transcript chat, les annonces inter-agent et le viewer de conversation, normalisation côté domain/adapters (conversation_log, paginate).
- #311: stop d'agent robuste pour PTY non monté, sessions headless et CLI custom — lifecycle/orchestrator durcis, ponts chat app-tauri/web-server alignés, arrêt fiable sans session orpheline.
- #312: préférence globale d'interface terminal par défaut — nouveau store settings (domain/application/infrastructure + tests), DTO et commandes backend, adapter frontend userSettings, panneau TerminalSettings dans les paramètres globaux.
- #313: Paramètres du projet — contexte de projet éditable et couleur de thème par projet (ProjectSettingsPanel + projectTheme), teinte des onglets projet, persistance via DTO projet.

QA verte (GO QA sur feature/batch-310-311-312-313-chat-stop-prefs-theme) — vérifications Git: git diff --cached --check OK; staging borné à crates/ et frontend/ (74 fichiers, +2105/-531), commit unique de lot conforme à la convention des lots (précédents batch-151/158/159, batch-210/211/212) car les fichiers partagés (dto.rs, commands.rs, CustomAgentChatView) entrelacent les 4 tickets.

fichiers .ideai/* (agents/test.md, chat-drafts.json, system-permissions.json, tickets/counter.json, tickets/index.json, tickets/308-313/), attachments/ et scripts/ hors périmètre et préservés en worktree ; aucun push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 21:26:16 +02:00
parent fc45594d9a
commit 402a60f213
74 changed files with 2105 additions and 531 deletions

View File

@ -42,7 +42,7 @@ import {
markProjectCliInteractionStarted,
markProjectCliInteractionStopped,
} from "@/features/projects/projectCliInteractionActivity";
import { Button, Spinner, cn } from "@/shared";
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared";
import type { ChatAttachmentInput, ChatDraftScope } from "@/ports";
export interface CustomAgentChatViewProps {
@ -66,14 +66,14 @@ export interface CustomAgentChatViewProps {
}
type ChatTurn =
| { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number }
| { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number }
| { role: "tool"; label: string; receivedAtMs?: number }
| { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number | null }
| { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number | null }
| { role: "tool"; label: string; receivedAtMs?: number | null }
| { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string }
| { role: "final"; text: string; receivedAtMs?: number }
| { role: "error"; text: string; receivedAtMs?: number }
| { role: "restart"; key: string; text: string; message?: string; failed?: boolean }
| { role: "unknown"; text: string; receivedAtMs?: number };
| { role: "final"; text: string; receivedAtMs?: number | null }
| { role: "error"; text: string; receivedAtMs?: number | null }
| { role: "restart"; key: string; text: string; message?: string; failed?: boolean; receivedAtMs?: number | null }
| { role: "unknown"; text: string; receivedAtMs?: number | null };
interface CachedChatView {
turns: ChatTurn[];
@ -89,14 +89,6 @@ const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
const COMPOSER_RESIZE_STEP = 12;
const FALLBACK_COMPOSER_HEIGHT = 64;
const COMPOSER_HISTORY_LIMIT = 100;
const MIN_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
const MAX_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2100, 0, 1);
const MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor(
MIN_REASONABLE_CHAT_TIMESTAMP_MS / 1000,
);
const MAX_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor(
MAX_REASONABLE_CHAT_TIMESTAMP_MS / 1000,
);
export function __clearCustomAgentChatViewCacheForTests() {
chatSessionViewCache.clear();
@ -214,7 +206,7 @@ function isReplyRecord(chunk: unknown): chunk is Record<string, unknown> {
function appendAgentDelta(
turns: ChatTurn[],
text: string,
receivedAtMs: number,
receivedAtMs: number | null | undefined,
): ChatTurn[] {
const next = [...turns];
const last = next[next.length - 1];
@ -241,7 +233,7 @@ function appendUserPrompt(
turns: ChatTurn[],
text: string,
attachments: string[] = [],
receivedAtMs = Date.now(),
receivedAtMs?: number | null,
): { turns: ChatTurn[]; appended: boolean } {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
@ -251,6 +243,11 @@ function appendUserPrompt(
turn.text === text &&
JSON.stringify(turn.attachments ?? []) === JSON.stringify(attachments)
) {
if (turn.receivedAtMs == null && receivedAtMs != null) {
const next = [...turns];
next[index] = { ...turn, receivedAtMs };
return { turns: next, appended: false };
}
return { turns, appended: false };
}
break;
@ -805,29 +802,21 @@ function formatSlashCommandHelp(commands: SlashCommand[]): string {
.join("\n");
}
function lastAgentReceivedAtMs(turns: ChatTurn[]): number | undefined {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (turn.role === "agent") return turn.receivedAtMs;
if (turn.role === "user" || turn.role === "final" || turn.role === "error") {
return undefined;
}
}
return undefined;
}
function foldChunk(
state: ChatViewState,
raw: unknown,
receivedAtMs = Date.now(),
receivedAtMs?: number | null,
): ChatViewState {
const { turns } = state;
if (!isReplyRecord(raw)) {
return {
...state,
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
};
}
const emittedAtMs =
receivedAtMs ??
normalizeTimestamp(typeof raw.atMs === "number" ? raw.atMs : null);
const last = turns[turns.length - 1];
if (
(last?.role === "final" || last?.role === "error") &&
@ -843,14 +832,14 @@ function foldChunk(
case "textDelta":
return {
...state,
turns: appendAgentDelta(turns, String(raw.text ?? ""), receivedAtMs),
turns: appendAgentDelta(turns, String(raw.text ?? ""), emittedAtMs),
};
case "toolActivity":
return {
...state,
turns: [
...turns,
{ role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs },
{ role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs: emittedAtMs },
],
};
case "progress": {
@ -860,7 +849,7 @@ function foldChunk(
...state,
turns: [
...turns,
{ role: "unknown", text: unknownChunkLabel(raw), receivedAtMs },
{ role: "unknown", text: unknownChunkLabel(raw), receivedAtMs: emittedAtMs },
],
};
}
@ -875,7 +864,7 @@ function foldChunk(
next.push({
role: "final",
text: content,
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
receivedAtMs: emittedAtMs,
});
return { ...completedState, turns: next };
}
@ -887,7 +876,7 @@ function foldChunk(
next.push({
role: "error",
text: String(raw.message ?? "Erreur agent"),
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
receivedAtMs: emittedAtMs,
});
return { ...completedState, turns: next };
}
@ -897,7 +886,7 @@ function foldChunk(
turns,
String(raw.text ?? raw.prompt ?? ""),
attachmentLabels(raw.attachments),
receivedAtMs,
emittedAtMs,
);
return {
...state,
@ -910,7 +899,7 @@ function foldChunk(
default:
return {
...state,
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs: emittedAtMs }],
};
}
}
@ -1998,7 +1987,7 @@ export function CustomAgentChatView({
{
role: "tool",
label: `Profil changé vers ${selected.name}.`,
receivedAtMs: Date.now(),
receivedAtMs: null,
},
],
progressIndex: new Map(),
@ -2040,7 +2029,7 @@ export function CustomAgentChatView({
const helpText = formatSlashCommandHelp(result.effect.commands);
updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: Date.now() }],
turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: null }],
}));
return;
}
@ -2065,7 +2054,7 @@ export function CustomAgentChatView({
{
role: "tool",
label: `Commande plugin ${result.command.name} exécutée.`,
receivedAtMs: Date.now(),
receivedAtMs: null,
},
],
}));
@ -2076,7 +2065,7 @@ export function CustomAgentChatView({
setError({ message, openFailure: false });
updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }],
turns: [...prev.turns, { role: "error", text: message, receivedAtMs: null }],
}));
}
}
@ -2195,7 +2184,6 @@ export function CustomAgentChatView({
void clearPersistedDraft();
setBusy(true);
setError(null);
const sentAtMs = Date.now();
updateChatViewState((prev) => ({
...prev,
turns: [
@ -2203,7 +2191,7 @@ export function CustomAgentChatView({
{
role: "user",
text: displayText,
receivedAtMs: sentAtMs,
receivedAtMs: null,
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
},
],
@ -2242,7 +2230,7 @@ export function CustomAgentChatView({
...prev,
turns: [
...prev.turns,
{ role: "error", text: describe(recoveryError), receivedAtMs: Date.now() },
{ role: "error", text: describe(recoveryError), receivedAtMs: null },
],
}));
return;
@ -2253,7 +2241,7 @@ export function CustomAgentChatView({
setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }],
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: null }],
}));
}
}
@ -2280,7 +2268,7 @@ export function CustomAgentChatView({
{
role: "tool",
label: "Request stopped.",
receivedAtMs: Date.now(),
receivedAtMs: null,
},
],
};
@ -2299,7 +2287,7 @@ export function CustomAgentChatView({
...completed,
turns: [
...completed.turns,
{ role: "tool", label: "Request already stopped.", receivedAtMs: Date.now() },
{ role: "tool", label: "Request already stopped.", receivedAtMs: null },
],
};
});
@ -2313,7 +2301,7 @@ export function CustomAgentChatView({
{
role: "error",
text: `Arrêt de la demande échoué: ${describe(e)}`,
receivedAtMs: Date.now(),
receivedAtMs: null,
},
],
}));
@ -2729,7 +2717,10 @@ function ChatBubble({
turn.failed ? "text-danger" : "text-muted",
)}
>
<span className="whitespace-pre-wrap text-center">{turn.text}</span>
<div className="flex items-baseline gap-2">
<span className="whitespace-pre-wrap text-center">{turn.text}</span>
<EntryTimestamp atMs={turn.receivedAtMs} />
</div>
{turn.message && <span className="max-w-[86%] text-center">{turn.message}</span>}
</div>
);
@ -2737,8 +2728,9 @@ function ChatBubble({
if (turn.role === "tool") {
return (
<div className="flex min-w-0 justify-center">
<span className="max-w-[80%] truncate rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
{turn.label}
<span className="flex max-w-[80%] items-baseline gap-2 truncate rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
<span className="truncate">{turn.label}</span>
<EntryTimestamp atMs={turn.receivedAtMs} />
</span>
</div>
);
@ -2824,67 +2816,21 @@ function ChatBubble({
);
}
function normalizeChatReceivedAtMs(receivedAtMs: number | undefined): number | undefined {
if (!Number.isFinite(receivedAtMs) || receivedAtMs === undefined || receivedAtMs <= 0) {
return undefined;
}
if (
receivedAtMs >= MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS &&
receivedAtMs < MAX_REASONABLE_CHAT_TIMESTAMP_SECONDS
) {
return receivedAtMs * 1000;
}
if (
receivedAtMs < MIN_REASONABLE_CHAT_TIMESTAMP_MS ||
receivedAtMs >= MAX_REASONABLE_CHAT_TIMESTAMP_MS
) {
return undefined;
}
return receivedAtMs;
}
function formatChatReceivedTime(receivedAtMs: number | undefined): string {
const normalizedAtMs = normalizeChatReceivedAtMs(receivedAtMs);
if (!normalizedAtMs) return "";
try {
const parts = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(new Date(normalizedAtMs));
const hour = parts.find((part) => part.type === "hour")?.value;
const minute = parts.find((part) => part.type === "minute")?.value;
return hour && minute ? `${hour}:${minute}` : "";
} catch {
return "";
}
}
function ChatMessageMeta({
label,
receivedAtMs,
labelClassName,
}: {
label: string;
receivedAtMs: number | undefined;
receivedAtMs: number | null | undefined;
labelClassName?: string;
}) {
const time = formatChatReceivedTime(receivedAtMs);
const normalizedAtMs = normalizeChatReceivedAtMs(receivedAtMs);
const dateTime = time && normalizedAtMs ? new Date(normalizedAtMs).toISOString() : undefined;
return (
<div className="mb-1 flex min-w-0 flex-col gap-0.5 text-xs sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
<span className={cn("min-w-0 truncate font-medium text-muted", labelClassName)}>
{label}
</span>
{time && (
<time
dateTime={dateTime}
className="shrink-0 text-faint"
>
{time}
</time>
)}
<EntryTimestamp atMs={receivedAtMs} />
</div>
);
}
@ -3105,6 +3051,7 @@ function TurnMarker({ progress }: { progress: ReplyProgress }) {
{stage}
</span>
<span className="truncate">{label}</span>
<EntryTimestamp atMs={progress.occurredAtMs} />
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
</div>
</div>
@ -3150,6 +3097,7 @@ function ToolProgressCard({
<span className="min-w-0 truncate text-muted">{compactToolName(progress)}</span>
)}
<span className="shrink-0 text-muted">· {stage}</span>
<EntryTimestamp atMs={progress.occurredAtMs} />
</div>
{delegation?.excerpt && !detail && (
<p className="mt-1 text-center text-[11px] text-content" style={fourLineClamp}>
@ -3224,6 +3172,7 @@ function ProgressBubble({
<span className="shrink-0 font-semibold">{title}</span>
<span className="min-w-0 truncate text-muted">{progress.label}</span>
<span className="shrink-0 text-muted">· {stage}</span>
<EntryTimestamp atMs={progress.occurredAtMs} />
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap justify-end gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{source}</span>