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

@ -847,9 +847,9 @@ describe("CustomAgentChatView", () => {
})),
sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => {
nowMs = firstDeltaReceivedAtMs;
onChunk({ kind: "textDelta", text: "Streaming reply" });
onChunk({ kind: "textDelta", text: "Streaming reply", atMs: firstDeltaReceivedAtMs });
nowMs = finalChunkReceivedAtMs;
onChunk({ kind: "final", content: "Completed reply" });
onChunk({ kind: "final", content: "Completed reply", atMs: finalChunkReceivedAtMs });
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
@ -898,16 +898,16 @@ describe("CustomAgentChatView", () => {
expect(userBubble).toBeTruthy();
expect(agentBubble).toBeTruthy();
expect(finalBubble).toBeTruthy();
expect(within(userBubble!).getByText(expectedChatTime(userReceivedAtMs))).toBeTruthy();
expect(within(userBubble!).getByLabelText("Heure indisponible")).toBeTruthy();
expect(
within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)),
).toBeTruthy();
expect(
within(finalBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)),
within(finalBubble!).getByText(expectedChatTime(finalChunkReceivedAtMs)),
).toBeTruthy();
expect(screen.getByText("Streaming reply")).toBeTruthy();
expect(screen.getByText("Completed reply")).toBeTruthy();
expect(screen.queryByText(expectedChatTime(finalChunkReceivedAtMs))).toBeNull();
expect(screen.getByText(expectedChatTime(finalChunkReceivedAtMs))).toBeTruthy();
} finally {
nowSpy.mockRestore();
}
@ -927,9 +927,9 @@ describe("CustomAgentChatView", () => {
})),
sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => {
nowMs = firstDeltaReceivedAtMs;
onChunk({ kind: "textDelta", text: "Streaming reply" });
onChunk({ kind: "textDelta", text: "Streaming reply", atMs: firstDeltaReceivedAtMs });
nowMs = errorReceivedAtMs;
onChunk({ kind: "error", message: "Broken reply" });
onChunk({ kind: "error", message: "Broken reply", atMs: errorReceivedAtMs });
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
@ -981,9 +981,9 @@ describe("CustomAgentChatView", () => {
within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)),
).toBeTruthy();
expect(
within(errorBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)),
within(errorBubble!).getByText(expectedChatTime(errorReceivedAtMs)),
).toBeTruthy();
expect(screen.queryByText(expectedChatTime(errorReceivedAtMs))).toBeNull();
expect(screen.getByText(expectedChatTime(errorReceivedAtMs))).toBeTruthy();
} finally {
nowSpy.mockRestore();
}
@ -1036,12 +1036,8 @@ describe("CustomAgentChatView", () => {
expect(agentBubble).toBeTruthy();
expect(finalBubble).toBeTruthy();
expect(
within(agentBubble!).getByText(expectedChatTime(replayReceivedAtMs)),
).toBeTruthy();
expect(
within(finalBubble!).getByText(expectedChatTime(replayReceivedAtMs)),
).toBeTruthy();
expect(within(agentBubble!).getByLabelText("Heure indisponible")).toBeTruthy();
expect(within(finalBubble!).getByLabelText("Heure indisponible")).toBeTruthy();
expect(within(agentBubble!).queryByText(indexOneTime)).toBeNull();
expect(within(finalBubble!).queryByText(indexOneTime)).toBeNull();
} finally {

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>

View File

@ -450,7 +450,8 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
it("stopping an agent unmounts the terminal view", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, { name: "Echo", profileId: "p1" });
const created = await agent.createAgent(PROJECT_ID, { name: "Echo", profileId: "p1" });
const stop = vi.spyOn(agent, "stopLiveAgent");
renderPanel(agent);
await waitForIdle();
@ -466,6 +467,31 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
await waitFor(() => {
expect(screen.queryByTestId("terminal-view")).toBeNull();
});
expect(stop).toHaveBeenCalledWith(
PROJECT_ID,
created.id,
"Arrêt demandé depuis Agents",
);
});
it("stops a live agent even when its TerminalView was never mounted", async () => {
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, { name: "Headless", profileId: "p1" });
await agent.launchAgent(
PROJECT_ID,
created.id,
{ cwd: "/repo", rows: 24, cols: 80, nodeId: "background-node" },
() => {},
);
const stop = vi.spyOn(agent, "stopLiveAgent");
renderPanel(agent);
await screen.findByRole("button", { name: "stop Headless" });
expect(screen.queryByTestId("terminal-view")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "stop Headless" }));
await waitFor(() => expect(stop).toHaveBeenCalled());
await expect(agent.listLiveAgents(PROJECT_ID)).resolves.toEqual([]);
});
it("selecting a template calls createAgentFromTemplate instead of createAgent", async () => {

View File

@ -171,7 +171,7 @@ function describe(e: unknown): string {
}
export function useAgents(projectId: string): AgentsViewModel {
const { agent, profile, system, terminal, input } = useGateways();
const { agent, profile, system, input } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
@ -515,25 +515,21 @@ export function useAgents(projectId: string): AgentsViewModel {
const stopAgent = useCallback(
async (agentId?: string) => {
const targetAgentId = agentId ?? runningAgentId;
const live = targetAgentId
? liveAgents.find((candidate) => candidate.agentId === targetAgentId)
: null;
if (!targetAgentId) return;
setRunningAgentId((current) =>
current === targetAgentId || !targetAgentId ? null : current,
);
if (live?.sessionId) {
try {
await terminal?.closeTerminal(live.sessionId);
setLiveAgents((current) =>
current.filter((candidate) => candidate.agentId !== targetAgentId),
);
} catch (e) {
setError(describe(e));
await refreshLiveAgents();
}
try {
await agent.stopLiveAgent(projectId, targetAgentId, "Arrêt demandé depuis Agents");
setLiveAgents((current) =>
current.filter((candidate) => candidate.agentId !== targetAgentId),
);
} catch (e) {
setError(describe(e));
await refreshLiveAgents();
}
},
[liveAgents, refreshLiveAgents, runningAgentId, terminal],
[agent, projectId, refreshLiveAgents, runningAgentId],
);
const cancelResume = useCallback(