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),
|
||||
};
|
||||
|
||||
@ -558,7 +558,7 @@ export type DomainEvent =
|
||||
target: string;
|
||||
ticketId: string;
|
||||
text: string;
|
||||
atMs: number;
|
||||
atMs: number | null;
|
||||
}
|
||||
| { type: "ptyOutput"; sessionId: string; bytes: number[] };
|
||||
|
||||
@ -751,7 +751,7 @@ export type ConversationPreviewStatus =
|
||||
export interface ConversationTurnWorkPreview {
|
||||
role: "prompt" | "response" | "toolActivity";
|
||||
source: TicketWorkSource;
|
||||
atMs: number;
|
||||
atMs: number | null;
|
||||
textPreview: string;
|
||||
textLen: number;
|
||||
}
|
||||
@ -800,7 +800,7 @@ export type TurnSource = { kind: "human" } | { kind: "agent"; agentId: string };
|
||||
/** One turn of the human transcript — full text, never truncated. */
|
||||
export interface TurnView {
|
||||
id: string;
|
||||
atMs: number;
|
||||
atMs: number | null;
|
||||
role: TurnRole;
|
||||
source: TurnSource;
|
||||
text: string;
|
||||
@ -1792,7 +1792,7 @@ export type ReplyProgressStage = "started" | "delta" | "completed" | "info";
|
||||
|
||||
export interface ReplyProgress {
|
||||
progressId?: string;
|
||||
occurredAtMs?: number;
|
||||
occurredAtMs?: number | null;
|
||||
source: ReplyProgressSource;
|
||||
kind: ReplyProgressKind;
|
||||
stage: ReplyProgressStage;
|
||||
@ -1817,12 +1817,13 @@ export type ReplyChunk =
|
||||
text: string;
|
||||
attachments?: string[];
|
||||
attachmentRefs?: ChatPromptAttachment[];
|
||||
atMs?: number | null;
|
||||
}
|
||||
| { kind: "textDelta"; text: string }
|
||||
| { kind: "toolActivity"; label: string }
|
||||
| { kind: "textDelta"; text: string; atMs?: number | null }
|
||||
| { kind: "toolActivity"; label: string; atMs?: number | null }
|
||||
| { kind: "progress"; progress: ReplyProgress }
|
||||
| { kind: "final"; content: string }
|
||||
| { kind: "error"; message: string };
|
||||
| { kind: "final"; content: string; atMs?: number | null }
|
||||
| { kind: "error"; message: string; atMs?: number | null };
|
||||
|
||||
export type SlashCommandAvailability =
|
||||
| { status: "available" }
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
}}
|
||||
|
||||
39
frontend/src/features/projects/ProjectSettingsPanel.test.tsx
Normal file
39
frontend/src/features/projects/ProjectSettingsPanel.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
91
frontend/src/features/projects/ProjectSettingsPanel.tsx
Normal file
91
frontend/src/features/projects/ProjectSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
59
frontend/src/features/projects/projectTheme.ts
Normal file
59
frontend/src/features/projects/projectTheme.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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" ? (
|
||||
|
||||
30
frontend/src/features/settings/TerminalSettings.test.tsx
Normal file
30
frontend/src/features/settings/TerminalSettings.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
86
frontend/src/features/settings/TerminalSettings.tsx
Normal file
86
frontend/src/features/settings/TerminalSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -71,6 +71,8 @@ function gateways(overrides: {
|
||||
readOpenProjectRestoreState: vi.fn(async () => ({ version: 1, entries: [], updatedAt: 0 })),
|
||||
readProjectContext: vi.fn(async () => "project context"),
|
||||
updateProjectContext: vi.fn(),
|
||||
readProjectSettings: vi.fn(async () => ({ themeColor: null })),
|
||||
updateProjectSettings: vi.fn(async (_projectId, settings) => settings),
|
||||
listProjectFiles: vi.fn(async (_projectId, path) => ({ path, entries: [] })),
|
||||
downloadProjectFile: vi.fn(),
|
||||
downloadProjectDirectory: vi.fn(),
|
||||
|
||||
@ -257,7 +257,7 @@ export interface AgentGateway {
|
||||
* Stops an already-live agent session without going through terminal teardown
|
||||
* commands from the Work panel. The backend owns the live-agent lifecycle.
|
||||
*/
|
||||
stopLiveAgent?(projectId: string, agentId: string): Promise<StoppedLiveAgent>;
|
||||
stopLiveAgent(projectId: string, agentId: string, reason?: string): Promise<StoppedLiveAgent>;
|
||||
/** Creates a new agent from scratch; returns the created agent. */
|
||||
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>;
|
||||
/**
|
||||
@ -435,9 +435,30 @@ export interface LiveAgent {
|
||||
/** Result returned when the backend stops a live agent. */
|
||||
export interface StoppedLiveAgent {
|
||||
agentId: string;
|
||||
stopped: StoppedAgentRuntime[];
|
||||
alreadyStopped: boolean;
|
||||
}
|
||||
|
||||
export interface StoppedAgentRuntime {
|
||||
sessionId: string;
|
||||
nodeId: string;
|
||||
kind: "pty" | "structured";
|
||||
runtimeState?: AgentRuntimeState;
|
||||
runtimeState: AgentRuntimeState;
|
||||
}
|
||||
|
||||
export type DefaultAgentTerminalInterface = "nativeTui" | "ideaCli";
|
||||
|
||||
export interface UserSettings {
|
||||
defaultAgentTerminalInterface: DefaultAgentTerminalInterface | null;
|
||||
}
|
||||
|
||||
export interface UserSettingsGateway {
|
||||
readUserSettings(): Promise<UserSettings>;
|
||||
updateUserSettings(settings: UserSettings): Promise<UserSettings>;
|
||||
}
|
||||
|
||||
export interface ProjectSettings {
|
||||
themeColor: string | null;
|
||||
}
|
||||
|
||||
export type AgentRuntimeState =
|
||||
@ -596,6 +617,10 @@ export interface ProjectGateway {
|
||||
readProjectContext(projectId: string): Promise<string>;
|
||||
/** Overwrites the shared project context stored at `.ideai/CONTEXT.md`. */
|
||||
updateProjectContext(projectId: string, content: string): Promise<void>;
|
||||
/** Reads project-owned settings. */
|
||||
readProjectSettings(projectId: string): Promise<ProjectSettings>;
|
||||
/** Replaces project-owned settings. */
|
||||
updateProjectSettings(projectId: string, settings: ProjectSettings): Promise<ProjectSettings>;
|
||||
/** Lists one project-relative directory for the web project files surface. */
|
||||
listProjectFiles(projectId: string, path: string): Promise<ProjectFileListing>;
|
||||
/** Downloads one project-relative file. */
|
||||
@ -1784,6 +1809,7 @@ export interface Gateways {
|
||||
window: WindowGateway;
|
||||
focusedProject: FocusedProjectGateway;
|
||||
uiPreferences: UiPreferencesGateway;
|
||||
userSettings: UserSettingsGateway;
|
||||
plugin: PluginGateway;
|
||||
pluginWorkspace: PluginWorkspaceGateway;
|
||||
pluginTask: PluginTaskGateway;
|
||||
|
||||
@ -32,6 +32,13 @@ export type { ToolbarProps } from "./ui/Toolbar";
|
||||
export { Spinner } from "./ui/Spinner";
|
||||
export type { SpinnerProps } from "./ui/Spinner";
|
||||
|
||||
export {
|
||||
EntryTimestamp,
|
||||
formatFullLocalTime,
|
||||
formatShortLocalTime,
|
||||
normalizeTimestamp,
|
||||
} from "./ui/EntryTimestamp";
|
||||
|
||||
export { zIndex } from "./ui/zIndex";
|
||||
export type { ZIndexLevel } from "./ui/zIndex";
|
||||
|
||||
|
||||
19
frontend/src/shared/ui/EntryTimestamp.test.tsx
Normal file
19
frontend/src/shared/ui/EntryTimestamp.test.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { EntryTimestamp, formatShortLocalTime } from "./EntryTimestamp";
|
||||
|
||||
describe("EntryTimestamp", () => {
|
||||
it("shows a stable local HH:mm and exposes the full zoned date", () => {
|
||||
const atMs = Date.UTC(2026, 8, 10, 12, 37);
|
||||
render(<EntryTimestamp atMs={atMs} />);
|
||||
const timestamp = screen.getByText(formatShortLocalTime(atMs)!);
|
||||
expect(timestamp.getAttribute("title")).toBeTruthy();
|
||||
expect(timestamp.getAttribute("tabindex")).toBe("0");
|
||||
});
|
||||
|
||||
it("does not invent a timestamp for legacy entries", () => {
|
||||
render(<EntryTimestamp atMs={null} />);
|
||||
expect(screen.getByLabelText("Heure indisponible").textContent).toBe("—");
|
||||
});
|
||||
});
|
||||
52
frontend/src/shared/ui/EntryTimestamp.tsx
Normal file
52
frontend/src/shared/ui/EntryTimestamp.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export function normalizeTimestamp(atMs: number | null | undefined): number | null {
|
||||
return typeof atMs === "number" && Number.isFinite(atMs) && atMs > 0 && !Number.isNaN(new Date(atMs).getTime())
|
||||
? atMs
|
||||
: null;
|
||||
}
|
||||
|
||||
export function formatShortLocalTime(atMs: number | null | undefined): string | null {
|
||||
const value = normalizeTimestamp(atMs);
|
||||
if (value === null) return null;
|
||||
const parts = new Intl.DateTimeFormat(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(new Date(value));
|
||||
const hour = parts.find((part) => part.type === "hour")?.value;
|
||||
const minute = parts.find((part) => part.type === "minute")?.value;
|
||||
return hour && minute ? `${hour}:${minute}` : null;
|
||||
}
|
||||
|
||||
export function formatFullLocalTime(atMs: number | null | undefined): string {
|
||||
const value = normalizeTimestamp(atMs);
|
||||
if (value === null) return "Heure indisponible";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "full",
|
||||
timeStyle: "long",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function EntryTimestamp({
|
||||
atMs,
|
||||
className,
|
||||
}: {
|
||||
atMs: number | null | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const value = normalizeTimestamp(atMs);
|
||||
const short = formatShortLocalTime(value);
|
||||
const full = formatFullLocalTime(value);
|
||||
return (
|
||||
<time
|
||||
dateTime={value === null ? undefined : new Date(value).toISOString()}
|
||||
title={full}
|
||||
aria-label={full}
|
||||
tabIndex={0}
|
||||
className={cn("shrink-0 text-faint", className)}
|
||||
>
|
||||
{short ?? "—"}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
@ -21,6 +21,7 @@ export interface MenuBarItem {
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
/** Renders a selected/active marker (e.g. the currently-open panel). */
|
||||
active?: boolean;
|
||||
/**
|
||||
@ -124,6 +125,7 @@ export function MenuBar({ menus, className }: MenuBarProps) {
|
||||
<button
|
||||
type="button"
|
||||
disabled={item.disabled}
|
||||
title={item.title}
|
||||
aria-haspopup={hasSub ? "menu" : undefined}
|
||||
aria-expanded={hasSub ? subOpen : undefined}
|
||||
onClick={() => {
|
||||
|
||||
@ -16,6 +16,9 @@ export interface TabItem {
|
||||
label: string;
|
||||
/** Optional leading visual, such as a status indicator. */
|
||||
leading?: ReactNode;
|
||||
/** Optional project colour used only for this tab's subtle neutral tint. */
|
||||
themeColor?: string | null;
|
||||
themeAccent?: string | null;
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
@ -42,6 +45,14 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
style={
|
||||
tab.themeColor
|
||||
? {
|
||||
backgroundColor: `color-mix(in srgb, var(--color-raised) ${active ? 90 : 96}%, ${tab.themeColor})`,
|
||||
...(active ? { borderColor: tab.themeAccent ?? tab.themeColor } : {}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md border px-1 transition-colors",
|
||||
active
|
||||
|
||||
Reference in New Issue
Block a user