Files
IdeA/frontend/src/ports/index.ts
Blomios b82e3e1a40 feat(agent): UI hot-swap de profil (A2) — commande Tauri + sélecteur + dialog
- Tauri : commande change_agent_profile + ChangeAgentProfileRequestDto/Dto
  (camelCase, relaunchedSession omis si None), câblage state.rs par composition.
- Front : gateway changeAgentProfile (adapters Tauri+mock), sélecteur de profil
  par agent, dialog de confirmation FR (« Changer le moteur abandonne l'historique
  de conversation… »), refresh sur event agentProfileChanged.

Tests : app-tauri dto 5/5 ; vitest 314/314 (mapping, payload, dialog gating,
libellé FR, refresh). 0 régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:07:35 +02:00

543 lines
21 KiB
TypeScript

/**
* UI ports (gateways) — interfaces describing *what the UI needs*, independent
* of transport (ARCHITECTURE §1.3). React components depend on these, never on
* `@tauri-apps/api` directly. Implemented by the Tauri adapters and by mocks.
*
* Signatures are intentionally minimal/skeletal for L1; later lots flesh out
* each gateway as their use cases land. They are aligned with the planned use
* cases (ARCHITECTURE §6) so the shape is stable.
*/
import type {
Agent,
AgentDrift,
AgentProfile,
DomainEvent,
EmbedderEngines,
EmbedderProfile,
FirstRunState,
GitBranches,
GitCommit,
GitFileStatus,
GraphCommit,
HealthReport,
LayoutKind,
LayoutList,
LayoutOperation,
LayoutTree,
Memory,
MemoryIndexEntry,
MemoryLink,
MemoryType,
Project,
ProfileAvailability,
Skill,
SkillScope,
Template,
TerminalSession,
Unsubscribe,
} from "@/domain";
/** System-level gateway: health/ping + global domain-event subscription. */
export interface SystemGateway {
/** Calls the backend `health` command (smoke test of the whole pipeline). */
health(note?: string): Promise<HealthReport>;
/** Subscribes to relayed domain events. */
onDomainEvent(handler: (event: DomainEvent) => void): Promise<Unsubscribe>;
/**
* Opens a native folder picker and returns the chosen path, or `null` if the
* user cancelled. This is the only sanctioned way to pick a folder — all call
* sites go through this port; the Tauri plugin is only imported in the adapter.
*/
pickFolder(): Promise<string | null>;
}
/** Input for {@link AgentGateway.createAgent}. */
export interface CreateAgentInput {
name: string;
profileId: string;
initialContent?: string;
}
/**
* Best-effort enriched details about a CLI conversation (T7), used to enrich the
* resume popup. Both fields are optional: a missing inspector or a missing
* transcript yields an empty object — the popup degrades to the status alone.
*/
export interface ConversationDetails {
/** A short, best-effort label for the conversation (last topic). */
lastTopic?: string;
/** A best-effort cumulative token count. */
tokenCount?: number;
}
/** Agents: create, list, read/update context, delete, launch (L6). */
export interface AgentGateway {
/** Lists all agents belonging to the given project. */
listAgents(projectId: string): Promise<Agent[]>;
/**
* Lists the agents that currently own a live session and the cell hosting
* each. Used to disable an agent already running in another cell (it cannot be
* launched a second time — one live session per agent).
*/
listLiveAgents(projectId: string): Promise<LiveAgent[]>;
/**
* Rebinds an already-live agent session to another visible layout cell without
* respawning the CLI process. Used when a background agent is opened in a cell,
* or when a live session is moved from a now-detached/closed cell.
*
* Backends that do not yet support agent-session rebinding may omit this
* method; the UI will show the session as running elsewhere but cannot attach
* it from a different cell.
*/
attachLiveAgent?(
projectId: string,
agentId: string,
nodeId: string,
): Promise<LiveAgent>;
/** Creates a new agent from scratch; returns the created agent. */
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>;
/**
* Hot-swaps an agent's runtime AI profile (Chantier A). The conversation
* history is abandoned (the product decision is "start fresh"); the agent's
* context `.md` and project memory are preserved. When the agent has a live
* session it is relaunched on the new profile and returned as
* {@link TerminalSession} so the hosting cell can rebind; otherwise
* `relaunchedSession` is absent.
*/
changeAgentProfile(
projectId: string,
agentId: string,
profileId: string,
rows: number,
cols: number,
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }>;
/** Reads an agent's `.md` context by agent id. */
readContext(projectId: string, agentId: string): Promise<string>;
/** Overwrites an agent's `.md` context. */
updateContext(projectId: string, agentId: string, content: string): Promise<void>;
/** Removes an agent from the project. */
deleteAgent(projectId: string, agentId: string): Promise<void>;
/**
* Launches the agent: opens a PTY, spawns the CLI, and wires the output stream.
* Mirrors {@link TerminalGateway.openTerminal} but invokes `launch_agent`.
*
* The returned handle carries an optional {@link TerminalHandle.assignedConversationId}:
* when the agent's profile assigns a fresh CLI conversation id on this first
* launch (the cell had none yet), it is surfaced here so the caller can persist
* it on the hosting leaf (`setCellConversation`) and resume on the next open.
* Pass the leaf's current `conversationId` via {@link OpenTerminalOptions} to
* resume an existing conversation instead.
*/
launchAgent(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle>;
/**
* Re-attaches to an agent's already-running PTY (same backend mechanism as
* {@link TerminalGateway.reattach}; agent sessions share the session-based
* terminal commands). Used when an agent cell's view re-mounts after a
* navigation/layout change, so the agent is never killed.
*/
reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult>;
/**
* Reads best-effort {@link ConversationDetails} for an agent's conversation
* (T7), to enrich the resume popup with the last topic + a token indicator.
* Best-effort by contract: a missing/unsupported inspector or a missing
* transcript resolves to an empty object (never rejects for "no details").
*/
inspectConversation(
projectId: string,
agentId: string,
conversationId: string,
): Promise<ConversationDetails>;
}
/** Options for opening a terminal. */
export interface OpenTerminalOptions {
/** Working directory (typically the project root). */
cwd: string;
/** Initial terminal height in rows. */
rows: number;
/** Initial terminal width in columns. */
cols: number;
/**
* Persistent CLI conversation id recorded on the hosting cell, if any. When
* present, an agent launch **resumes** that conversation; when absent the
* launch may *assign* a fresh id (surfaced on the returned handle). Only
* meaningful for {@link AgentGateway.launchAgent}; ignored for plain terminals.
*/
conversationId?: string;
/**
* The layout leaf (node) hosting this launch. Drives the "one live session per
* agent" invariant backend-side: launching an agent already running in a
* *different* node is refused (`AGENT_ALREADY_RUNNING`); the same node is
* idempotent. Only meaningful for {@link AgentGateway.launchAgent}.
*/
nodeId?: string;
}
/** One currently-live agent and the cell hosting it (see {@link AgentGateway.listLiveAgents}). */
export interface LiveAgent {
/** The live agent's id. */
agentId: string;
/** The node (layout leaf) hosting the agent's live session. */
nodeId: string;
/**
* The live PTY session id, when the backend exposes it. Required for opening
* a background/running agent in a new visible cell without respawning it.
*/
sessionId?: string;
}
/**
* A live terminal handle returned by {@link TerminalGateway.openTerminal}.
*
* The output stream is delivered to the `onData` callback passed at open time
* (over a Tauri Channel in the real adapter); the handle exposes the input/
* control operations and a `close` that also tears the stream down.
*/
export interface TerminalHandle {
/** Stable session id (UUID) used by the backend for this PTY. */
readonly sessionId: string;
/**
* Conversation id **assigned** by an agent launch when the profile minted a
* fresh one (the cell had none yet). Present only on handles returned by
* {@link AgentGateway.launchAgent}; `undefined` for plain terminals, resumes,
* or profiles without a session block. The caller persists it on the hosting
* leaf so the next open resumes instead of re-assigning.
*/
readonly assignedConversationId?: string;
/** Sends bytes (xterm keystrokes) to the PTY. */
write(data: Uint8Array): Promise<void>;
/** Resizes the PTY. */
resize(rows: number, cols: number): Promise<void>;
/**
* Detaches the **view** from the PTY without killing it: stops the local
* output subscription so a torn-down view (navigation / layout change) stops
* receiving bytes, while the backend PTY keeps running. The session can later
* be re-attached via {@link TerminalGateway.reattach}.
*
* This is the lifecycle the view's cleanup must use — never {@link close}.
*/
detach(): void;
/**
* Kills the PTY and stops the output stream. Reserved for an **explicit** user
* action (closing the terminal); navigation must never call this.
*/
close(): Promise<void>;
}
/**
* Terminals (L3): open a PTY with a per-session output stream, then write/
* resize/close it through the returned {@link TerminalHandle}.
*/
export interface TerminalGateway {
/**
* Opens a terminal. `onData` receives every chunk of PTY output (bytes) as it
* arrives. Resolves once the PTY is spawned and the stream is wired.
*/
openTerminal(
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle>;
/**
* Re-attaches to an **already-running** PTY identified by `sessionId` (after a
* view was torn down by navigation/layout change). Returns the live handle and
* the retained scrollback, which the caller repaints into xterm before the new
* output stream (`onData`) starts delivering subsequent bytes. Does NOT
* re-spawn the process.
*
* Rejects if the session is no longer alive (the caller then opens fresh).
*/
reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult>;
/**
* Kills a live PTY by its session id, independently of any view-held handle.
* Used when a cell's agent changes: the old PTY must be torn down even though
* the owning {@link TerminalHandle} is private to its (unmounting) view, whose
* cleanup only ever {@link TerminalHandle.detach}es. Best-effort: resolves even
* if the session is already gone.
*
* This is the only sanctioned way to kill a PTY outside
* {@link TerminalHandle.close}.
*/
closeTerminal(sessionId: string): Promise<void>;
}
/** The outcome of {@link TerminalGateway.reattach}. */
export interface ReattachResult {
/** The live terminal handle for the re-attached session. */
handle: TerminalHandle;
/** The retained scrollback bytes to repaint before the live stream resumes. */
scrollback: Uint8Array;
}
/** Projects: create/open/close/list (L2). */
export interface ProjectGateway {
/** Lists the projects known to the registry. */
listProjects(): Promise<Project[]>;
/** Creates a project from a root; returns the created project. */
createProject(name: string, root: string): Promise<Project>;
/** Opens a project by id; returns the opened project. */
openProject(projectId: string): Promise<Project>;
/** Closes a project by id. */
closeProject(projectId: string): Promise<void>;
/** Reads the shared project context stored at `.ideai/CONTEXT.md`. */
readProjectContext(projectId: string): Promise<string>;
/** Overwrites the shared project context stored at `.ideai/CONTEXT.md`. */
updateProjectContext(projectId: string, content: string): Promise<void>;
}
/** Layout: load the terminal grid tree and apply mutating operations (L4). */
export interface LayoutGateway {
/** Loads a project's layout tree (defaults to a single cell if none persisted).
* When `layoutId` is omitted, the active layout for the project is used. */
loadLayout(projectId: string, layoutId?: string): Promise<LayoutTree>;
/**
* Applies a split/merge/resize/move/setSession/setCellAgent operation; the backend persists
* `.ideai/layout.json` and returns the resulting tree.
* When `layoutId` is omitted, the active layout for the project is used.
*/
mutateLayout(
projectId: string,
operation: LayoutOperation,
layoutId?: string,
): Promise<LayoutTree>;
/** Lists all named layouts for a project, with the current active id. */
listLayouts(projectId: string): Promise<LayoutList>;
/** Creates a new named layout for a project; returns the new layout id. */
createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }>;
/** Renames a layout. */
renameLayout(projectId: string, layoutId: string, name: string): Promise<void>;
/** Deletes a layout; returns the new active layout id. */
deleteLayout(projectId: string, layoutId: string): Promise<{ activeId: string }>;
/** Sets the active layout for a project. */
setActiveLayout(projectId: string, layoutId: string): Promise<void>;
}
/** Git: status/commit/checkout/… (L8). */
export interface GitGateway {
status(projectId: string): Promise<GitFileStatus[]>;
stage(projectId: string, path: string): Promise<void>;
unstage(projectId: string, path: string): Promise<void>;
commit(projectId: string, message: string): Promise<GitCommit>;
branches(projectId: string): Promise<GitBranches>;
checkout(projectId: string, branch: string): Promise<void>;
log(projectId: string, limit: number): Promise<GitCommit[]>;
init(projectId: string): Promise<void>;
/** Returns the full commit DAG for the git-graph layout. */
graph(projectId: string, limit: number): Promise<GraphCommit[]>;
}
/** Remote (SSH/WSL) connection management (L9). */
export interface RemoteGateway {
connect(projectId: string): Promise<void>;
}
/** Input for {@link TemplateGateway.createTemplate}. */
export interface CreateTemplateInput {
name: string;
content: string;
defaultProfileId: string;
}
/**
* Templates (L7): CRUD for agent templates, creation of agents from templates,
* drift detection and synchronisation.
*/
export interface TemplateGateway {
/** Lists all templates. */
listTemplates(): Promise<Template[]>;
/** Creates a new template; returns the created template. */
createTemplate(input: CreateTemplateInput): Promise<Template>;
/** Updates a template's content; increments its version; returns the updated template. */
updateTemplate(templateId: string, content: string): Promise<Template>;
/** Deletes a template by id. */
deleteTemplate(templateId: string): Promise<void>;
/**
* Creates an agent in `projectId` based on the given template.
* The agent's origin will be `fromTemplate`; its context will be the template's `contentMd`.
*/
createAgentFromTemplate(
projectId: string,
templateId: string,
opts?: { name?: string; synchronized?: boolean },
): Promise<Agent>;
/** Returns the list of synchronized agents in `projectId` whose template has been updated. */
detectDrift(projectId: string): Promise<AgentDrift[]>;
/**
* Syncs a single agent to the current version of its template.
* Returns `{ synced: true, version }` on success, `{ synced: false, version: null }` for
* scratch / non-synchronized agents.
*/
syncAgent(
projectId: string,
agentId: string,
): Promise<{ synced: boolean; version: number | null }>;
}
/** Input for {@link SkillGateway.createSkill}. */
export interface CreateSkillInput {
/** Owning project (resolved to a root; ignored on disk for `global`). */
projectId: string;
name: string;
content: string;
scope: SkillScope;
}
/**
* Skills (L12): CRUD for reusable, model-agnostic workflows in either scope, and
* agent↔skill assignment. Assigned skills are injected into the agent's
* convention file at activation (handled backend-side, ARCHITECTURE §14.2).
*/
export interface SkillGateway {
/** Lists the skills in one scope for the given project. */
listSkills(projectId: string, scope: SkillScope): Promise<Skill[]>;
/** Creates a new skill; returns the created skill. */
createSkill(input: CreateSkillInput): Promise<Skill>;
/** Updates a skill's content; returns the updated skill. */
updateSkill(
projectId: string,
scope: SkillScope,
skillId: string,
content: string,
): Promise<Skill>;
/** Deletes a skill by id from its scope's store. */
deleteSkill(
projectId: string,
scope: SkillScope,
skillId: string,
): Promise<void>;
/** Assigns a skill to an agent (idempotent). */
assignSkill(
projectId: string,
agentId: string,
skillId: string,
scope: SkillScope,
): Promise<void>;
/** Unassigns a skill from an agent (idempotent). */
unassignSkill(
projectId: string,
agentId: string,
skillId: string,
): Promise<void>;
}
/** Input for {@link MemoryGateway.createMemory}. */
export interface CreateMemoryInput {
/** Owning project (resolved to its `.ideai/` memory store). */
projectId: string;
/** Human title; also the source of the note's slug identity. */
name: string;
description: string;
type: MemoryType;
content: string;
}
/**
* Memory (L14): CRUD over project memory notes + the recall-oriented index and
* `[[wikilink]]` resolution. Identity is the **slug** (kebab-case), not a UUID;
* a note's slug is immutable, so `updateMemory` never changes it. Mirrors the
* seven backend memory commands (+ optional `recall`).
*/
export interface MemoryGateway {
/** Lists every memory note of a project (full payloads). */
listMemories(projectId: string): Promise<Memory[]>;
/** Reads a single note by slug. */
getMemory(projectId: string, slug: string): Promise<Memory>;
/** Creates a new note; returns the created note. */
createMemory(input: CreateMemoryInput): Promise<Memory>;
/** Updates a note's mutable fields (slug is immutable); returns the updated note. */
updateMemory(
projectId: string,
slug: string,
description: string,
type: MemoryType,
content: string,
): Promise<Memory>;
/** Deletes a note by slug. */
deleteMemory(projectId: string, slug: string): Promise<void>;
/** Reads the recall-oriented memory index. */
readIndex(projectId: string): Promise<MemoryIndexEntry[]>;
/** Resolves a note's `[[wikilinks]]` to the slugs of existing target notes. */
resolveLinks(projectId: string, slug: string): Promise<MemoryLink[]>;
/** Best-effort recall: the index entries most relevant to `text`, within a token budget. */
recall(
projectId: string,
text: string,
tokenBudget: number,
): Promise<MemoryIndexEntry[]>;
}
/**
* AI profiles & first-run (L5). Drives the first-run wizard and profile
* management: the pre-filled reference catalogue, detection of installed CLIs,
* and CRUD/batch persistence of the chosen/edited/custom profiles.
*/
export interface ProfileGateway {
/** First-run state: whether to show the wizard + the reference catalogue. */
firstRunState(): Promise<FirstRunState>;
/** The pre-filled, editable reference catalogue (Claude/Codex/Gemini/Aider). */
referenceProfiles(): Promise<AgentProfile[]>;
/** Probes each candidate's detection command; returns availability (✓/✗). */
detectProfiles(candidates: AgentProfile[]): Promise<ProfileAvailability[]>;
/** Lists the configured profiles. */
listProfiles(): Promise<AgentProfile[]>;
/** Creates or replaces (by id) a single profile; returns the saved profile. */
saveProfile(profile: AgentProfile): Promise<AgentProfile>;
/** Deletes a profile by id. */
deleteProfile(profileId: string): Promise<void>;
/** Persists the batch of chosen profiles, closing the first run. */
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
}
/** Input for {@link EmbedderGateway.saveEmbedderProfile}. */
export interface SaveEmbedderProfileInput {
profile: EmbedderProfile;
}
/**
* Embedder configuration (L14 / lot C2). Drives the memory/embedder settings
* panel: lists the configured embedder profiles, the available engines (with
* build-time feature flags so unavailable strategies can be shown disabled),
* and persists / removes profiles. Changing the active embedder takes effect at
* the next app start. Mirrors the four backend embedder commands.
*/
export interface EmbedderGateway {
/** Lists the configured embedder profiles (transparent: no secrets). */
listEmbedderProfiles(): Promise<EmbedderProfile[]>;
/** Creates or replaces (by id) a single profile; returns the saved profile. */
saveEmbedderProfile(profile: EmbedderProfile): Promise<EmbedderProfile>;
/** Deletes an embedder profile by id. */
deleteEmbedderProfile(embedderId: string): Promise<void>;
/** Describes the available engines + build feature flags + detection. */
describeEmbedderEngines(): Promise<EmbedderEngines>;
}
/**
* The full set of gateways the app depends on, injected via the DI provider.
* The composition (real vs mock) is chosen in `app/`.
*/
export interface Gateways {
system: SystemGateway;
agent: AgentGateway;
terminal: TerminalGateway;
project: ProjectGateway;
layout: LayoutGateway;
git: GitGateway;
remote: RemoteGateway;
profile: ProfileGateway;
template: TemplateGateway;
skill: SkillGateway;
memory: MemoryGateway;
embedder: EmbedderGateway;
}