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(

View File

@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { Spinner } from "@/shared";
import { EntryTimestamp, Spinner } from "@/shared";
import { useAgentRestart, useTargetAnnouncements } from "./AnnouncementsProvider";
export interface AgentRestartOverlayProps {
@ -69,7 +69,8 @@ export function AgentRestartOverlay({ agentId }: AgentRestartOverlayProps) {
</div>
) : (
announcements.map((announcement) => (
<div key={announcement.seq} className="text-sm text-content/90">
<div key={announcement.seq} className="flex items-baseline gap-2 text-sm text-content/90">
<EntryTimestamp atMs={announcement.atMs} className="text-xs" />
<span className="whitespace-pre-wrap break-words">{announcement.text}</span>
</div>
))

View File

@ -12,6 +12,7 @@
*/
import { useRequesterAnnouncements } from "./AnnouncementsProvider";
import { EntryTimestamp } from "@/shared";
export interface AnnouncementsPreviewProps {
/** The requesting agent this preview belongs to (the announcement `requester`). */
@ -60,10 +61,11 @@ export function AnnouncementsPreview({
{recent.map((a) => (
<li
key={a.seq}
className="truncate text-xs text-muted"
className="flex min-w-0 items-baseline gap-1.5 text-xs text-muted"
title={a.text}
>
{a.text}
<EntryTimestamp atMs={a.atMs} />
<span className="truncate">{a.text}</span>
</li>
))}
</ul>

View File

@ -15,7 +15,7 @@
import { useEffect, useRef } from "react";
import { Spinner } from "@/shared";
import { EntryTimestamp, Spinner } from "@/shared";
import {
useHydrateAgentBusy,
useTargetAnnouncements,
@ -32,15 +32,6 @@ export interface TargetAnnouncementsOverlayProps {
fallbackText?: string;
}
function formatTime(atMs: number): string {
if (!Number.isFinite(atMs) || atMs <= 0) return "";
try {
return new Date(atMs).toLocaleTimeString();
} catch {
return "";
}
}
export function TargetAnnouncementsOverlay({
projectId,
agentId,
@ -88,13 +79,12 @@ export function TargetAnnouncementsOverlay({
</div>
) : (
announcements.map((a) => {
const time = formatTime(a.atMs);
return (
<div
key={a.seq}
className="flex items-baseline gap-2 text-sm text-muted"
>
{time && <span className="shrink-0 text-xs text-faint">{time}</span>}
<EntryTimestamp atMs={a.atMs} className="text-xs" />
<span className="whitespace-pre-wrap break-words text-content/90">
{a.text}
</span>

View File

@ -34,7 +34,7 @@ export interface Announcement {
/** Human-readable réflexion/annonce text. */
text: string;
/** Wall-clock timestamp (epoch-ms) as emitted by the backend. */
atMs: number;
atMs: number | null;
/**
* Monotonic local sequence assigned on ingest. Provides a stable React key and
* a total order even when two announcements share the same `atMs`.
@ -110,6 +110,7 @@ export function purgeAllForTarget(
/** Orders two announcements oldest → newest (by `atMs`, tie-broken by `seq`). */
function byTime(a: Announcement, b: Announcement): number {
if (a.atMs === null || b.atMs === null) return a.seq - b.seq;
return a.atMs - b.atMs || a.seq - b.seq;
}

View File

@ -18,7 +18,7 @@ import type {
TurnSource,
TurnView,
} from "@/domain";
import { Button, Spinner, cn } from "@/shared";
import { Button, EntryTimestamp, Spinner, cn } from "@/shared";
import { useProjectWorkState } from "@/features/workstate";
import { useConversationThread } from "./useConversationThread";
@ -38,15 +38,6 @@ function partyKey(source: TurnSource): string {
return source.kind === "human" ? "human" : `agent:${source.agentId}`;
}
function formatTime(atMs: number): string {
if (!Number.isFinite(atMs) || atMs <= 0) return "";
try {
return new Date(atMs).toLocaleString();
} catch {
return "";
}
}
function roleLabel(role: TurnView["role"]): string {
switch (role) {
case "prompt":
@ -185,6 +176,7 @@ function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) {
<span aria-hidden>{open ? "▾" : "▸"}</span>
Tool activity
<span className="text-faint">· {turn.textLen} chars</span>
<EntryTimestamp atMs={turn.atMs} />
</button>
{open && (
<pre className="mt-1.5 whitespace-pre-wrap break-words font-mono text-[11px] text-muted">
@ -353,7 +345,6 @@ export function ConversationViewer({
);
}
const party = parties.meta.get(partyKey(turn.source));
const time = formatTime(turn.atMs);
return (
<div key={turn.id} className={cn("flex flex-col", sideClass)}>
<div
@ -369,7 +360,7 @@ export function ConversationViewer({
{party?.label ?? "?"}
</span>
<span>· {roleLabel(turn.role)}</span>
{time && <span className="text-faint">· {time}</span>}
<EntryTimestamp atMs={turn.atMs} />
</div>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
</div>

View File

@ -843,10 +843,8 @@ function LeafView({
}
if (cellMode === "custom" && agentGateway?.closeAgentChat) {
await agentGateway.closeAgentChat(session);
} else if (agentId && agentGateway?.stopLiveAgent) {
await agentGateway.stopLiveAgent(projectId, agentId).catch(async () => {
await terminal?.closeTerminal(session);
});
} else if (agentId && agentGateway) {
await agentGateway.stopLiveAgent(projectId, agentId, "Changement d'interface terminal");
} else {
await terminal?.closeTerminal(session);
}
@ -1115,7 +1113,7 @@ function LeafView({
height: "100%",
minHeight: 0,
minWidth: 0,
border: "1px solid #2a2a2a",
border: "1px solid var(--color-border-strong, #3b4757)",
boxSizing: "border-box",
overflow: "hidden",
}}

View File

@ -0,0 +1,39 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { createMockGateways } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { ProjectSettingsPanel, PROJECT_THEME_CHANGED_EVENT } from "./ProjectSettingsPanel";
import { accessibleProjectAccent, validThemeColor } from "./projectTheme";
describe("ProjectSettingsPanel", () => {
it("persists a project colour, emits the scoped refresh, and resets to fallback", async () => {
const gateways = createMockGateways();
const project = await gateways.project.createProject("Alpha", "/alpha");
const update = vi.spyOn(gateways.project, "updateProjectSettings");
const changed = vi.fn();
window.addEventListener(PROJECT_THEME_CHANGED_EVENT, changed);
render(
<DIProvider gateways={gateways}>
<ProjectSettingsPanel projectId={project.id} />
</DIProvider>,
);
const picker = (await screen.findByLabelText("Couleur du projet")) as HTMLInputElement;
await waitFor(() => expect(picker.disabled).toBe(false));
fireEvent.change(picker, { target: { value: "#aa3366" } });
await waitFor(() => expect(update).toHaveBeenCalledWith(project.id, { themeColor: "#AA3366" }));
expect(changed).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Rétablir la couleur par défaut" }));
await waitFor(() => expect(update).toHaveBeenLastCalledWith(project.id, { themeColor: null }));
expect(screen.getByText("Contexte du projet")).toBeTruthy();
window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, changed);
});
it("keeps strict colours and raises dark accents to readable contrast", () => {
expect(validThemeColor("#12abEF")).toBe("#12ABEF");
expect(validThemeColor("red")).toBeNull();
expect(accessibleProjectAccent("#000000")).not.toBe("#000000");
});
});

View File

@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import { useGateways } from "@/app/di";
import { Button, Panel, Spinner } from "@/shared";
import { ProjectContextPanel } from "./ProjectContextPanel";
import { DEFAULT_PROJECT_COLOR, validThemeColor } from "./projectTheme";
export const PROJECT_THEME_CHANGED_EVENT = "idea:project-theme-changed";
export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
const { project } = useGateways();
const [themeColor, setThemeColor] = useState<string | null>(null);
const [busy, setBusy] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setBusy(true);
project
.readProjectSettings(projectId)
.then((settings) => {
if (!cancelled) {
setThemeColor(validThemeColor(settings.themeColor));
setError(null);
}
})
.catch(() => {
if (!cancelled) setError("Impossible de charger les paramètres du projet.");
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, [project, projectId]);
async function save(next: string | null) {
setBusy(true);
setError(null);
try {
const saved = await project.updateProjectSettings(projectId, { themeColor: next });
const normalized = validThemeColor(saved.themeColor);
setThemeColor(normalized);
window.dispatchEvent(
new CustomEvent(PROJECT_THEME_CHANGED_EVENT, {
detail: { projectId, themeColor: normalized },
}),
);
} catch {
setError("Impossible d’enregistrer la couleur du projet.");
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col gap-4">
<Panel title="Apparence" className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<label className="flex items-center gap-3 text-sm font-medium text-content">
Couleur du projet
<input
aria-label="Couleur du projet"
type="color"
value={themeColor ?? DEFAULT_PROJECT_COLOR}
disabled={busy}
onChange={(event) => void save(event.target.value.toUpperCase())}
className="h-9 w-12 cursor-pointer rounded border border-border bg-raised p-1 disabled:cursor-not-allowed"
/>
</label>
<Button size="sm" variant="ghost" disabled={busy || themeColor === null} onClick={() => void save(null)}>
Rétablir la couleur par défaut
</Button>
</div>
{busy && <Spinner size={14} />}
{error && <p role="alert" className="text-sm text-danger">{error}</p>}
</Panel>
<section aria-labelledby="project-context-settings-title">
<h2
id="project-context-settings-title"
className="mb-2 text-sm font-semibold text-content"
>
Contexte du projet
</h2>
<ProjectContextPanel projectId={projectId} />
</section>
</div>
);
}

View File

@ -13,6 +13,7 @@
import type { TabItem } from "@/shared";
import { Button, Tabs, cn } from "@/shared";
import type { ProjectActivityStatus } from "./projectActivity";
import { accessibleProjectAccent } from "./projectTheme";
export interface ProjectTabsProps {
items: TabItem[];
@ -25,6 +26,7 @@ export interface ProjectTabsProps {
onOpenProjectsPanel: () => void;
/** Per-project custom CLI activity, keyed by project id. Missing = none. */
activityByProjectId?: Record<string, ProjectActivityStatus>;
themeColorByProjectId?: Record<string, string | null>;
className?: string;
}
@ -36,10 +38,15 @@ export function ProjectTabs({
projectsPanelOpen,
onOpenProjectsPanel,
activityByProjectId = {},
themeColorByProjectId = {},
className,
}: ProjectTabsProps) {
const tabs = items.map((item) => ({
...item,
themeColor: themeColorByProjectId[item.id] ?? null,
themeAccent: themeColorByProjectId[item.id]
? accessibleProjectAccent(themeColorByProjectId[item.id]!)
: null,
leading: (
<ProjectActivityDot
status={activityByProjectId[item.id] ?? "none"}

View File

@ -81,6 +81,8 @@ import { useProjectActivity } from "./useProjectActivity";
import { ProjectTabs } from "./ProjectTabs";
import { ProjectActionsMenu } from "./ProjectActionsMenu";
import { RemoveProjectDialog } from "./RemoveProjectDialog";
import { PROJECT_THEME_CHANGED_EVENT } from "./ProjectSettingsPanel";
import { projectThemeStyle, validThemeColor } from "./projectTheme";
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
import {
PANEL_TITLE,
@ -99,6 +101,7 @@ import {
const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
projects: "md",
context: "lg",
projectSettings: "lg",
work: "lg",
tickets: "lg",
agents: "lg",
@ -235,6 +238,7 @@ export function ProjectsView() {
system,
window: windowGateway,
focusedProject,
project,
git,
agent,
} = useGateways();
@ -273,8 +277,15 @@ export function ProjectsView() {
const [removalPending, setRemovalPending] = useState(false);
const [removalError, setRemovalError] = useState<string | null>(null);
const [removalNotice, setRemovalNotice] = useState<Project | null>(null);
const [projectThemeColors, setProjectThemeColors] = useState<
Record<string, string | null>
>({});
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
const openProjectThemeKey = vm.openTabs.map((tab) => tab.id).join("\u0000");
const activeThemeStyle = projectThemeStyle(
active ? projectThemeColors[active.id] : null,
);
const activeViewerConversation =
viewerConversation?.projectId === active?.id ? viewerConversation : null;
const showRestoreNotification =
@ -301,6 +312,40 @@ export function ProjectsView() {
useEffect(() => {
if (vm.restoreFailures.length === 0) setRestoreDetailsOpen(false);
}, [vm.restoreFailures.length]);
useEffect(() => {
let cancelled = false;
void Promise.all(
vm.openTabs.map(async (tab) => {
try {
const settings = await project.readProjectSettings(tab.id);
return [tab.id, validThemeColor(settings.themeColor)] as const;
} catch {
return [tab.id, null] as const;
}
}),
).then((entries) => {
if (!cancelled) setProjectThemeColors(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [project, openProjectThemeKey]);
useEffect(() => {
const update = (event: Event) => {
const detail = (
event as CustomEvent<{ projectId: string; themeColor: string | null }>
).detail;
if (!detail?.projectId) return;
setProjectThemeColors((current) => ({
...current,
[detail.projectId]: validThemeColor(detail.themeColor),
}));
};
window.addEventListener(PROJECT_THEME_CHANGED_EVENT, update);
return () => window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, update);
}, []);
const projectNameById = useMemo(() => {
const names = new Map<string, string>();
for (const project of vm.projects) names.set(project.id, project.name);
@ -601,6 +646,7 @@ export function ProjectsView() {
// placement is marked in the submenu (and the parent entry shows ● when the
// panel is open somewhere). Order per UX: content panels first, Projects last.
const panelOrder: PanelId[] = [
"projectSettings",
"context",
"work",
"tickets",
@ -659,6 +705,11 @@ export function ProjectsView() {
const nativePanelItems: MenuBarItem[] = panelOrder.map((panel) => ({
id: panel,
label: PANEL_TITLE[panel],
disabled: panel === "projectSettings" && !active,
title:
panel === "projectSettings" && !active
? "Ouvrez ou sélectionnez un projet pour accéder à ses paramètres."
: undefined,
active: placementOf(placements, panel) !== "closed",
onSelect: () => {},
submenu: placementSubmenu(panel),
@ -954,6 +1005,7 @@ export function ProjectsView() {
{/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */}
<ProjectTabs
items={vm.openTabs.map((t) => ({ id: t.id, label: t.name }))}
themeColorByProjectId={projectThemeColors}
activeTabId={vm.activeTabId}
onSelect={(id) => vm.activateTab(id)}
onClose={(id) => void vm.closeTab(id)}
@ -965,6 +1017,11 @@ export function ProjectsView() {
}}
/>
<div
data-testid="active-project-theme-scope"
className="flex min-h-0 flex-1 flex-col overflow-hidden bg-canvas"
style={activeThemeStyle}
>
{/* ── Menu bar (replaces the former left sidebar) ── */}
<PluginMenuRenderBoundary
resetKey={pluginMenuResetKey}
@ -1204,6 +1261,7 @@ export function ProjectsView() {
))}
</div>
)}
</div>
</div>
);
}

View File

@ -21,6 +21,7 @@ import { ProjectWorkStatePanel } from "@/features/workstate";
import { TicketsView } from "@/features/tickets";
import { GitPanel } from "@/features/git";
import { ProjectContextPanel } from "./ProjectContextPanel";
import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
import type { PanelId } from "./viewPlacement";
/** Panels that render a project-scoped view (everything except "projects"). */
@ -45,6 +46,8 @@ export function ViewPanelBody({
switch (panel) {
case "context":
return <ProjectContextPanel projectId={projectId} />;
case "projectSettings":
return <ProjectSettingsPanel projectId={projectId} />;
case "work":
return (
<ProjectWorkStatePanel

View File

@ -0,0 +1,59 @@
import type { CSSProperties } from "react";
export const DEFAULT_PROJECT_COLOR = "#4C8DFF";
export function validThemeColor(value: string | null | undefined): string | null {
return value && /^#[0-9A-Fa-f]{6}$/.test(value) ? value.toUpperCase() : null;
}
function rgb(hex: string): [number, number, number] {
return [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16)) as [
number,
number,
number,
];
}
function luminance(hex: string): number {
const channels = rgb(hex).map((channel) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
}
function contrast(a: string, b: string): number {
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (light + 0.05) / (dark + 0.05);
}
/** Lightens very dark user colours until accent text remains WCAG-AA on canvas. */
export function accessibleProjectAccent(raw: string): string {
const color = validThemeColor(raw) ?? DEFAULT_PROJECT_COLOR;
const [r, g, b] = rgb(color);
for (let amount = 0; amount <= 1; amount += 0.02) {
const next = `#${[r, g, b]
.map((channel) => Math.round(channel + (255 - channel) * amount).toString(16).padStart(2, "0"))
.join("")}`;
if (contrast(next, "#0E1116") >= 4.5) return next.toUpperCase();
}
return "#FFFFFF";
}
export function projectThemeStyle(themeColor: string | null | undefined): CSSProperties {
const raw = validThemeColor(themeColor);
if (!raw) return {};
const accent = accessibleProjectAccent(raw);
return {
"--project-theme-color": raw,
"--color-primary": accent,
"--color-primary-hover": `color-mix(in srgb, ${accent} 86%, white)`,
"--color-on-primary": contrast(accent, "#0E1116") >= 4.5 ? "#0E1116" : "#FFFFFF",
"--color-canvas": `color-mix(in srgb, #0e1116 96%, ${raw})`,
"--color-surface": `color-mix(in srgb, #161b22 95%, ${raw})`,
"--color-raised": `color-mix(in srgb, #1c2430 94%, ${raw})`,
"--color-overlay": `color-mix(in srgb, #222c3a 94%, ${raw})`,
"--color-border": `color-mix(in srgb, #2b3440 88%, ${accent})`,
"--color-border-strong": accent,
} as CSSProperties;
}

View File

@ -610,6 +610,33 @@ describe("ProjectsView (with MockProjectGateway)", () => {
});
});
it("opens project settings from Panneaux and reuses the context editor", async () => {
const project = new MockProjectGateway();
await project.createProject("alpha", "/p/a");
renderView(project);
await screen.findByText("/p/a");
fireEvent.click(screen.getByRole("button", { name: "Open" }));
await screen.findByRole("tab", { name: "alpha" });
openPanel("Paramètres du projet");
expect(await screen.findByText("Apparence")).toBeTruthy();
expect(screen.getByLabelText("Couleur du projet")).toBeTruthy();
expect(screen.getByText("Contexte du projet")).toBeTruthy();
expect(screen.getByLabelText("project context")).toBeTruthy();
});
it("disables project settings while no project is active", async () => {
renderView(new MockProjectGateway());
await waitForIdle();
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
const item = screen.getByRole("button", { name: "Paramètres du projet" });
expect(item.hasAttribute("disabled")).toBe(true);
expect(item.getAttribute("title")).toBe(
"Ouvrez ou sélectionnez un projet pour accéder à ses paramètres.",
);
});
it("Browse… button calls pickFolder and fills the root field with the returned path", async () => {
const system = new MockSystemGateway();
renderView(new MockProjectGateway(), system);

View File

@ -23,6 +23,7 @@ import type { ViewWindowSnapshot } from "@/ports";
export type PanelId =
| "projects"
| "context"
| "projectSettings"
| "work"
| "tickets"
| "agents"
@ -50,6 +51,7 @@ export type ViewPlacements = Partial<Record<PanelId, ViewPlacement>>;
export const PANEL_TITLE: Record<PanelId, string> = {
projects: "Projects",
context: "Project context",
projectSettings: "Paramètres du projet",
work: "Work state",
tickets: "Tickets",
agents: "Agents",

View File

@ -26,13 +26,15 @@ import { ProfilesSettings } from "@/features/first-run";
import { DevicesScreen } from "@/features/devices";
import { PluginsPanel } from "@/features/plugins";
import { DeploymentSettings } from "./DeploymentSettings";
import { TerminalSettings } from "./TerminalSettings";
/** The Settings sections, in menu/nav order. */
export type SettingsSection = "aiProfiles" | "deployment" | "devices" | "plugins";
export type SettingsSection = "aiProfiles" | "terminal" | "deployment" | "devices" | "plugins";
/** Human labels, shared by the nav column and the `Settings` menu (ticket #78 — French). */
export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = {
aiProfiles: "Profils IA",
terminal: "Terminal",
deployment: "Déploiement",
devices: "Appareils",
plugins: "Plugins",
@ -41,6 +43,7 @@ export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = {
/** Section order — the single source of truth for both nav and menu. */
export const SETTINGS_SECTIONS: SettingsSection[] = [
"aiProfiles",
"terminal",
"deployment",
"devices",
"plugins",
@ -98,6 +101,8 @@ export function SettingsView({
<div className="w-full max-w-2xl">
{section === "aiProfiles" ? (
<ProfilesSettings />
) : section === "terminal" ? (
<TerminalSettings />
) : section === "deployment" ? (
<DeploymentSettings />
) : section === "plugins" ? (

View File

@ -0,0 +1,30 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import { createMockGateways } from "@/adapters/mock";
import { TerminalSettings } from "./TerminalSettings";
describe("TerminalSettings", () => {
it("loads and persists the exclusive global default without touching sessions", async () => {
const gateways = createMockGateways();
const update = vi.spyOn(gateways.userSettings, "updateUserSettings");
const close = vi.spyOn(gateways.terminal, "closeTerminal");
render(
<DIProvider gateways={gateways}>
<TerminalSettings />
</DIProvider>,
);
const native = (await screen.findByLabelText("TUI native")) as HTMLInputElement;
const cli = screen.getByLabelText("CLI IdeA") as HTMLInputElement;
await waitFor(() => expect(native.checked).toBe(true));
fireEvent.click(cli);
await waitFor(() =>
expect(update).toHaveBeenCalledWith({ defaultAgentTerminalInterface: "ideaCli" }),
);
expect(cli.checked).toBe(true);
expect(close).not.toHaveBeenCalled();
expect(screen.getByText("Utilisée à l'ouverture d'un nouveau terminal. Les terminaux déjà ouverts ne sont pas modifiés.")).toBeTruthy();
});
});

View File

@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import type { DefaultAgentTerminalInterface } from "@/ports";
import { Panel, Spinner } from "@/shared";
import { useGateways } from "@/app/di";
const choices: Array<{ value: DefaultAgentTerminalInterface; label: string }> = [
{ value: "nativeTui", label: "TUI native" },
{ value: "ideaCli", label: "CLI IdeA" },
];
export function TerminalSettings() {
const { userSettings } = useGateways();
const [value, setValue] = useState<DefaultAgentTerminalInterface>("nativeTui");
const [busy, setBusy] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setBusy(true);
userSettings
.readUserSettings()
.then((settings) => {
if (!cancelled) {
setValue(settings.defaultAgentTerminalInterface ?? "nativeTui");
setError(null);
}
})
.catch(() => {
if (!cancelled) setError("Impossible de charger la préférence de terminal.");
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, [userSettings]);
async function select(next: DefaultAgentTerminalInterface) {
const previous = value;
setValue(next);
setBusy(true);
setError(null);
try {
const saved = await userSettings.updateUserSettings({
defaultAgentTerminalInterface: next,
});
setValue(saved.defaultAgentTerminalInterface ?? "nativeTui");
} catch {
setValue(previous);
setError("Impossible d’enregistrer la préférence de terminal.");
} finally {
setBusy(false);
}
}
return (
<Panel title="Terminal" className="flex flex-col gap-4 p-4">
<fieldset disabled={busy} className="flex flex-col gap-3">
<legend className="text-sm font-semibold text-content">
Interface de terminal par défaut
</legend>
<div className="flex flex-col gap-2">
{choices.map((choice) => (
<label key={choice.value} className="flex items-center gap-2 text-sm text-content">
<input
type="radio"
name="default-agent-terminal-interface"
value={choice.value}
checked={value === choice.value}
onChange={() => void select(choice.value)}
/>
{choice.label}
</label>
))}
</div>
<p className="text-xs text-muted">
Utilisée à l'ouverture d'un nouveau terminal. Les terminaux déjà ouverts ne sont pas modifiés.
</p>
</fieldset>
{busy && <Spinner size={14} />}
{error && <p role="alert" className="text-sm text-danger">{error}</p>}
</Panel>
);
}

View File

@ -7,5 +7,6 @@ export {
type SettingsSection,
} from "./SettingsView";
export { DeploymentSettings } from "./DeploymentSettings";
export { TerminalSettings } from "./TerminalSettings";
export { useDeployment } from "./useDeployment";
export type { DeploymentVm } from "./useDeployment";

View File

@ -19,7 +19,7 @@ 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, Panel, Spinner, cn } from "@/shared";
import { Button, EntryTimestamp, Panel, Spinner, cn } from "@/shared";
import { useProjectWorkState } from "./useProjectWorkState";
export interface ProjectWorkStatePanelProps {
@ -307,7 +307,10 @@ function ConversationSummaryLine({
<ul className="space-y-0.5">
{(summary.recentTurns ?? []).map((turn) => (
<li key={`${turn.atMs}-${turn.role}-${turn.textPreview}`}>
<span className="font-medium text-content">{turn.role}:</span>{" "}
<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>
))}
@ -659,7 +662,7 @@ function AgentRow({
}
async function stop(): Promise<void> {
if (!agent.live || !agentGateway?.stopLiveAgent) return;
if (!agent.live || !agentGateway) return;
const warning = currentTurn
? " The current turn will be interrupted."
: "";
@ -667,10 +670,15 @@ function AgentRow({
setActionBusy(true);
setMessage(null);
try {
const stopped = await agentGateway.stopLiveAgent(projectId, agent.agentId);
const stopped = await agentGateway.stopLiveAgent(
projectId,
agent.agentId,
"Arrêt demandé depuis Travail",
);
if (visibleNodeIds.has(agent.live.nodeId)) {
const leaf = visibleLeaves.find((candidate) => candidate.id === agent.live?.nodeId);
if (!leaf?.session || leaf.session === stopped.sessionId) {
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);
}
}
@ -763,7 +771,7 @@ function AgentRow({
size="sm"
variant="danger"
loading={actionBusy}
disabled={!agentGateway?.stopLiveAgent}
disabled={!agentGateway}
onClick={() => void stop()}
>
Stop

View File

@ -1006,7 +1006,13 @@ describe("ProjectWorkStatePanel", () => {
"Stop Runner? The current turn will be interrupted.",
),
);
await waitFor(() => expect(stopSpy).toHaveBeenCalledWith(PROJECT_ID, created.id));
await waitFor(() =>
expect(stopSpy).toHaveBeenCalledWith(
PROJECT_ID,
created.id,
"Arrêt demandé depuis Travail",
),
);
await waitFor(() => expect(refreshSpy).toHaveBeenCalled());
await waitFor(async () => {
const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0];