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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user