Files
IdeaSDK/frontend/src/domain/index.ts
Blomios 8509653e3c feat(frontend): popup de confirmation à la fermeture avec travail en cours (#83)
Écoute l'event Tauri `app-exit-work-guard` (émis par le backend quand la
fermeture de la fenêtre main est interceptée) et affiche une popup modale
"Du travail est encore en cours" avec le résumé pluralisé exact du carnet
#83, le détail agents/tâches capé à 5 lignes, et les deux actions Annuler
(focus par défaut, no-op local) / Quitter quand même (danger, appelle
confirm_app_exit). Pas d'option "ne plus demander".

- domain/ports/adapters (Tauri listen+invoke, HTTP desktop-only stub, mock
  avec helpers de test) : onAppExitWorkGuard/confirmAppExit sur
  SystemGateway, suivant le patron déjà utilisé pour focused-project et les
  domain events.
- AppExitConfirmDialog : mounted une fois près de la racine (App.tsx), à
  côté d'AnnouncementsProvider — role="alertdialog", focus trap, Échap =
  Annuler, pas de fermeture au clic extérieur, ne se referme jamais
  automatiquement (un event pendant l'ouverture rafraîchit juste le résumé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 19:16:29 +02:00

1445 lines
48 KiB
TypeScript

/**
* Pure UI-domain types — TS mirrors of the backend DTOs (camelCase wire format,
* ARCHITECTURE §1.3). No React, no Tauri here: these are plain data shapes the
* ports speak in, so view logic and gateways stay testable in isolation.
*/
/** Health report returned by the `health` gateway/command. */
export interface HealthReport {
version: string;
alive: boolean;
timeMillis: number;
correlationId: string;
note: string | null;
}
// ---------------------------------------------------------------------------
// App-exit work guard (ticket #83) — confirmation before quitting IdeA while
// agents/background tasks are active. Mirrors the backend
// `AppExitWorkGuardStateDto` (main window only; detached windows don't carry
// this guard).
// ---------------------------------------------------------------------------
/** One active work item contributing to {@link AppExitWorkGuardState}. */
export type AppExitWorkGuardDetail =
| {
kind: "busyAgent";
projectId: string;
projectName: string;
agentId: string;
agentName: string;
ticketId: string | null;
}
| {
kind: "activeBackgroundTask";
projectId: string;
projectName: string;
agentId: string;
agentName: string;
taskId: string;
state: string;
taskKind: string;
};
/** App-wide shutdown guard read model, carried by the `app-exit-work-guard` event. */
export interface AppExitWorkGuardState {
hasWorkInProgress: boolean;
busyAgentCount: number;
activeBackgroundTaskCount: number;
totalWorkCount: number;
details: AppExitWorkGuardDetail[];
}
/**
* Lifecycle status of a local model server during an agent launch (F35, mirror
* of the backend `ModelServerStatusDto`, tagged on `state`, camelCase wire).
*
* Contract note — the backend event carries the `serverId` on the *envelope*
* ({@link DomainEvent} `modelServerStatusChanged`), not inside the status, and it
* does NOT emit `baseURL`/`model` in the status payload: the UI reads what the
* backend actually sends. `reused` (on `ready`) tells apart a freshly-started
* server from a reused running one.
*/
export type ModelServerStatus =
| { state: "notConfigured" }
| { state: "probing" }
| { state: "starting" }
| {
/**
* The server is downloading/preparing a remote model. Progress fields are
* optional (all `null` in the F1 MVP; the bar/% display is the F2 stretch):
* the UI only needs `downloading` to raise the cell overlay.
*/
state: "downloading";
downloadedBytes: number | null;
totalBytes: number | null;
percent: number | null;
source: string | null;
}
| { state: "ready"; reused: boolean }
| { state: "failed"; code: string; message: string };
/**
* Stop policy of a managed local model server (mirror of the backend
* `StopPolicyDto`, camelCase wire):
* - `keepAlive`: leave the process running,
* - `stopOnAppExit`: stop when IdeA exits (the effective default),
* - `stopWhenUnused`: stop when no profile references it.
*/
export type StopPolicy = "keepAlive" | "stopOnAppExit" | "stopWhenUnused";
/**
* Where the served `.gguf` model comes from (F35 V2, mirror of the backend
* `ModelSourceDto`, camelCase wire, tagged on `type`):
* - `localPath`: an absolute `.gguf` path already present on disk (`--model`),
* - `huggingFace`: a `namespace/repo[:quant]` reference llama.cpp downloads and
* caches automatically (`-hf`).
*/
export type ModelSource =
| { type: "localPath"; path: string }
| { type: "huggingFace"; repo: string };
/**
* A declared local model server (F35, mirror of the backend flat
* `LocalModelServerConfigDto`, camelCase wire). Global to IdeA (not project
* scoped). Referenced by an OpenCode profile through
* `OpenCodeConfig.localModelServerId` (= this `id`).
*
* Contract notes:
* - `id` is a UUID **minted client-side** on create (the backend requires a valid
* UUID; it does not generate the server id, only an internal model id it hides).
* - `servedModelName` is what the user configures and what is sent to OpenCode as
* the `model` — the backend's internal `model.id`/`model.label` are not exposed.
* - `modelSource` (V2) replaces the former `modelPath`; it is required by the
* backend when `autoStart` is `true`.
*/
export interface LocalModelServerConfig {
id: string;
/** Only `llamaCpp` is supported today (camelCase, not "llamacpp"). */
kind: "llamaCpp";
name: string;
/** OpenAI-compatible base URL served by `llama-server` (normalised to `/v1`). */
baseURL: string;
port: number;
/** Explicit model source (`.gguf` path or Hugging Face ref). Required for auto-start. */
modelSource?: ModelSource;
/** Model name exposed by the server and sent to OpenCode as `model`. */
servedModelName: string;
/** Explicit `llama-server` executable path/command (optional). */
binaryPath?: string;
/** llama.cpp `--host` (default `127.0.0.1`). */
host: string;
/** llama.cpp `-ngl` GPU layers (optional; `0` = CPU only). */
gpuLayers?: number;
/** llama.cpp `-c` context size (optional). */
contextSize?: number;
/** Whether to pass `--jinja` (Jinja chat template). */
jinja: boolean;
args: string[];
autoStart: boolean;
stopPolicy: StopPolicy;
}
/**
* A previewed `llama-server` command line (F35 V2, mirror of the backend
* `PreviewModelServerCommandDto`). Built by the backend from a draft config —
* never reconstructed client-side.
*/
export interface ModelServerCommandPreview {
/** Resolved executable (e.g. `llama-server` or an absolute path). */
command: string;
/** Argument vector, unescaped. */
args: string[];
/** Human-readable, shell-escaped command line. */
display: string;
}
/**
* How the embedded web server is exposed (ticket #68, mirror of the backend
* `ServerExposureMode`).
*
* - `localOnly` — binds loopback, no remote access.
* - `remoteProxyLocal` — binds loopback; an HTTPS reverse proxy runs on *this*
* machine and reaches IdeA over loopback.
* - `remoteProxyOtherMachine` — binds a concrete LAN address so a proxy on
* *another* host can reach it; that proxy must be declared in
* `trustedProxies`.
*/
export type ServerExposureMode =
| "localOnly"
| "remoteProxyLocal"
| "remoteProxyOtherMachine";
/**
* Persisted embedded-server exposure settings (mirror of the backend
* `ServerExposureSettingsDto`).
*
* The backend validates every field on save/start; the UI never derives network
* facts (LAN addresses, upstream URL) from these — it asks `previewExposure`.
*/
export interface ServerExposureSettings {
mode: ServerExposureMode;
/** TCP port to bind. `0` asks the OS for an ephemeral port. */
port: number;
/** Public HTTPS origin, required by both remote modes. */
publicOrigin?: string;
/**
* Peers allowed to contact IdeA, as IPs or CIDRs. **Not** a listen address.
* Required (non-empty) by `remoteProxyOtherMachine`.
*/
trustedProxies: string[];
/**
* Concrete LAN address to bind, required by `remoteProxyOtherMachine`. Must
* come from {@link ServerExposurePreview.candidateLanAddresses} — the UI is
* never allowed to invent one.
*/
lanBindAddress?: string;
}
/** A non-fatal diagnostic from the exposure preview (never blocks a start). */
export interface DiagnosticWarning {
/** Stable warning code (e.g. `missingTrustedProxy`). */
code: string;
/** Human-readable, actionable message. */
message: string;
}
/**
* Backend-derived preview of a draft exposure config (mirror of
* `ServerExposurePreviewDto`). The backend is the sole authority on LAN
* addresses and the proxy upstream URL.
*/
export interface ServerExposurePreview {
/** LAN addresses the backend discovered on this host. */
candidateLanAddresses: string[];
/** URL the reverse proxy should target; absent in `localOnly`. */
upstreamUrl?: string;
/** Non-fatal diagnostics to surface alongside the form. */
warnings: DiagnosticWarning[];
}
/** Embedded-server lifecycle state (mirror of `EmbeddedServerStatusStateDto`). */
export type EmbeddedServerState =
| "stopped"
| "starting"
| "running"
| "stopping"
| "failed";
/**
* Embedded-server status (mirror of `EmbeddedServerStatusDto`).
*
* Carries **no pairing code** (#77): a code no longer exists while the server
* runs, only when someone asks for one. Generating is a device-management action
* ({@link PairingCode}), not a property of the server's status.
*/
export interface EmbeddedServerStatus {
state: EmbeddedServerState;
/** Local URL, when running. */
localUrl?: string;
/** Public URL, when a remote mode is configured. */
publicUrl?: string;
/** Upstream URL to hand to the reverse proxy. */
upstreamUrl?: string;
/** Last failure, when `state` is `failed`. */
error?: GatewayError;
}
/** A domain event relayed from the backend (tagged union on `type`). */
export type DomainEvent =
| { type: "projectCreated"; projectId: string }
| { type: "agentLaunched"; agentId: string; sessionId: string }
| { type: "agentExited"; agentId: string; code: number }
| {
/**
* A local model server changed lifecycle state while (re)launching an
* agent whose OpenCode profile binds `localModelServerId` (F35). Keyed by
* `serverId`; the frontend correlates it back to the agent through the
* profile's `opencode.localModelServerId`.
*/
type: "modelServerStatusChanged";
serverId: string;
status: ModelServerStatus;
}
| {
/**
* An agent launch failed before a runtime session was created (F35). Unlike
* a generic "agent failed", this carries an actionable `message` and a
* stable `code`; `cause` namespaces the failure (e.g. `"model_server"`).
*/
type: "agentLaunchFailed";
agentId: string;
cause: string;
code: string;
message: string;
}
| { type: "agentProfileChanged"; agentId: string; profileId: string }
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
| {
type: "backgroundTaskChanged";
projectId: string;
taskId: string;
agentId: string;
state: string;
}
| {
type: "agentInboxChanged";
agentId: string;
depth: number;
action: string;
}
| {
type: "agentWakeChanged";
projectId: string;
agentId: string;
action: string;
reason?: string;
}
| {
/**
* A delegation is ready to be injected into the agent's native terminal
* (ARCHITECTURE §20). The backend is the queue/busy authority but no longer
* PTY-writes the turn: the frontend write-portal runs the handshake (b→e)
* and writes `text` + `submitSequence`. `submitSequence`/`submitDelayMs`
* come from the target's profile; absent ⇒ the portal applies its defaults
* (`"\r"`, ~350 ms).
*/
type: "delegationReady";
agentId: string;
ticket: string;
text: string;
submitSequence?: string;
submitDelayMs?: number;
}
| {
/**
* An agent entered a session/rate limit (ARCHITECTURE §21). Low-frequency,
* model-agnostic. `resetsAtMs` is the reset instant in epoch-milliseconds;
* absent ⇒ unknown (no reliable time → no auto-resume). The frontend badges
* "limité jusqu'à HH:MM" when known, "limité" otherwise.
*/
type: "agentRateLimited";
agentId: string;
resetsAtMs?: number;
}
| {
/**
* An auto-resume was armed for a rate-limited agent (ARCHITECTURE §21).
* `fireAtMs` is the wake-up deadline in epoch-milliseconds. The frontend
* shows the countdown + the "Annuler la reprise" button (cancellable window).
*/
type: "agentResumeScheduled";
agentId: string;
fireAtMs: number;
}
| {
/**
* An agent's auto-resume was cancelled (ARCHITECTURE §21): the user clicked
* "Annuler la reprise". The frontend removes the countdown but keeps the
* "limité" state (no auto-resume will fire).
*/
type: "agentResumeCancelled";
agentId: string;
}
| {
/**
* An agent was effectively resumed after a limit (ARCHITECTURE §21): the
* wake-up fired (or an immediate resume). The frontend clears all limit state.
*/
type: "agentResumed";
agentId: string;
}
| {
/**
* Human net (level 3): a session limit is suspected without any reliable
* reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the
* frontend to surface an "heure inconnue" state and prompt the user.
* `resetsAtMs` carries an estimate when one exists, else absent.
*/
type: "agentRateLimitSuspected";
agentId: string;
resetsAtMs?: number;
}
| { type: "templateUpdated"; templateId: string; version: number }
| { type: "agentDriftDetected"; agentId: string; from: number; to: number }
| { type: "agentSynced"; agentId: string; to: number }
| { type: "layoutChanged"; projectId: string }
| { type: "remoteConnected"; projectId: string }
| { type: "gitStateChanged"; projectId: string }
| { type: "memorySaved"; slug: string }
| {
type: "orchestratorRequestProcessed";
requesterId: string;
action: string;
ok: boolean;
/**
* Which entry door the delegation arrived through: `"mcp"` (MCP server) or
* `"file"` (`.ideai/requests` watcher). Optional for backward-compatibility:
* an event relayed without it simply yields no source badge.
*/
source?: "mcp" | "file";
}
| { type: "issueCreated"; issueId: string; issueRef: string }
| { type: "issueUpdated"; issueRef: string; version: number }
| {
/**
* A ticket was deleted (ticket #6). Mirror of the backend
* `DomainEventDto::IssueDeleted`. `issueRef` is the deleted `#N`;
* `freedSprint` is the sprint id it was released from, when any. Starts with
* `issue`, so {@link isTicketEvent} matches it and ticket lists refresh
* automatically; an open detail on the same `issueRef` closes.
*/
type: "issueDeleted";
projectId: string;
issueRef: string;
freedSprint: string | null;
}
| {
type: "issueStatusChanged";
issueRef: string;
status: TicketStatus;
version: number;
}
| {
type: "issuePriorityChanged";
issueRef: string;
priority: TicketPriority;
version: number;
}
| { type: "issueCarnetUpdated"; issueRef: string; version: number }
| {
type: "issueLinked";
issueRef: string;
target: string;
kind: TicketLinkKind;
version: number;
}
| {
type: "issueUnlinked";
issueRef: string;
target: string;
kind: TicketLinkKind;
version: number;
}
| {
type: "issueAgentAssigned";
issueRef: string;
agentId: string;
version: number;
}
| {
type: "issueAgentUnassigned";
issueRef: string;
agentId: string;
version: number;
}
| {
/**
* A ticket changed sprint membership (ticket #10). Mirror of the backend
* `DomainEventDto::IssueSprintChanged`. `from`/`to` are sprint ids (`null`
* ⇒ no sprint). Starts with `issue`, so {@link isTicketEvent} matches it
* and ticket lists refresh automatically.
*/
type: "issueSprintChanged";
issueRef: string;
from: string | null;
to: string | null;
version: number;
}
| { type: "sprintCreated"; sprintId: string; order: number }
| { type: "sprintRenamed"; sprintId: string; name: string; version: number }
| {
type: "sprintReordered";
sprintId: string;
order: number;
version: number;
}
| { type: "sprintDeleted"; sprintId: string }
| {
/**
* A ticket AI assistant chat was opened (ticket #8). Mirror of the backend
* `DomainEventDto::TicketAssistantOpened`. `issueRef` is the bound ticket,
* `profileId` the runtime AI profile driving the assistant.
*/
type: "ticketAssistantOpened";
issueRef: string;
profileId: string;
}
| {
/** A ticket AI assistant chat was closed/disposed (ticket #8). */
type: "ticketAssistantClosed";
issueRef: string;
}
| {
/**
* An intermediate assistant announcement emitted **during** an inter-agent
* structured turn (ticket #4, "annonces inter-agent"). Mirror of the backend
* `DomainEventDto::AgentAnnouncement`. Ephemeral (never persisted): the front
* folds these into a live, bounded, per-`(target, ticketId)` index that feeds
* the target-cell overlay (F3) and the requester-cell preview (F2).
*
* `requester` is `"user"` or the requesting agent's id; `target` is the agent
* being contacted; `text` is the human-readable réflexion/annonce.
*/
type: "agentAnnouncement";
projectId: string;
requester: string;
target: string;
ticketId: string;
text: string;
atMs: number;
}
| { type: "ptyOutput"; sessionId: string; bytes: number[] };
/**
* Whether a domain event is one of the ticket (`Issue*`) events. A small helper
* so ticket view-models refresh on any ticket mutation without re-enumerating
* every variant at each call site.
*/
export function isTicketEvent(
event: DomainEvent,
): event is Extract<DomainEvent, { type: `issue${string}` }> {
return event.type.startsWith("issue");
}
/**
* Whether a domain event is a sprint lifecycle event (`sprint*`). Lets the
* ticket view-model re-fetch the sprint list when sprints are created, renamed,
* reordered or deleted (ticket #10).
*/
export function isSprintEvent(
event: DomainEvent,
): event is Extract<DomainEvent, { type: `sprint${string}` }> {
return event.type.startsWith("sprint");
}
/** Where a project physically lives (mirror of the backend `RemoteRef`). */
export type RemoteRef =
| { kind: "local" }
| {
kind: "ssh";
host: string;
port: number;
user: string;
auth: unknown;
remoteRoot: string;
}
| { kind: "wsl"; distro: string };
/** A project as returned by the `project` gateway (mirror of `ProjectDto`). */
export interface Project {
id: string;
name: string;
root: string;
remote: RemoteRef;
createdAt: number;
}
/** Stable error shape mirrored from the backend `ErrorDto`. */
export interface GatewayError {
code: string;
message: string;
}
// ---------------------------------------------------------------------------
// Work state (UX conversations/delegations live read-model)
// ---------------------------------------------------------------------------
/** Live session currently associated with an agent in the work-state read-model. */
export interface LiveWorkSession {
nodeId: string;
sessionId: string;
kind: "pty" | "structured";
}
/** Busy/idle status for an agent in the work-state read-model. */
export type WorkBusyState =
| { state: "idle" }
| { state: "busy"; ticket: string; sinceMs: number };
/** FIFO status for an input/ticket currently tracked by the backend queue. */
export type TicketWorkStatus = "inProgress" | "queued";
/** Who produced an input/ticket in the work-state read-model. */
export type TicketWorkSource =
| { kind: "human" }
| { kind: "agent"; agentId: string };
/** One queued or in-progress input/ticket for an agent. */
export interface AgentTicketState {
ticketId: string;
conversationId: string;
position: number;
status: TicketWorkStatus;
source: TicketWorkSource;
requesterLabel: string;
taskPreview: string;
taskLen: number;
}
/** One first-class background task visible in the work-state read-model. */
export interface BackgroundCompletion {
taskId: string;
ownerAgentId: string;
projectId: string;
kind: string;
status: "running" | "completed" | "failed" | "cancelled" | "pending" | "delivered";
exitCode: number | null;
/** Human-readable summary / error / reason of a terminal result (ticket #5). */
summary: string | null;
stdoutTail: string | null;
stderrTail: string | null;
/** Last-update timestamp (epoch ms); chronological ordering key. */
updatedAtMs: number;
}
/** One item currently pending in an agent's inbox. */
export interface InboxItem {
id: string;
agentId: string;
source: string;
kind: string;
body: string;
createdAtMs: number;
correlationId?: string;
priority: number;
}
/** Availability of a compact conversation summary in the work-state read-model. */
export type ConversationPreviewStatus =
| "ready"
| "missing"
| "partial"
| "unavailable";
/** Recent turn preview attached to a conversation summary. */
export interface ConversationTurnWorkPreview {
role: "prompt" | "response" | "toolActivity";
source: TicketWorkSource;
atMs: number;
textPreview: string;
textLen: number;
}
/** Compact thread context joined to tickets by conversation id. */
export interface ConversationWorkSummary {
conversationId: string;
status: ConversationPreviewStatus;
objectivePreview: string | null;
summaryPreview: string | null;
summaryLen: number;
upTo: string | null;
recentTurns: ConversationTurnWorkPreview[];
}
/** One agent row in the project work-state read-model. */
export interface AgentWorkState {
agentId: string;
name: string;
profileId: string;
live?: LiveWorkSession;
busy: WorkBusyState;
tickets: AgentTicketState[];
inboxDepth?: number;
inbox?: InboxItem[];
backgroundTasks?: BackgroundCompletion[];
}
/** Minimal read-only live-state surface for a project. */
export interface ProjectWorkState {
agents: AgentWorkState[];
conversations: ConversationWorkSummary[];
}
// ---------------------------------------------------------------------------
// Conversation transcript (LS7 — human thread-per-pair viewer, mirror of LS6 DTO)
// ---------------------------------------------------------------------------
/** Nature of a transcript turn (mirror of the backend `TurnRole`). */
export type TurnRole = "prompt" | "response" | "toolActivity";
/** Origin of a transcript turn (mirror of the backend `TurnSource`). */
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;
role: TurnRole;
source: TurnSource;
text: string;
textLen: number;
}
/** Pagination travel direction (`"backward"` = towards older turns). */
export type PageDirection = "forward" | "backward";
/** A page of the human transcript, oldest-to-newest. */
export interface TurnPage {
turns: TurnView[];
hasMore: boolean;
nextAnchor?: string;
}
// ---------------------------------------------------------------------------
// Permissions (LP1)
// ---------------------------------------------------------------------------
/** Agent capability governed by IdeA permissions. */
export type Capability = "read" | "write" | "delete" | "executeBash";
/** Permission rule verdict. */
export type PermissionEffect = "allow" | "deny";
/** Fallback stance when no permission rule matches. */
export type PermissionPosture = "ask" | "allow" | "deny";
/** Glob path scope, relative to the project root. */
export type PathScope = string[];
/** Shell command matcher. */
export type CommandMatcher =
| { kind: "exact"; value: string }
| { kind: "prefix"; value: string }
| { kind: "glob"; value: string };
/** Per-command bash verdict. */
export interface CommandRule {
matcher: CommandMatcher;
effect: PermissionEffect;
}
/** One permission rule. */
export interface PermissionRule {
capability: Capability;
effect: PermissionEffect;
paths?: PathScope;
commands?: CommandRule[];
}
/** Permission policy bundle. */
export interface PermissionSet {
rules: PermissionRule[];
fallback: PermissionPosture;
}
/** One sparse agent override in `.ideai/permissions.json`. */
export interface AgentPermissionOverride {
agentId: string;
permissions: PermissionSet;
}
/** Full project permission document. */
export interface ProjectPermissions {
version: number;
projectDefaults?: PermissionSet;
agents?: AgentPermissionOverride[];
}
/** Effective project+agent policy. */
export interface EffectivePermissions {
rules: PermissionRule[];
fallback: PermissionPosture;
}
// ---------------------------------------------------------------------------
// MCP tool permissions (ticket #82) — distinct from the file/command
// permissions above: an allowlist of exact MCP tool names, applied by the MCP
// server/bridge before dispatch, not by Landlock/sandbox.
// ---------------------------------------------------------------------------
/** Allowlist-based MCP tool policy: exact tool names permitted. */
export interface McpToolPolicy {
allowedTools: string[];
}
/** One agent's MCP tool policy override, replacing the project default entirely. */
export interface AgentMcpToolPolicyOverride {
agentId: string;
policy: McpToolPolicy;
}
/**
* Backend-canonical classification of the MCP tool catalogue. The frontend
* must treat this as the source of truth for read vs write/action — never
* hardcode the split locally (ticket #82 acceptance criteria).
*/
export interface McpToolCatalogue {
readOnlyTools: string[];
writeActionTools: string[];
}
/** Full per-project MCP tool permission document, mirroring the backend DTO. */
export interface ProjectMcpToolPermissions {
version: number;
catalogue: McpToolCatalogue;
projectDefault: McpToolPolicy | null;
agents: AgentMcpToolPolicyOverride[];
}
// ---------------------------------------------------------------------------
// Layout (L4) — mirror of the domain `LayoutTree` (ARCHITECTURE §3, §7).
// ---------------------------------------------------------------------------
/** Split direction: `row` = columns (left→right), `column` = rows (top→bottom). */
export type Direction = "row" | "column";
/** A terminal-hosting leaf cell. `session` is the hosted SessionId, if any.
* `agent` is the agent id if an agent is pinned to this cell (absent = plain terminal).
* `conversationId` is the persistent CLI conversation id (survives PTY close/reopen,
* lets the agent resume); omitted when absent (mirrors the backend `skip_serializing_if`).
* `agentWasRunning` records whether the cell's agent process was running at close time;
* omitted when `false`. */
export interface LeafCell {
id: string;
session?: string | null;
agent?: string;
conversationId?: string;
agentWasRunning?: boolean;
}
/** A weighted child of a split. `weight` is a relative (`> 0`) share. */
export interface WeightedChild {
node: LayoutNode;
weight: number;
}
/** A weighted n-ary split (rows or columns). */
export interface SplitContainer {
id: string;
direction: Direction;
children: WeightedChild[];
}
/** A spreadsheet grid cell placement with spans. */
export interface GridCell {
node: LayoutNode;
row: number;
col: number;
rowSpan: number;
colSpan: number;
}
/** A spreadsheet-style grid with per-row/col weights and span-based merging. */
export interface GridContainer {
id: string;
colWeights: number[];
rowWeights: number[];
cells: GridCell[];
}
/**
* A node in the layout tree. Tagged on `type` with the payload under `node`,
* matching the backend `#[serde(tag = "type", content = "node")]`.
*/
export type LayoutNode =
| { type: "leaf"; node: LeafCell }
| { type: "split"; node: SplitContainer }
| { type: "grid"; node: GridContainer };
/** The root of a tab's terminal layout. */
export interface LayoutTree {
root: LayoutNode;
}
/**
* A layout mutation (mirror of the backend `LayoutOperationDto`, tagged on
* `type`). Node/session ids are UUID strings.
*/
export type LayoutOperation =
| {
type: "split";
target: string;
direction: Direction;
newLeaf: string;
container: string;
}
| { type: "merge"; container: string; keepIndex: number }
| { type: "resize"; container: string; weights: number[] }
| { type: "move"; from: string; to: string }
| { type: "setSession"; target: string; session?: string | null }
| { type: "setCellAgent"; target: string; agent: string | null }
| { type: "setCellConversation"; target: string; conversationId: string | null }
| { type: "setAgentRunning"; target: string; running: boolean };
/** The kind of a named layout. */
export type LayoutKind = "terminal" | "gitGraph";
/** Named layout entry returned by `listLayouts`. */
export interface LayoutInfo {
id: string;
name: string;
kind: LayoutKind;
}
/**
* A commit node in the full git graph (DAG). `refs` carries short branch/tag
* names (e.g. `"main"`, `"tag: v1.0"`). `timestamp` is seconds since Unix epoch.
*/
export interface GraphCommit {
hash: string;
summary: string;
parents: string[];
refs: string[];
author: string;
timestamp: number;
}
/** Response of `listLayouts`. */
export interface LayoutList {
layouts: LayoutInfo[];
activeId: string;
}
/** Unsubscribe handle returned by event subscriptions. */
export type Unsubscribe = () => void;
// ---------------------------------------------------------------------------
// AI profiles & first-run (L5) — mirror of the domain `AgentProfile` /
// `ContextInjection` (CONTEXT §9, ARCHITECTURE §3).
// ---------------------------------------------------------------------------
/**
* Context-injection strategy (tagged on `strategy`, mirror of the backend
* `ContextInjection`):
* - `conventionFile`: write the `.md` to a conventional file (e.g. `CLAUDE.md`),
* - `flag`: pass the context path through a CLI flag,
* - `stdin`: pipe the content on stdin,
* - `env`: pass the context via an environment variable.
*/
export type ContextInjection =
| { strategy: "conventionFile"; target: string }
| { strategy: "flag"; flag: string }
| { strategy: "stdin" }
| { strategy: "env"; var: string };
/** The four injection strategy discriminants. */
export type InjectionStrategy = ContextInjection["strategy"];
/**
* Structured-execution adapter of a profile (mirror of the backend
* `StructuredAdapter`, camelCase wire format). Absent (`structuredAdapter`
* omitted) ⇒ the profile is a plain TUI/PTY agent (historical behaviour);
* present ⇒ the profile is selectable as a structured AI agent:
* - `claude` / `codex` / `openCode`: driven by a spawned CLI binary,
* - `openAiCompatible`: driven by IdeA's native HTTP adapter against an
* OpenAI-compatible chat server (Ollama, llama.cpp, a LAN runtime) — see
* {@link HttpChatConfig}.
*/
export type StructuredAdapter = "claude" | "codex" | "openCode" | "openAiCompatible";
/**
* HTTP configuration of an OpenAI-compatible chat server (mirror of the backend
* `HttpChatConfig`, camelCase wire format). Carried by a profile whose
* {@link StructuredAdapter} is `openAiCompatible`.
*
* Transparent by design: secrets are never stored here — `apiKeyEnv` is the
* *name* of an environment variable, never the key itself (the backend resolves
* it at call time).
*/
export interface HttpChatConfig {
/** Root or `/chat/completions` endpoint of the HTTP server (http/https). */
endpoint: string;
/** Model name sent in the `/chat/completions` payload. */
model: string;
/** Name of the env var holding the API key (optional). Never the key. */
apiKeyEnv?: string;
/** Per-turn request timeout, in milliseconds. */
requestTimeoutMs?: number;
/** Connection timeout, in milliseconds. */
connectTimeoutMs?: number;
/** Tool-calling re-loop guard for one turn (defaults to ~16 backend-side). */
maxToolIterations?: number;
}
/**
* Configuration for an OpenCode process-backed profile pointed at its own
* llama.cpp (OpenAI-compatible) endpoint. Mirror of the backend `OpenCodeConfig`
* (camelCase wire format: `baseURL`, `apiKey?`, `model`, `reasoning?`,
* `attachment?`). OpenCode is a locally-piloted CLI, distinct from the in-process
* {@link HttpChatConfig} adapter.
*/
export interface OpenCodeConfig {
/**
* OpenAI-compatible base URL served by `llama-server` (http/https), for
* example `http://localhost:8080/v1`.
*/
baseURL: string;
/**
* Optional API key forwarded to the provider — the key *itself*, not an env
* var name (a local llama.cpp typically needs none / a placeholder). Omitted
* when blank (mirrors the backend `skip_serializing_if`).
*/
apiKey?: string;
/** Model name served by `llama-server`, for example `qwen3-coder-30b`. */
model: string;
/** Enables model reasoning. Effective default: `true`. */
reasoning?: boolean;
/** Enables attachments. Effective default: `false`. */
attachment?: boolean;
/**
* Optional id of a managed local model server (F35) this profile binds to.
* When set, IdeA can start/stop the referenced `llama-server` for the profile;
* omitted when the profile points at an externally-managed endpoint (mirrors
* the backend `skip_serializing_if`). Opaque string id on the wire.
*/
localModelServerId?: string;
}
/**
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
* UUID string; `detect` is the optional detection command line.
*/
export interface AgentProfile {
id: string;
name: string;
command: string;
args: string[];
contextInjection: ContextInjection;
detect: string | null;
cwdTemplate: string;
/** Optional CLI flags for agent-session continuity: `assignFlag` to bind a
* conversation id at launch, `resumeFlag` to resume an existing conversation. */
session?: { assignFlag?: string; resumeFlag: string };
/**
* Structured-execution adapter (mirror of the backend `structured_adapter`,
* `skip_serializing_if = Option::is_none`). Absent ⇒ plain TUI/PTY profile;
* present ⇒ selectable structured AI agent. Additive: existing Claude/Codex
* profiles are unaffected.
*/
structuredAdapter?: StructuredAdapter;
/**
* HTTP config for a `openAiCompatible` structured adapter (mirror of the
* backend `chat_http`, `skip_serializing_if = Option::is_none`). Absent for
* every historical profile and every non-HTTP adapter.
*/
chatHttp?: HttpChatConfig;
/** OpenCode process-backed config. Present for `structuredAdapter: "openCode"`. */
opencode?: OpenCodeConfig;
}
/** Availability of a candidate profile after detection (mirror of the DTO). */
export interface ProfileAvailability {
profile: AgentProfile;
available: boolean;
}
/** First-run state (mirror of `FirstRunStateDto`). */
export interface FirstRunState {
isFirstRun: boolean;
referenceProfiles: AgentProfile[];
}
// ---------------------------------------------------------------------------
// Agents (L6) — mirror of the domain `Agent` / `AgentOrigin` (ARCHITECTURE §6).
// ---------------------------------------------------------------------------
/**
* Origin of an agent (tagged on `type`, mirror of the backend `AgentOrigin`):
* - `scratch`: created from scratch, no template link,
* - `fromTemplate`: derived from a template, tracking the last synced version.
*/
export type AgentOrigin =
| { type: "scratch" }
| { type: "fromTemplate"; templateId: string; syncedTemplateVersion: number };
/**
* A project-scoped agent (mirror of the backend `Agent` DTO, camelCase wire
* format). `contextPath` is the relative path of the agent's `.md` within
* `.ideai/` (e.g. `agents/foo.md`).
*/
export interface Agent {
id: string;
name: string;
contextPath: string;
profileId: string;
origin: AgentOrigin;
synchronized: boolean;
/** Skills assigned to this agent (injected into its convention file). */
skills: SkillRef[];
}
/**
* A terminal/PTY session as returned by the backend (mirror of
* `TerminalSessionDto`, camelCase wire format). Surfaced by `changeAgentProfile`
* as the freshly relaunched session when a live agent was hot-swapped.
*/
export interface TerminalSession {
/** Stable session id (UUID) — used for write/resize/close + the output channel. */
sessionId: string;
/** Working directory the shell runs in. */
cwd: string;
/** Current rows. */
rows: number;
/** Current cols. */
cols: number;
/**
* Conversation id assigned by this (re)launch, when the profile supports
* session assignment and the hosting cell had none yet; absent otherwise.
*/
assignedConversationId?: string;
}
/**
* An agent that can be resumed when its project is (re)opened (mirror of the
* backend `ResumableAgentDto`, camelCase wire format; ARCHITECTURE §15.2). It is
* a read-only inventory entry computed at open time — never a spawn:
* - `nodeId` is the layout leaf hosting the agent (where to relaunch),
* - `conversationId` is the persisted CLI conversation id; absent ⇒ relaunch fresh,
* - `wasRunning` is `agentWasRunning` frozen at close time (drives the status label),
* - `resumeSupported` reflects whether the agent's profile exposes a usable
* session strategy; when `false`, "Reprendre" relaunches fresh ("relance à neuf").
*/
export interface ResumableAgent {
agentId: string;
name: string;
nodeId: string;
conversationId?: string;
wasRunning: boolean;
resumeSupported: boolean;
}
// ---------------------------------------------------------------------------
// Skills (L12) — mirror of the domain `Skill` / `SkillRef`.
// ---------------------------------------------------------------------------
/**
* Where a skill lives (selects its backing store): `global` skills are reusable
* across projects; `project` skills are specific to one project's `.ideai/`.
*/
export type SkillScope = "global" | "project";
/**
* A reusable, model-agnostic workflow assignable to agents (mirror of the
* backend `Skill` DTO, camelCase wire format).
*/
export interface Skill {
id: string;
name: string;
contentMd: string;
scope: SkillScope;
}
/** A reference from an agent to one assigned skill (mirror of `SkillRef`). */
export interface SkillRef {
skillId: string;
scope: SkillScope;
}
// ---------------------------------------------------------------------------
// Memory (L14) — mirror of the backend `MemoryDto` / `MemoryIndexEntryDto`.
// ---------------------------------------------------------------------------
/**
* The category of a memory note (selects how it is recalled/injected). Mirrors
* the backend `type` field; identity of a note is its **slug** (kebab-case).
*/
export type MemoryType = "user" | "feedback" | "project" | "reference";
/**
* A memory note (mirror of the backend `Memory` DTO, camelCase wire format).
* `name` doubles as the human title and the source of the slug identity.
*/
export interface Memory {
name: string;
description: string;
type: MemoryType;
content: string;
}
/**
* One entry of the memory index (mirror of the backend `MemoryIndexEntry`):
* a lightweight, recall-oriented projection of a note.
*/
export interface MemoryIndexEntry {
slug: string;
title: string;
hook: string;
type: MemoryType;
}
/** A resolved `[[wikilink]]` target — the slug of another note. */
export type MemoryLink = string;
// ---------------------------------------------------------------------------
// Embedder (L14 / lot C2) — mirror of the backend `EmbedderProfileDto` and
// `EmbedderEnginesDto`. Drives the memory/embedder settings panel. Identity of
// a profile is its `id`; changing the active embedder takes effect at the next
// app start (the UI says so explicitly).
// ---------------------------------------------------------------------------
/**
* Embedding strategy of an embedder profile (mirror of the backend `strategy`).
* `none` ⇒ no vector tier (naïve recall); the other strategies select where the
* embeddings come from.
*/
export type EmbedderStrategy = "localOnnx" | "localServer" | "api" | "none";
/**
* A declarative embedder profile (mirror of the backend `EmbedderProfile` DTO,
* camelCase wire format). Transparent by design: secrets are never stored here —
* `apiKeyEnv` is the *name* of an environment variable, never the key itself.
*/
export interface EmbedderProfile {
id: string;
name: string;
strategy: EmbedderStrategy;
/** Model id/name (ONNX model, server/api model). Omitted for `none`. */
model?: string;
/** Server/API endpoint URL. Omitted for `localOnnx` / `none`. */
endpoint?: string;
/** Name of the env var holding the API key (api strategy). Never the key. */
apiKeyEnv?: string;
/** Embedding vector dimension. */
dimension: number;
}
/**
* A recommended local ONNX engine (mirror of the backend `recommendedOnnx`
* entry). `e5-small` is the curated default (`recommended: true`).
*/
export interface RecommendedOnnxEngine {
id: string;
displayName: string;
dimension: number;
approxSizeMb: number;
recommended: boolean;
}
/**
* Capabilities/availability snapshot of the embedder engines on this build
* (mirror of the backend `describe_embedder_engines`). The `vector*Enabled`
* flags reflect Cargo feature flags compiled into the build: a strategy whose
* flag is `false` is shown disabled ("not available in this build").
*/
export interface EmbedderEngines {
recommendedOnnx: RecommendedOnnxEngine[];
ollamaDetected: boolean;
onnxCachedModels: string[];
vectorHttpEnabled: boolean;
vectorOnnxEnabled: boolean;
}
// ---------------------------------------------------------------------------
// Templates (L7) — mirror of the domain `Template` / `AgentDrift`.
// ---------------------------------------------------------------------------
/**
* A reusable agent template (mirror of the backend `Template` DTO).
* `version` is incremented on each `updateTemplate` call.
*/
export interface Template {
id: string;
name: string;
contentMd: string;
version: number;
defaultProfileId: string;
}
/**
* Drift between a synchronized agent's last-synced template version and the
* template's current version (mirror of `AgentDrift` backend DTO).
*/
export interface AgentDrift {
agentId: string;
from: number;
to: number;
}
// ---------------------------------------------------------------------------
// Git (L8) — UI-domain types for the git feature.
// ---------------------------------------------------------------------------
/** A file in the working tree with its staging state. */
export interface GitFileStatus {
path: string;
staged: boolean;
}
/** A git commit summary (short representation). */
export interface GitCommit {
hash: string;
summary: string;
}
/** The list of branches and the currently checked-out branch. */
export interface GitBranches {
branches: string[];
current: string | null;
}
// ---------------------------------------------------------------------------
// Tickets (public wire name for the backend `Issue` model — "ticket" is the
// user-visible term; all commands/DTOs/events speak `ticket`/`issue`). Mirrors
// the `Ticket*Dto` shapes in `crates/app-tauri/src/tickets.rs` (camelCase).
// ---------------------------------------------------------------------------
/** A ticket reference on the wire, always `#<n>` (e.g. `"#42"`). */
export type TicketRef = string;
/** Lifecycle status of a ticket. */
export type TicketStatus = "open" | "inProgress" | "QA" | "closed";
/** Priority of a ticket. */
export type TicketPriority = "low" | "medium" | "high" | "critical";
/** The kind of relationship between two tickets. */
export type TicketLinkKind =
| "relatesTo"
| "blocks"
| "blockedBy"
| "duplicates"
| "dependsOn";
/** Who performed a ticket mutation (mirror of `TicketActorDto`, tagged `kind`). */
export type TicketActor =
| { kind: "user" }
| { kind: "agent"; agentId: string }
| { kind: "system" };
/** One directed link from a ticket to another (mirror of `TicketLinkDto`). */
export interface TicketLink {
targetRef: TicketRef;
kind: TicketLinkKind;
}
/** Lifecycle status of a sprint (mirror of the backend `SprintStatus`). */
export type SprintStatus = "planned" | "active" | "done";
/** A sprint bucket tickets can belong to (mirror of `SprintDto`). */
export interface Sprint {
id: string;
/** Reorderable position; sections are displayed by ascending `order`. */
order: number;
name: string;
status: SprintStatus;
/** How many tickets currently belong to this sprint. */
ticketCount: number;
/** Optimistic-concurrency version. */
version: number;
}
/** The full ticket, as returned by `ticket_read`/`ticket_create`/… . */
export interface Ticket {
id: string;
ref: TicketRef;
number: number;
title: string;
description: string;
status: TicketStatus;
priority: TicketPriority;
/** Sprint membership (`null` ⇒ no sprint). Additive (ticket #10). */
sprintId?: string | null;
/** Present only when the ticket was read with `includeCarnet`. */
carnet?: string;
links: TicketLink[];
assignedAgentIds: string[];
createdBy: TicketActor;
updatedBy: TicketActor;
createdAt: number;
updatedAt: number;
/** Optimistic-concurrency version; echoed back as `expectedVersion` on writes. */
version: number;
}
/** A ticket summary row from `ticket_list` (mirror of `TicketSummaryDto`). */
export interface TicketSummary {
ref: TicketRef;
path: string;
title: string;
status: TicketStatus;
priority: TicketPriority;
/** Sprint membership (`null` ⇒ no sprint). Additive (ticket #10). */
sprintId?: string | null;
assignedAgentIds: string[];
updatedAt: number;
}
/** One page of ticket summaries (mirror of `TicketListDto`). */
export interface TicketList {
items: TicketSummary[];
/** Opaque cursor for the next page, absent when the list is exhausted. */
nextCursor?: string;
}
/** The scoped-to-a-ticket Markdown carnet (mirror of `TicketCarnetDto`). */
export interface TicketCarnet {
ref: TicketRef;
carnet: string;
version: number;
}
/**
* A ticket AI assistant chat session (mirror of the backend `TicketChatDto`,
* ticket #8). `sessionId` is the live structured session driving the assistant;
* `requester` is the assistant agent id; `issueRef` the bound ticket.
*/
export interface TicketChat {
sessionId: string;
requester: string;
issueRef: TicketRef;
}
/**
* One streamed chunk of an assistant turn (mirror of the backend `ReplyChunk`,
* tagged on `kind`). `textDelta` is an incremental text fragment; `toolActivity`
* a best-effort activity badge; `final` the deterministic end-of-turn chunk
* carrying the aggregated content — after it the turn is frozen. `error` is a
* visible terminal fallback (ticket #60): the model produced no usable answer,
* so the turn ends with a human-readable explanation instead of a silent hang.
*/
export type ReplyChunk =
| { kind: "textDelta"; text: string }
| { kind: "toolActivity"; label: string }
| { kind: "final"; content: string }
| { kind: "error"; message: string };
// ---------------------------------------------------------------------------
// Paired devices + pairing code (ticket #77)
// ---------------------------------------------------------------------------
/**
* One device paired with this IdeA instance (mirror of the backend device DTO).
*
* Deliberately carries **no IP and no User-Agent**: the design is mono-user and
* the list is an access-management surface, not a forensics log. `name` is the
* human label typed on the device itself at pairing time.
*/
export interface PairedDevice {
deviceId: string;
name: string;
/** Epoch milliseconds the device was paired. */
pairedAtMs: number;
/** Epoch milliseconds of the device's last authenticated request. */
lastSeenAtMs: number;
/** True for the device rendering this list (never true on desktop). */
isCurrentDevice: boolean;
}
/**
* A freshly generated, single-use pairing code (mirror of the backend DTO).
*
* `code` is the **canonical** value — uppercase hex, no separator. Any grouping
* shown to the user is presentation only; see {@link normalizePairingCode}.
*/
export interface PairingCode {
code: string;
/** Epoch milliseconds the code stops being accepted. */
expiresAtMs: number;
/** Lifetime granted at generation (600 s today). */
ttlSeconds: number;
}
/**
* Canonical form of a pairing code as the server compares it: uppercase, with
* spaces **and dashes** removed.
*
* Both separators matter. The UI groups the code visually (`AB12 CD34`) and a
* user may retype it with a dash out of habit, so a code copied by eye must
* still pair. The server normalises too (#76) — this keeps the client honest
* rather than being the only line of defence.
*/
export function normalizePairingCode(raw: string): string {
return raw.replace(/[\s-]+/g, "").toUpperCase();
}