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

@ -82,14 +82,19 @@ describe("TauriAgentGateway invoke payloads", () => {
it("stop_live_agent wraps project and agent in the request DTO", async () => {
invoke.mockResolvedValueOnce({
agentId: "agent-2",
sessionId: "session-4",
kind: "pty",
stopped: [{
sessionId: "session-4",
nodeId: "node-3",
kind: "pty",
runtimeState: "stopped",
}],
alreadyStopped: false,
});
const out = await new TauriAgentGateway().stopLiveAgent("proj-1", "agent-2");
expect(invoke).toHaveBeenCalledWith("stop_live_agent", {
request: { projectId: "proj-1", agentId: "agent-2" },
});
expect(out.sessionId).toBe("session-4");
expect(out.stopped[0]?.sessionId).toBe("session-4");
});
it("change_agent_profile wraps all five fields in the request DTO (camelCase)", async () => {

View File

@ -98,9 +98,9 @@ export class TauriAgentGateway implements AgentGateway {
});
}
stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
stopLiveAgent(projectId: string, agentId: string, reason?: string): Promise<StoppedLiveAgent> {
return invoke<StoppedLiveAgent>("stop_live_agent", {
request: { projectId, agentId },
request: { projectId, agentId, ...(reason ? { reason } : {}) },
});
}

View File

@ -47,7 +47,7 @@ function normalizeTurn(value: unknown, index: number): TurnView {
const text = stringValue(turn.text);
return {
id: stringValue(turn.id, `turn-${index}`),
atMs: numberValue(turn.atMs, index),
atMs: typeof turn.atMs === "number" && Number.isFinite(turn.atMs) ? turn.atMs : null,
role: normalizeRole(turn.role),
source: normalizeSource(turn.source),
text,

View File

@ -14,7 +14,7 @@
* error or an inert stub, never a Tauri call.
*/
import type { Gateways } from "@/ports";
import type { Gateways, UserSettings, UserSettingsGateway } from "@/ports";
import { LocalStorageUiPreferencesGateway } from "../uiPreferences";
import { HttpInvoker } from "./httpInvoker";
import { WsLiveClient } from "./wsLiveClient";
@ -146,6 +146,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
focusedProject: new WebFocusedProjectGateway(),
// Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is.
uiPreferences: new LocalStorageUiPreferencesGateway(),
userSettings: new HttpUserSettingsGateway(http),
plugin: new WebPluginGateway(),
pluginWorkspace: new WebPluginWorkspaceGateway(),
pluginTask: new WebPluginTaskGateway(),
@ -156,6 +157,18 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
};
}
class HttpUserSettingsGateway implements UserSettingsGateway {
constructor(private readonly http: HttpInvoker) {}
readUserSettings(): Promise<UserSettings> {
return this.http.invoke<UserSettings>("read_user_settings");
}
updateUserSettings(settings: UserSettings): Promise<UserSettings> {
return this.http.invoke<UserSettings>("update_user_settings", { request: settings });
}
}
export { HttpInvoker } from "./httpInvoker";
export { WsLiveClient } from "./wsLiveClient";
export { WebSession, getWebSession, setWebSessionForTests } from "./webSession";

View File

@ -73,6 +73,7 @@ import type {
PermissionGateway,
ProfileGateway,
ProjectGateway,
ProjectSettings,
SaveOpenCodeProviderProfileInput,
SkillGateway,
TemplateGateway,
@ -134,6 +135,14 @@ export class HttpProjectGateway implements ProjectGateway {
async updateProjectContext(projectId: string, content: string): Promise<void> {
await this.http.invoke("update_project_context", { request: { projectId, content } });
}
readProjectSettings(projectId: string): Promise<ProjectSettings> {
return this.http.invoke<ProjectSettings>("read_project_settings", { projectId });
}
updateProjectSettings(projectId: string, settings: ProjectSettings): Promise<ProjectSettings> {
return this.http.invoke<ProjectSettings>("update_project_settings", {
request: { projectId, themeColor: settings.themeColor },
});
}
listProjectFiles(projectId: string, path: string): Promise<ProjectFileListing> {
return this.http.request<ProjectFileListing>(
"GET",

View File

@ -216,8 +216,10 @@ export class HttpAgentGateway implements AgentGateway {
attachLiveAgent(projectId: string, agentId: string, nodeId: string): Promise<LiveAgent> {
return this.http.invoke<LiveAgent>("attach_live_agent", { request: { projectId, agentId, nodeId } });
}
stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
return this.http.invoke<StoppedLiveAgent>("stop_live_agent", { request: { projectId, agentId } });
stopLiveAgent(projectId: string, agentId: string, reason?: string): Promise<StoppedLiveAgent> {
return this.http.invoke<StoppedLiveAgent>("stop_live_agent", {
request: { projectId, agentId, ...(reason ? { reason } : {}) },
});
}
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
return this.http.invoke<Agent>("create_agent", {

View File

@ -34,6 +34,7 @@ import { TauriTicketGateway } from "./ticket";
import { TauriWindowGateway } from "./window";
import { TauriFocusedProjectGateway } from "./focusedProject";
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
import { TauriUserSettingsGateway } from "./userSettings";
import { TauriPluginGateway } from "./plugin";
import { TauriPluginWorkspaceGateway } from "./pluginWorkspace";
import { TauriPluginTaskGateway } from "./pluginTask";
@ -82,6 +83,7 @@ export function createTauriGateways(): Gateways {
window: new TauriWindowGateway(),
focusedProject: new TauriFocusedProjectGateway(),
uiPreferences: new LocalStorageUiPreferencesGateway(),
userSettings: new TauriUserSettingsGateway(),
plugin: new TauriPluginGateway(),
pluginWorkspace: new TauriPluginWorkspaceGateway(),
pluginTask: new TauriPluginTaskGateway(),
@ -115,6 +117,7 @@ export {
TauriWindowGateway,
TauriFocusedProjectGateway,
LocalStorageUiPreferencesGateway,
TauriUserSettingsGateway,
TauriPluginGateway,
TauriPluginStorageGateway,
};

View File

@ -136,6 +136,7 @@ import type {
OpenTerminalOptions,
ProfileGateway,
ProjectGateway,
ProjectSettings,
PermissionGateway,
PluginConfigDocumentReadInput,
PluginConfigDocumentUpdateInput,
@ -178,6 +179,8 @@ import type {
CreateTicketInput,
UpdateTicketInput,
UiPreferencesGateway,
UserSettings,
UserSettingsGateway,
ViewWindowClosed,
ViewWindowSnapshot,
WindowGateway,
@ -414,7 +417,6 @@ export class MockAgentGateway implements AgentGateway {
/** Runtime kind per live agent (`agentId → kind`). */
private liveKindByAgent = new Map<string, "pty" | "structured">();
/** Last stopped runtime per agent for idempotent stop. */
private stoppedByAgent = new Map<string, StoppedLiveAgent>();
/** Retained structured reply chunks per live chat session. */
private chatScrollback = new Map<string, ReplyChunk[]>();
/** Persisted custom chat drafts keyed by project/layout/node. */
@ -539,14 +541,9 @@ export class MockAgentGateway implements AgentGateway {
}
const sessionId = this.liveSessionByAgent.get(agentId);
if (!sessionId) {
const stopped = this.stoppedByAgent.get(agentId);
if (stopped) return stopped;
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} has no live session`,
};
throw err;
return { agentId, stopped: [], alreadyStopped: true };
}
const nodeId = this.liveByAgent.get(agentId) ?? "mock-node";
const session = this.sessions.get(sessionId);
if (session) session.closed = true;
this.sessions.delete(sessionId);
@ -557,11 +554,14 @@ export class MockAgentGateway implements AgentGateway {
this.liveKindByAgent.delete(agentId);
const stopped: StoppedLiveAgent = {
agentId,
sessionId,
kind,
runtimeState: "stopped",
stopped: [{
sessionId,
nodeId,
kind,
runtimeState: "stopped",
}],
alreadyStopped: false,
};
this.stoppedByAgent.set(agentId, stopped);
return stopped;
}
@ -842,7 +842,6 @@ export class MockAgentGateway implements AgentGateway {
this.liveByAgent.set(agentId, options.nodeId);
this.liveSessionByAgent.set(agentId, sessionId);
this.liveKindByAgent.set(agentId, "pty");
this.stoppedByAgent.delete(agentId);
}
const clearLive = () => {
if (this.liveByAgent.get(agentId) === options.nodeId) {
@ -910,7 +909,6 @@ export class MockAgentGateway implements AgentGateway {
this.liveByAgent.set(agentId, options.nodeId);
this.liveSessionByAgent.set(agentId, sessionId);
this.liveKindByAgent.set(agentId, "structured");
this.stoppedByAgent.delete(agentId);
}
return {
sessionId,
@ -1251,6 +1249,7 @@ export class MockTerminalGateway implements TerminalGateway {
export class MockProjectGateway implements ProjectGateway {
private projects: Project[] = [];
private contexts = new Map<string, string>();
private settings = new Map<string, ProjectSettings>();
private restoreProjectIds: string[] = [];
private restoreFailures = new Map<string, OpenProjectRestoreFailure>();
@ -1426,6 +1425,28 @@ export class MockProjectGateway implements ProjectGateway {
this.contexts.set(projectId, content);
}
async readProjectSettings(projectId: string): Promise<ProjectSettings> {
if (!this.projects.some((project) => project.id === projectId)) {
throw { code: "NOT_FOUND", message: `project ${projectId} not found` };
}
return this.settings.get(projectId) ?? { themeColor: null };
}
async updateProjectSettings(
projectId: string,
settings: ProjectSettings,
): Promise<ProjectSettings> {
if (!this.projects.some((project) => project.id === projectId)) {
throw { code: "NOT_FOUND", message: `project ${projectId} not found` };
}
if (settings.themeColor !== null && !/^#[0-9A-Fa-f]{6}$/.test(settings.themeColor)) {
throw { code: "INVALID", message: "themeColor must use #RRGGBB" };
}
const stored = { themeColor: settings.themeColor?.toUpperCase() ?? null };
this.settings.set(projectId, stored);
return stored;
}
async listProjectFiles(_projectId: string, path: string): Promise<ProjectFileListing> {
return { path, entries: [] };
}
@ -4838,6 +4859,7 @@ export function createMockGateways(): Gateways {
window: new MockWindowGateway(),
focusedProject: new MockFocusedProjectGateway(),
uiPreferences: new MockUiPreferencesGateway(),
userSettings: new MockUserSettingsGateway(),
plugin: new MockPluginGateway(),
pluginWorkspace: new MockPluginWorkspaceGateway(),
pluginTask: new MockPluginTaskGateway(),
@ -4848,5 +4870,18 @@ export function createMockGateways(): Gateways {
};
}
export class MockUserSettingsGateway implements UserSettingsGateway {
private settings: UserSettings = { defaultAgentTerminalInterface: null };
async readUserSettings(): Promise<UserSettings> {
return { ...this.settings };
}
async updateUserSettings(settings: UserSettings): Promise<UserSettings> {
this.settings = { ...settings };
return { ...this.settings };
}
}
// Re-export GatewayError so tests can reference the thrown shape.
export type { GatewayError };

View File

@ -42,6 +42,7 @@ describe("createMockGateways", () => {
"terminal",
"ticket",
"uiPreferences",
"userSettings",
"window",
"workState",
]);

View File

@ -15,7 +15,7 @@ import type {
RemoveProjectOutput,
RestoreOpenProjectsOutput,
} from "@/domain";
import type { ProjectGateway } from "@/ports";
import type { ProjectGateway, ProjectSettings } from "@/ports";
export class TauriProjectGateway implements ProjectGateway {
listProjects(): Promise<Project[]> {
@ -78,6 +78,19 @@ export class TauriProjectGateway implements ProjectGateway {
});
}
readProjectSettings(projectId: string): Promise<ProjectSettings> {
return invoke<ProjectSettings>("read_project_settings", { projectId });
}
updateProjectSettings(
projectId: string,
settings: ProjectSettings,
): Promise<ProjectSettings> {
return invoke<ProjectSettings>("update_project_settings", {
request: { projectId, themeColor: settings.themeColor },
});
}
async listProjectFiles(): Promise<ProjectFileListing> {
throw new Error("Project files browsing is available in the web workspace only.");
}

View File

@ -0,0 +1,13 @@
import { invoke } from "@tauri-apps/api/core";
import type { UserSettings, UserSettingsGateway } from "@/ports";
export class TauriUserSettingsGateway implements UserSettingsGateway {
readUserSettings(): Promise<UserSettings> {
return invoke<UserSettings>("read_user_settings");
}
updateUserSettings(settings: UserSettings): Promise<UserSettings> {
return invoke<UserSettings>("update_user_settings", { request: settings });
}
}

View File

@ -220,7 +220,7 @@ function normalizeSummaryStatus(value: unknown): ConversationPreviewStatus {
return "missing";
}
function normalizeTurn(value: unknown, index: number): ConversationTurnWorkPreview {
function normalizeTurn(value: unknown): ConversationTurnWorkPreview {
const turn = isRecord(value) ? value : {};
const textPreview = stringValue(turn.textPreview);
const role =
@ -230,7 +230,7 @@ function normalizeTurn(value: unknown, index: number): ConversationTurnWorkPrevi
return {
role,
source: normalizeSource(turn.source),
atMs: numberValue(turn.atMs, index),
atMs: nullableNumber(turn.atMs),
textPreview,
textLen: numberValue(turn.textLen, textPreview.length),
};