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:
@ -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 () => {
|
||||
|
||||
@ -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 } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -42,6 +42,7 @@ describe("createMockGateways", () => {
|
||||
"terminal",
|
||||
"ticket",
|
||||
"uiPreferences",
|
||||
"userSettings",
|
||||
"window",
|
||||
"workState",
|
||||
]);
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
|
||||
13
frontend/src/adapters/userSettings.ts
Normal file
13
frontend/src/adapters/userSettings.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user