feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé

- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:24:51 +02:00
parent 32398827fb
commit 785e9935fd
118 changed files with 5793 additions and 866 deletions

View File

@ -116,6 +116,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
* set by the hook once launchAgent resolves.
*/
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const [panelNodeIds, setPanelNodeIds] = useState<Record<string, string>>({});
/**
* Live PTY session id of the running agent terminal, keyed by agent id. Lets
@ -149,11 +150,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
}
function handleLaunch(agentId: string) {
setPanelNodeIds((prev) => ({
...prev,
[agentId]: prev[agentId] ?? crypto.randomUUID(),
}));
setActiveAgentId(agentId);
}
function handleStop() {
vm.stopAgent();
function handleStop(agentId?: string) {
void vm.stopAgent(agentId);
setActiveAgentId(null);
}
@ -288,6 +293,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
{vm.agents.map((a) => {
const isSelected = a.id === vm.selectedAgentId;
const isRunning = a.id === activeAgentId;
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
const profileName =
vm.profiles.find((p) => p.id === a.profileId)?.name ??
a.profileId;
@ -318,6 +324,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
)}
</span>
<span className="text-xs text-muted">{profileName}</span>
{live && (
<span className="text-xs text-primary">
running in IdeA · {live.sessionId ?? live.nodeId}
</span>
)}
</button>
<div className="flex items-center gap-1.5 shrink-0">
@ -334,12 +345,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
Sync
</Button>
)}
{isRunning ? (
{isRunning || live ? (
<Button
size="sm"
variant="ghost"
aria-label={`stop ${a.name}`}
onClick={handleStop}
onClick={() => handleStop(a.id)}
className="text-danger hover:text-danger"
>
Stop
@ -381,7 +392,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
key={activeAgentId}
cwd={projectRoot}
open={(opts, onData) =>
vm.launchAgent(activeAgentId, opts, onData)
vm.launchAgent(
activeAgentId,
{ ...opts, nodeId: panelNodeIds[activeAgentId] },
onData,
)
}
reattach={(sessionId, onData) =>
gateways.agent.reattach(sessionId, onData)

View File

@ -10,7 +10,7 @@
import { useCallback, useEffect, useState } from "react";
import type { Agent, AgentProfile, GatewayError } from "@/domain";
import type { OpenTerminalOptions, TerminalHandle } from "@/ports";
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
import { useGateways } from "@/app/di";
/** What the agents UI needs from this hook. */
@ -23,6 +23,8 @@ export interface AgentsViewModel {
context: string;
/** Profiles available for assigning to a new agent. */
profiles: AgentProfile[];
/** Agents that currently own a live PTY session, as tracked by IdeA. */
liveAgents: LiveAgent[];
/** Last error message, or `null`. */
error: string | null;
/** Whether a request is in flight. */
@ -34,6 +36,8 @@ export interface AgentsViewModel {
runningAgentId: string | null;
/** Reloads the agent list. */
refresh: () => Promise<void>;
/** Reloads the currently-live agent sessions. */
refreshLiveAgents: () => Promise<void>;
/** Creates a new agent and refreshes the list. */
createAgent: (name: string, profileId: string, initialContent?: string) => Promise<void>;
/** Selects an agent and loads its context. */
@ -52,8 +56,8 @@ export interface AgentsViewModel {
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
) => Promise<TerminalHandle>;
/** Clears `runningAgentId` (called by the Stop button to unmount the terminal). */
stopAgent: () => void;
/** Stops an agent PTY explicitly by closing its live session id. */
stopAgent: (agentId?: string) => Promise<void>;
}
function describe(e: unknown): string {
@ -64,12 +68,13 @@ function describe(e: unknown): string {
}
export function useAgents(projectId: string): AgentsViewModel {
const { agent, profile, system } = useGateways();
const { agent, profile, system, terminal } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [context, setContext] = useState<string>("");
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
const [liveAgents, setLiveAgents] = useState<LiveAgent[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [runningAgentId, setRunningAgentId] = useState<string | null>(null);
@ -91,10 +96,22 @@ export function useAgents(projectId: string): AgentsViewModel {
}
}, [agent, profile, projectId]);
const refreshLiveAgents = useCallback(async () => {
try {
setLiveAgents(await agent.listLiveAgents(projectId));
} catch {
setLiveAgents([]);
}
}, [agent, projectId]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
void refreshLiveAgents();
}, [refreshLiveAgents]);
// Live refresh: the agents list is otherwise only reloaded on mount or after a
// UI-driven CRUD. An agent created/launched/stopped *out of band* — most
// notably by the orchestrator (`spawn_agent`, ARCHITECTURE §14.3) when an AI
@ -109,6 +126,7 @@ export function useAgents(projectId: string): AgentsViewModel {
.onDomainEvent((event) => {
if (event.type === "agentLaunched" || event.type === "agentExited") {
void refresh();
void refreshLiveAgents();
}
})
.then((un) => {
@ -119,7 +137,7 @@ export function useAgents(projectId: string): AgentsViewModel {
cancelled = true;
unsubscribe?.();
};
}, [system, refresh]);
}, [system, refresh, refreshLiveAgents]);
const createAgent = useCallback(
async (name: string, profileId: string, initialContent?: string) => {
@ -205,28 +223,51 @@ export function useAgents(projectId: string): AgentsViewModel {
try {
const handle = await agent.launchAgent(projectId, agentId, options, onData);
setRunningAgentId(agentId);
void refreshLiveAgents();
return handle;
} catch (e) {
setError(describe(e));
throw e;
}
},
[agent, projectId],
[agent, projectId, refreshLiveAgents],
);
const stopAgent = useCallback(() => {
setRunningAgentId(null);
}, []);
const stopAgent = useCallback(
async (agentId?: string) => {
const targetAgentId = agentId ?? runningAgentId;
const live = targetAgentId
? liveAgents.find((candidate) => candidate.agentId === targetAgentId)
: null;
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();
}
}
},
[liveAgents, refreshLiveAgents, runningAgentId, terminal],
);
return {
agents,
selectedAgentId,
context,
profiles,
liveAgents,
error,
busy,
runningAgentId,
refresh,
refreshLiveAgents,
createAgent,
selectAgent,
saveContext,

View File

@ -0,0 +1,481 @@
/**
* `EmbedderSettings` — memory/embedder configuration panel (L14 / lot C2).
*
* Pure presentation: all behaviour comes from {@link useEmbedder}. Styled with
* `@/shared` design system tokens; no inline styles, no `invoke()`.
*
* Product spirit ("Linux: nothing imposed"):
* - All strategies are listed on an equal footing: None, ONNX local, Ollama
* (localServer), API, Custom.
* - `e5-small` (ONNX) is only *pre-selected* as the recommended default — the
* cursor sits on it, but nothing is written until the user clicks "Save".
* - A strategy whose Cargo feature flag is `false` (vectorOnnxEnabled /
* vectorHttpEnabled) is shown disabled with "not available in this build".
* `none` is always available.
* - "Back to None" is always present.
* - The change takes effect at the next app start — the panel says so.
*/
import { useEffect, useMemo, useState } from "react";
import type {
EmbedderProfile,
EmbedderStrategy,
RecommendedOnnxEngine,
} from "@/domain";
import { Button, Field, Input, Panel, Spinner, cn } from "@/shared";
import { useEmbedder } from "./useEmbedder";
/** Default endpoint placeholder for a local embedding server (Ollama). */
const LOCAL_SERVER_PLACEHOLDER = "http://localhost:11434/api/embeddings";
const API_ENDPOINT_PLACEHOLDER = "https://api.openai.com/v1/embeddings";
/** The selectable strategies, listed on equal footing. `none` is always last. */
interface StrategyOption {
strategy: EmbedderStrategy;
/** Stable selection key (custom shares the `api` strategy but its own form). */
key: string;
label: string;
}
const STRATEGY_OPTIONS: StrategyOption[] = [
{ strategy: "none", key: "none", label: "None (naïve recall)" },
{ strategy: "localOnnx", key: "localOnnx", label: "Local ONNX" },
{ strategy: "localServer", key: "localServer", label: "Ollama (local server)" },
{ strategy: "api", key: "api", label: "API" },
{ strategy: "api", key: "custom", label: "Custom" },
];
/** Editable draft of the form, before it is turned into a profile and saved. */
interface Draft {
id: string;
name: string;
model: string;
endpoint: string;
apiKeyEnv: string;
dimension: string;
/** Selected recommended ONNX engine id (localOnnx). */
onnxEngineId: string;
}
const EMPTY_DRAFT: Draft = {
id: "",
name: "",
model: "",
endpoint: "",
apiKeyEnv: "",
dimension: "",
onnxEngineId: "",
};
/** Builds the initial draft for a selection key, given the available engines. */
function draftFor(
key: string,
recommendedOnnx: RecommendedOnnxEngine[],
existing: EmbedderProfile | null,
): Draft {
if (existing) {
return {
id: existing.id,
name: existing.name,
model: existing.model ?? "",
endpoint: existing.endpoint ?? "",
apiKeyEnv: existing.apiKeyEnv ?? "",
dimension: String(existing.dimension),
onnxEngineId:
key === "localOnnx"
? recommendedOnnx.find((e) => e.id === existing.model)?.id ??
recommendedOnnx[0]?.id ??
""
: "",
};
}
switch (key) {
case "localOnnx": {
const reco =
recommendedOnnx.find((e) => e.recommended) ?? recommendedOnnx[0];
return {
...EMPTY_DRAFT,
id: "onnx-local",
name: reco?.displayName ?? "Local ONNX",
model: reco?.id ?? "",
dimension: reco ? String(reco.dimension) : "",
onnxEngineId: reco?.id ?? "",
};
}
case "localServer":
return {
...EMPTY_DRAFT,
id: "ollama",
name: "Ollama",
endpoint: LOCAL_SERVER_PLACEHOLDER,
model: "nomic-embed-text",
dimension: "768",
};
case "api":
return {
...EMPTY_DRAFT,
id: "api",
name: "API embedder",
endpoint: API_ENDPOINT_PLACEHOLDER,
apiKeyEnv: "OPENAI_API_KEY",
model: "text-embedding-3-small",
dimension: "1536",
};
case "custom":
return { ...EMPTY_DRAFT, id: "custom", name: "Custom embedder" };
default:
return { ...EMPTY_DRAFT };
}
}
/** Whether a strategy is compiled into this build (None always is). */
function strategyEnabled(
strategy: EmbedderStrategy,
flags: { vectorOnnxEnabled: boolean; vectorHttpEnabled: boolean },
): boolean {
switch (strategy) {
case "none":
return true;
case "localOnnx":
return flags.vectorOnnxEnabled;
case "localServer":
case "api":
return flags.vectorHttpEnabled;
}
}
export function EmbedderSettings() {
const vm = useEmbedder();
// Selection cursor; pre-selected on the recommended ONNX engine. Writing
// happens only on "Save".
const [selectedKey, setSelectedKey] = useState<string>("localOnnx");
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
const recommendedOnnx = vm.engines?.recommendedOnnx ?? [];
const flags = {
vectorOnnxEnabled: vm.engines?.vectorOnnxEnabled ?? false,
vectorHttpEnabled: vm.engines?.vectorHttpEnabled ?? false,
};
// Once the engines load, seed the draft for the current selection (mirroring
// an existing active profile if its strategy matches the selection).
useEffect(() => {
if (!vm.engines) return;
const opt = STRATEGY_OPTIONS.find((o) => o.key === selectedKey);
const existing =
vm.active && opt && vm.active.strategy === opt.strategy ? vm.active : null;
setDraft(draftFor(selectedKey, vm.engines.recommendedOnnx, existing));
// We intentionally re-seed only when engines / selection / active change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vm.engines, vm.active, selectedKey]);
const selectedOption = STRATEGY_OPTIONS.find((o) => o.key === selectedKey)!;
const selectedStrategy = selectedOption.strategy;
function patch(p: Partial<Draft>) {
setDraft((d) => ({ ...d, ...p }));
}
function selectOnnxEngine(id: string) {
const eng = recommendedOnnx.find((e) => e.id === id);
patch({
onnxEngineId: id,
model: eng?.id ?? id,
// Dimension is auto-filled from the chosen model (e5-small ⇒ 384).
dimension: eng ? String(eng.dimension) : draft.dimension,
});
}
// ── UI-side validation, aligned with the backend (avoid an INVALID error) ──
const dimensionNum = Number.parseInt(draft.dimension, 10);
const validation = useMemo(() => {
if (selectedStrategy === "none") return { ok: true as const };
if (!draft.id.trim()) return { ok: false as const, msg: "Id is required." };
if (!draft.name.trim())
return { ok: false as const, msg: "Name is required." };
if (!Number.isFinite(dimensionNum) || dimensionNum <= 0)
return { ok: false as const, msg: "Dimension must be a positive number." };
if (selectedStrategy === "localServer" || selectedStrategy === "api") {
if (!draft.endpoint.trim())
return { ok: false as const, msg: "Endpoint is required." };
}
return { ok: true as const };
}, [selectedStrategy, draft, dimensionNum]);
function toProfile(): EmbedderProfile {
return {
id: draft.id.trim(),
name: draft.name.trim(),
strategy: selectedStrategy,
dimension: dimensionNum,
model: draft.model.trim() || undefined,
endpoint:
selectedStrategy === "localServer" || selectedStrategy === "api"
? draft.endpoint.trim() || undefined
: undefined,
apiKeyEnv:
selectedStrategy === "api"
? draft.apiKeyEnv.trim() || undefined
: undefined,
};
}
async function handleSave() {
if (!validation.ok) return;
await vm.saveProfile(toProfile());
}
async function handleBackToNone() {
// Delete every configured (non-none) profile so recall falls back to naïve.
for (const p of vm.profiles) {
await vm.deleteProfile(p.id);
}
setSelectedKey("none");
}
const selectClass = cn(
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
);
const hasActive = vm.active !== null;
return (
<Panel title="Memory / Embedder" className="flex flex-col gap-0">
{vm.error && (
<p
role="alert"
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
<div className="flex flex-col gap-4 p-4">
{/* ── Restart notice ── */}
<p
className="rounded-md border border-border bg-raised px-3 py-2 text-xs text-muted"
data-testid="embedder-restart-note"
>
The embedder change takes effect at the next app start.
</p>
{/* ── Active tier (current) ── */}
<p className="text-xs text-faint" data-testid="embedder-active">
Current:{" "}
{hasActive ? (
<span className="text-content">
Vector {vm.active!.strategy}
{vm.active!.model ? ` ${vm.active!.model}` : ""}
</span>
) : (
<span className="text-content">Naïve (None)</span>
)}
</p>
{/* ── Strategy chooser ── */}
<fieldset className="flex flex-col gap-2" aria-label="embedder strategy">
<legend className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
Strategy
</legend>
{STRATEGY_OPTIONS.map((opt) => {
const enabled = strategyEnabled(opt.strategy, flags);
const recommended = opt.key === "localOnnx";
return (
<label
key={opt.key}
className={cn(
"flex items-center gap-2 rounded-md border px-3 py-2 text-sm",
selectedKey === opt.key
? "border-primary bg-raised"
: "border-border",
!enabled && "opacity-50",
)}
data-testid={`embedder-strategy-${opt.key}`}
>
<input
type="radio"
name="embedder-strategy"
value={opt.key}
checked={selectedKey === opt.key}
disabled={!enabled}
onChange={() => setSelectedKey(opt.key)}
aria-label={opt.label}
/>
<span className="text-content">{opt.label}</span>
{recommended && (
<span className="rounded border border-primary/40 bg-primary/10 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-primary">
Recommended
</span>
)}
{!enabled && (
<span className="text-[11px] text-faint">
not available in this build
</span>
)}
</label>
);
})}
</fieldset>
{/* ── Conditional fields per strategy ── */}
{selectedStrategy !== "none" && (
<div className="flex flex-col gap-3">
{/* localOnnx → model picker + auto dimension */}
{selectedStrategy === "localOnnx" && (
<Field label="ONNX model">
{({ id }) => (
<select
id={id}
aria-label="onnx model"
className={selectClass}
value={draft.onnxEngineId}
disabled={vm.busy}
onChange={(e) => selectOnnxEngine(e.target.value)}
>
{recommendedOnnx.map((eng) => (
<option key={eng.id} value={eng.id}>
{eng.displayName}
{eng.recommended ? " — recommended" : ""} (dim{" "}
{eng.dimension}, ~{eng.approxSizeMb} MB)
</option>
))}
</select>
)}
</Field>
)}
{/* localServer / api → endpoint */}
{(selectedStrategy === "localServer" ||
selectedStrategy === "api") && (
<Field label="Endpoint">
{({ id }) => (
<Input
id={id}
aria-label="endpoint"
placeholder={
selectedStrategy === "localServer"
? LOCAL_SERVER_PLACEHOLDER
: API_ENDPOINT_PLACEHOLDER
}
value={draft.endpoint}
disabled={vm.busy}
onChange={(e) => patch({ endpoint: e.target.value })}
/>
)}
</Field>
)}
{/* api → apiKeyEnv (variable NAME, never the key) */}
{selectedStrategy === "api" && (
<Field
label="API key environment variable"
hint="Name of the env var holding the key — never the key itself."
>
{({ id, describedBy }) => (
<Input
id={id}
aria-label="api key env var"
placeholder="OPENAI_API_KEY"
aria-describedby={describedBy}
value={draft.apiKeyEnv}
disabled={vm.busy}
onChange={(e) => patch({ apiKeyEnv: e.target.value })}
/>
)}
</Field>
)}
{/* model (localServer / api / custom) */}
{selectedStrategy !== "localOnnx" && (
<Field label="Model">
{({ id }) => (
<Input
id={id}
aria-label="model"
placeholder="model name"
value={draft.model}
disabled={vm.busy}
onChange={(e) => patch({ model: e.target.value })}
/>
)}
</Field>
)}
{/* dimension — auto-filled for ONNX, editable everywhere */}
<Field
label="Dimension"
error={!validation.ok ? validation.msg : undefined}
>
{({ id, describedBy }) => (
<Input
id={id}
aria-label="dimension"
type="number"
min={1}
value={draft.dimension}
aria-describedby={describedBy}
disabled={vm.busy}
onChange={(e) => patch({ dimension: e.target.value })}
/>
)}
</Field>
{/* id / name (custom & advanced) */}
<Field label="Profile id">
{({ id }) => (
<Input
id={id}
aria-label="profile id"
placeholder="my-embedder"
value={draft.id}
disabled={vm.busy}
onChange={(e) => patch({ id: e.target.value })}
/>
)}
</Field>
<Field label="Profile name">
{({ id }) => (
<Input
id={id}
aria-label="profile name"
placeholder="My embedder"
value={draft.name}
disabled={vm.busy}
onChange={(e) => patch({ name: e.target.value })}
/>
)}
</Field>
</div>
)}
{/* ── Actions ── */}
<div className="flex items-center gap-2 border-t border-border pt-3">
{vm.busy && <Spinner size={14} />}
{selectedStrategy !== "none" && (
<Button
type="button"
variant="primary"
aria-label="save embedder"
disabled={vm.busy || !validation.ok}
onClick={() => void handleSave()}
>
Save
</Button>
)}
<Button
type="button"
aria-label="back to none"
disabled={vm.busy}
onClick={() => void handleBackToNone()}
>
Back to None
</Button>
</div>
</div>
</Panel>
);
}

View File

@ -0,0 +1,237 @@
/**
* L14 / lot C2 — embedder settings feature wired to the stateful
* `MockEmbedderGateway` via the real `DIProvider` (same harness as
* `memory.test.tsx`).
*
* Covers:
* - the strategy list renders on an equal footing (None, ONNX, Ollama, API,
* Custom) with the "Recommended" badge on ONNX
* - conditional fields per strategy (ONNX model picker + auto dimension;
* localServer endpoint; api endpoint + apiKeyEnv label; none → no fields)
* - a strategy whose build flag is false is shown disabled with
* "not available in this build" (None stays available)
* - Save calls the gateway with the right profile
* - "Back to None" deletes the configured profiles
*
* Plus the active-tier transparency line in MemoryPanel (Naïve vs Vector).
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { MockEmbedderGateway, MockMemoryGateway } from "@/adapters/mock";
import type { EmbedderEngines, EmbedderProfile } from "@/domain";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { MemoryPanel } from "@/features/memory";
import { EmbedderSettings } from "./EmbedderSettings";
function renderEmbedder(
engines?: Partial<EmbedderEngines>,
seed: EmbedderProfile[] = [],
) {
const embedder = new MockEmbedderGateway(engines, seed);
const gateways = { embedder } as unknown as Gateways;
return {
embedder,
...render(
<DIProvider gateways={gateways}>
<EmbedderSettings />
</DIProvider>,
),
};
}
async function waitForEmbedderIdle() {
await waitFor(() => {
expect(
(screen.getByRole("button", { name: "back to none" }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
}
describe("EmbedderSettings (with MockEmbedderGateway)", () => {
it("renders the strategy list on an equal footing with a Recommended badge", async () => {
renderEmbedder();
await waitForEmbedderIdle();
// Every strategy is offered.
expect(screen.getByTestId("embedder-strategy-none")).toBeTruthy();
expect(screen.getByTestId("embedder-strategy-localOnnx")).toBeTruthy();
expect(screen.getByTestId("embedder-strategy-localServer")).toBeTruthy();
expect(screen.getByTestId("embedder-strategy-api")).toBeTruthy();
expect(screen.getByTestId("embedder-strategy-custom")).toBeTruthy();
// ONNX carries the "Recommended" badge.
expect(screen.getByText("Recommended")).toBeTruthy();
// The change-takes-effect notice is visible.
expect(screen.getByTestId("embedder-restart-note").textContent).toMatch(
/next app start/i,
);
});
it("pre-selects ONNX with the recommended model and auto-fills its dimension (384)", async () => {
renderEmbedder();
await waitForEmbedderIdle();
// ONNX is the default cursor → its model picker is shown.
const modelSelect = (await screen.findByLabelText(
"onnx model",
)) as HTMLSelectElement;
expect(modelSelect.value).toBe("e5-small");
// Dimension auto-filled from the model.
expect((screen.getByLabelText("dimension") as HTMLInputElement).value).toBe(
"384",
);
});
it("shows endpoint for localServer and endpoint + apiKeyEnv (named, not the key) for api", async () => {
renderEmbedder();
await waitForEmbedderIdle();
// localServer → endpoint, no apiKeyEnv.
fireEvent.click(
screen.getByRole("radio", { name: "Ollama (local server)" }),
);
await waitFor(() => expect(screen.getByLabelText("endpoint")).toBeTruthy());
expect(screen.queryByLabelText("api key env var")).toBeNull();
// api → endpoint + apiKeyEnv with an explicit "env var" label.
fireEvent.click(screen.getByRole("radio", { name: "API" }));
await waitFor(() =>
expect(screen.getByLabelText("api key env var")).toBeTruthy(),
);
expect(screen.getByText(/environment variable/i)).toBeTruthy();
expect(screen.getByText(/never the key itself/i)).toBeTruthy();
});
it("renders no conditional fields for None", async () => {
renderEmbedder();
await waitForEmbedderIdle();
fireEvent.click(screen.getByRole("radio", { name: "None (naïve recall)" }));
await waitFor(() =>
expect(screen.queryByLabelText("dimension")).toBeNull(),
);
expect(screen.queryByLabelText("onnx model")).toBeNull();
// No Save button in None mode (only "Back to None").
expect(screen.queryByRole("button", { name: "save embedder" })).toBeNull();
});
it("disables strategies whose build flag is false with a 'not available' note", async () => {
renderEmbedder({ vectorOnnxEnabled: false, vectorHttpEnabled: false });
await waitForEmbedderIdle();
const onnxRadio = screen.getByRole("radio", {
name: "Local ONNX",
}) as HTMLInputElement;
const ollamaRadio = screen.getByRole("radio", {
name: "Ollama (local server)",
}) as HTMLInputElement;
const apiRadio = screen.getByRole("radio", { name: "API" }) as HTMLInputElement;
const noneRadio = screen.getByRole("radio", {
name: "None (naïve recall)",
}) as HTMLInputElement;
expect(onnxRadio.disabled).toBe(true);
expect(ollamaRadio.disabled).toBe(true);
expect(apiRadio.disabled).toBe(true);
// None is always available.
expect(noneRadio.disabled).toBe(false);
// The honesty note appears.
expect(
screen.getAllByText("not available in this build").length,
).toBeGreaterThan(0);
});
it("Save sends the recommended ONNX profile (e5-small, dim 384)", async () => {
const { embedder } = renderEmbedder();
const spy = vi.spyOn(embedder, "saveEmbedderProfile");
await waitForEmbedderIdle();
fireEvent.click(screen.getByRole("button", { name: "save embedder" }));
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
const saved = spy.mock.calls[0][0];
expect(saved.strategy).toBe("localOnnx");
expect(saved.model).toBe("e5-small");
expect(saved.dimension).toBe(384);
expect(saved.id.length).toBeGreaterThan(0);
expect(saved.name.length).toBeGreaterThan(0);
// ONNX never carries an endpoint/apiKeyEnv.
expect(saved.endpoint).toBeUndefined();
expect(saved.apiKeyEnv).toBeUndefined();
});
it("Save for api sends endpoint + apiKeyEnv (the env var NAME)", async () => {
const { embedder } = renderEmbedder();
const spy = vi.spyOn(embedder, "saveEmbedderProfile");
await waitForEmbedderIdle();
fireEvent.click(screen.getByRole("radio", { name: "API" }));
await waitFor(() =>
expect(screen.getByLabelText("api key env var")).toBeTruthy(),
);
fireEvent.change(screen.getByLabelText("api key env var"), {
target: { value: "MY_KEY_VAR" },
});
fireEvent.click(screen.getByRole("button", { name: "save embedder" }));
await waitFor(() => expect(spy).toHaveBeenCalled());
const saved = spy.mock.calls.at(-1)![0];
expect(saved.strategy).toBe("api");
expect(saved.apiKeyEnv).toBe("MY_KEY_VAR");
expect(saved.endpoint).toBeTruthy();
});
it("'Back to None' deletes the configured profiles", async () => {
const seed: EmbedderProfile[] = [
{ id: "onnx-local", name: "Local ONNX", strategy: "localOnnx", model: "e5-small", dimension: 384 },
];
const { embedder } = renderEmbedder(undefined, seed);
const spy = vi.spyOn(embedder, "deleteEmbedderProfile");
await waitForEmbedderIdle();
fireEvent.click(screen.getByRole("button", { name: "back to none" }));
await waitFor(() => expect(spy).toHaveBeenCalledWith("onnx-local"));
expect(await embedder.listEmbedderProfiles()).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// MemoryPanel — active recall tier transparency
// ---------------------------------------------------------------------------
describe("MemoryPanel active tier (transparency)", () => {
function renderPanel(embedder: MockEmbedderGateway) {
const gateways = {
memory: new MockMemoryGateway(),
embedder,
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<MemoryPanel projectId="p" />
</DIProvider>,
);
}
it("shows 'Naïve (None)' when no embedder is configured", async () => {
renderPanel(new MockEmbedderGateway());
const tier = await screen.findByTestId("memory-tier");
await waitFor(() => expect(tier.textContent).toMatch(/Naïve \(None\)/));
});
it("shows 'Vector — <strategy> <model>' when an embedder is configured", async () => {
const embedder = new MockEmbedderGateway(undefined, [
{ id: "onnx-local", name: "Local ONNX", strategy: "localOnnx", model: "e5-small", dimension: 384 },
]);
renderPanel(embedder);
const tier = await screen.findByTestId("memory-tier");
await waitFor(() => expect(tier.textContent).toMatch(/Vector — localOnnx e5-small/));
});
});

View File

@ -0,0 +1,4 @@
/** Public surface of the embedder feature (L14 / lot C2). */
export { EmbedderSettings } from "./EmbedderSettings";
export { useEmbedder } from "./useEmbedder";
export type { EmbedderViewModel } from "./useEmbedder";

View File

@ -0,0 +1,118 @@
/**
* `useEmbedder` — view-model hook for the memory/embedder settings (L14 / C2).
*
* Owns the configured embedder profiles and the available engines (with the
* build-time feature flags). Consumes {@link EmbedderGateway} exclusively; never
* touches `invoke()` or `@tauri-apps/api`, keeping the component layer testable
* with mock gateways (ARCHITECTURE §1.3).
*
* Note: the embedder change takes effect at the *next app start* — this hook
* just persists the choice; it does not hot-swap the live recall tier.
*/
import { useCallback, useEffect, useState } from "react";
import type { EmbedderEngines, EmbedderProfile, GatewayError } from "@/domain";
import { useGateways } from "@/app/di";
/** What the embedder settings UI needs from this hook. */
export interface EmbedderViewModel {
/** The configured embedder profiles. */
profiles: EmbedderProfile[];
/** The available engines + build feature flags, or `null` until loaded. */
engines: EmbedderEngines | null;
/** The single active profile (first configured), or `null` (naïve/None). */
active: EmbedderProfile | null;
/** Last error message, or `null`. */
error: string | null;
/** Whether a request is in flight. */
busy: boolean;
/** Reloads profiles + engines. */
refresh: () => Promise<void>;
/** Persists a profile (create/replace by id) and refreshes. */
saveProfile: (profile: EmbedderProfile) => Promise<void>;
/** Deletes a profile by id and refreshes. */
deleteProfile: (embedderId: string) => Promise<void>;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
export function useEmbedder(): EmbedderViewModel {
const { embedder } = useGateways();
const [profiles, setProfiles] = useState<EmbedderProfile[]>([]);
const [engines, setEngines] = useState<EmbedderEngines | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
const [list, eng] = await Promise.all([
embedder.listEmbedderProfiles(),
embedder.describeEmbedderEngines(),
]);
setProfiles(list);
setEngines(eng);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [embedder]);
useEffect(() => {
void refresh();
}, [refresh]);
const saveProfile = useCallback(
async (profile: EmbedderProfile) => {
setBusy(true);
setError(null);
try {
await embedder.saveEmbedderProfile(profile);
await refresh();
} catch (e) {
setError(describe(e));
setBusy(false);
}
},
[embedder, refresh],
);
const deleteProfile = useCallback(
async (embedderId: string) => {
setBusy(true);
setError(null);
try {
await embedder.deleteEmbedderProfile(embedderId);
await refresh();
} catch (e) {
setError(describe(e));
setBusy(false);
}
},
[embedder, refresh],
);
// The active embedder is the first configured profile (the backend keeps a
// single active embedder); `null` ⇒ naïve recall (None).
const active = profiles.find((p) => p.strategy !== "none") ?? null;
return {
profiles,
engines,
active,
error,
busy,
refresh,
saveProfile,
deleteProfile,
};
}

View File

@ -8,7 +8,7 @@
* rather than xterm's visual output. They stay robust whether or not xterm
* bailed.
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Gateways } from "@/ports";
@ -90,8 +90,10 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
});
});
it("closing a cell removes THAT cell and keeps its sibling (closes the right terminal)", async () => {
it("closing a cell removes THAT cell and keeps its sibling without killing its session", async () => {
const layout = new MockLayoutGateway();
const terminal = new MockTerminalGateway();
const closeSpy = vi.spyOn(terminal, "closeTerminal");
const initial = await layout.loadLayout("p1");
const a = leaves(initial)[0].id;
// Split A → container c with children [A (index 0), b (index 1)].
@ -102,8 +104,18 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
newLeaf: "b",
container: "c",
});
await layout.mutateLayout("p1", {
type: "setSession",
target: "b",
session: "running-session-b",
});
renderGrid(layout);
const gateways = { layout, terminal } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2),
);
@ -117,6 +129,7 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
expect(
screen.getByTestId("layout-leaf").getAttribute("data-node-id"),
).toBe(a);
expect(closeSpy).not.toHaveBeenCalled();
});
it("closing the other cell keeps the opposite sibling", async () => {

View File

@ -28,7 +28,7 @@ import type {
} from "@/ports";
import { ResumeConversationPopup, TerminalView } from "@/features/terminals";
import { useGateways } from "@/app/di";
import { normalizeWeights, resizeAdjacent } from "./layout";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";
interface LayoutGridProps {
@ -56,6 +56,7 @@ export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
</div>
);
}
const visibleNodeIds = new Set(leaves(vm.layout).map((l) => l.id));
return (
<div
@ -73,6 +74,7 @@ export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
vm={vm}
parentSplit={null}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
</div>
);
@ -85,9 +87,10 @@ interface NodeViewProps {
/** The enclosing split + this node's index in it, for the merge action. */
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
visibleNodeIds: Set<string>;
}
function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: NodeViewProps) {
switch (node.type) {
case "leaf":
return (
@ -101,12 +104,29 @@ function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
vm={vm}
parentSplit={parentSplit}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
);
case "split":
return <SplitView split={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
return (
<SplitView
split={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
);
case "grid":
return <GridView grid={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
return (
<GridView
grid={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
);
}
}
@ -120,6 +140,7 @@ interface LeafViewProps {
vm: LayoutViewModel;
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
visibleNodeIds: Set<string>;
}
/**
@ -135,7 +156,7 @@ interface PendingResume {
reject: (e: unknown) => void;
}
function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId }: LeafViewProps) {
function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId, visibleNodeIds }: LeafViewProps) {
// A cell can be closed only when it lives inside a (binary) split: closing it
// collapses the parent split, keeping the *sibling*. Splits are always binary
// in this model (a split wraps a leaf into a 2-child container), so the kept
@ -204,13 +225,25 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
// Build the terminal opener based on whether an agent is pinned.
const agentId = agent ?? null;
/**
* Whether `candidate` is currently running in a cell *other than this one*.
* Such an agent cannot be launched here (one live session per agent), so the
* dropdown disables it and `onChange` rejects selecting it.
*/
const isLiveElsewhere = (candidate: string): boolean =>
liveAgents.some((la) => la.agentId === candidate && la.nodeId !== id);
/** The live session for `candidate`, if any. */
const liveFor = (candidate: string): LiveAgent | undefined =>
liveAgents.find((la) => la.agentId === candidate);
/** True when the live session is already displayed by another visible cell. */
const visibleElsewhere = (candidate: string): LiveAgent | undefined => {
const live = liveFor(candidate);
return live && live.nodeId !== id && visibleNodeIds.has(live.nodeId)
? live
: undefined;
};
/** A live session whose previous host cell no longer exists in the layout. */
const backgroundLive = (candidate: string): LiveAgent | undefined => {
const live = liveFor(candidate);
return live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId)
? live
: undefined;
};
// A transient notice shown when an action is blocked by the singleton
// invariant (selecting / launching an agent already live in another cell).
@ -231,6 +264,16 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
onData: (bytes: Uint8Array) => void,
convId: string | undefined,
): Promise<TerminalHandle> => {
const background = backgroundLive(agentId!);
if (background?.sessionId && agentGateway!.attachLiveAgent) {
const attached = await agentGateway!.attachLiveAgent(projectId, agentId!, id);
const sessionId = attached.sessionId ?? background.sessionId;
void vm.setSession(id, sessionId);
const result = await agentGateway!.reattach(sessionId, onData);
refreshLive();
return result.handle;
}
const handle = await agentGateway!.launchAgent(
projectId,
agentId!,
@ -328,15 +371,39 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
value={agentId ?? ""}
onChange={(e) => {
const val = e.target.value;
// Reject pinning an agent that is already running in another cell.
// (The dropdown also disables such options, but guard the change in
// case it is set programmatically.)
if (val !== "" && isLiveElsewhere(val)) {
setBusyNotice("Cet agent tourne déjà dans une autre cellule.");
if (val === "") {
setBusyNotice(null);
void vm.setCellAgent(id, null);
return;
}
setBusyNotice(null);
void vm.setCellAgent(id, val === "" ? null : val);
void (async () => {
const current = agentGateway?.listLiveAgents
? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
: liveAgents;
setLiveAgents(current);
const live = current.find((la) => la.agentId === val);
const isVisible =
live && live.nodeId !== id && visibleNodeIds.has(live.nodeId);
if (isVisible) {
setBusyNotice("Cet agent est déjà visible dans une autre cellule.");
return;
}
const isBackground =
live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId);
if (isBackground) {
if (!agentGateway?.attachLiveAgent || !live.sessionId) {
setBusyNotice("Session active introuvable pour cet agent.");
return;
}
setBusyNotice(null);
const attached = await agentGateway.attachLiveAgent(projectId, val, id);
await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
refreshLive();
return;
}
setBusyNotice(null);
await vm.setCellAgent(id, val);
})().catch((err: unknown) => setBusyNotice(describeNotice(err)));
}}
style={{
fontSize: 11,
@ -350,13 +417,12 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
>
<option value="">Plain</option>
{agents.map((a) => {
// An agent already running in another cell cannot be pinned here.
// The agent pinned on THIS cell stays selectable (same node).
const elsewhere = isLiveElsewhere(a.id);
const elsewhere = visibleElsewhere(a.id);
const background = backgroundLive(a.id);
return (
<option key={a.id} value={a.id} disabled={elsewhere}>
<option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
{a.name}
{elsewhere ? " (en cours ailleurs)" : ""}
{elsewhere ? " (visible ailleurs)" : background ? " (arrière-plan)" : ""}
</option>
);
})}
@ -437,9 +503,10 @@ interface SplitViewProps {
cwd: string;
vm: LayoutViewModel;
projectId: string;
visibleNodeIds: Set<string>;
}
function SplitView({ split, cwd, vm, projectId }: SplitViewProps) {
function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: SplitViewProps) {
const isRow = split.direction === "row";
const baseWeights = split.children.map((c) => c.weight);
const containerRef = useRef<HTMLDivElement | null>(null);
@ -480,6 +547,7 @@ function SplitView({ split, cwd, vm, projectId }: SplitViewProps) {
cwd={cwd}
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
parentSplit={{
container: split.id,
index: i,
@ -571,9 +639,10 @@ interface GridViewProps {
cwd: string;
vm: LayoutViewModel;
projectId: string;
visibleNodeIds: Set<string>;
}
function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
function GridView({ grid, cwd, vm, projectId, visibleNodeIds }: GridViewProps) {
const cols = normalizeWeights(grid.colWeights)
.map((p) => `${p}fr`)
.join(" ");
@ -604,7 +673,14 @@ function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
overflow: "hidden",
}}
>
<NodeView node={cell.node} cwd={cwd} vm={vm} parentSplit={null} projectId={projectId} />
<NodeView
node={cell.node}
cwd={cwd}
vm={vm}
parentSplit={null}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
</div>
))}
</div>
@ -615,3 +691,10 @@ function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
function keyOf(node: LayoutNode, fallback: number): string {
return node.node.id ?? String(fallback);
}
function describeNotice(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}

View File

@ -232,15 +232,17 @@ describe("setCellAgent (agent dropdown per cell)", () => {
renderGrid(layout, agent, "p1", terminal);
// Close the SECOND cell: keep the first, drop the second → its PTY dies.
// Close the SECOND cell: keep the first, drop the second. Closing a cell is
// now a detach-only view operation; it must not kill the PTY.
await waitFor(() => {
expect(screen.getByLabelText(`close ${droppedId}`)).toBeTruthy();
});
fireEvent.click(screen.getByLabelText(`close ${droppedId}`));
await waitFor(() => {
expect(closeSpy).toHaveBeenCalledWith("dropped-session");
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1);
});
expect(closeSpy).not.toHaveBeenCalled();
});
it("agent selector shows project agents as options", async () => {

View File

@ -4,11 +4,11 @@
* - The mock agent gateway mirrors the backend guard: launching an agent already
* live in another cell is refused with `AGENT_ALREADY_RUNNING`, and the same
* node is idempotent; `listLiveAgents` reports who runs where (T5).
* - The per-cell dropdown disables (and `onChange` rejects) an agent already live
* in another cell, while the agent pinned on its own cell stays selectable (T6).
* - The per-cell dropdown blocks an agent already visible in another cell, but
* can rebind a background live session when the backend exposes its session id.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Gateways, LiveAgent } from "@/ports";
import {
@ -65,7 +65,13 @@ describe("singleton agent — mock gateway guard (T5)", () => {
);
const live: LiveAgent[] = await agent.listLiveAgents("p1");
expect(live).toEqual([{ agentId: a.id, nodeId: "node-A" }]);
expect(live).toEqual([
{
agentId: a.id,
nodeId: "node-A",
sessionId: "mock-agent-session-1",
},
]);
});
it("relaunching in the SAME node is allowed (idempotent), not refused", async () => {
@ -104,34 +110,68 @@ describe("singleton agent — dropdown guard (T6)", () => {
);
}
it("disables an agent already running in another cell and rejects selecting it", async () => {
it("opens an agent already running elsewhere by rebinding its live session", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
await agent.launchAgent(
"p1",
a.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: "old-node" },
noop,
);
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
renderGrid(layout, agent);
// The option is selectable: choosing it rebinds the running session here.
const option = await screen.findByRole("option", {
name: /Busy/,
});
expect((option as HTMLOptionElement).disabled).toBe(false);
const select = screen.getByRole("combobox") as HTMLSelectElement;
fireEvent.change(select, { target: { value: a.id } });
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
const leaf = leaves(updated).find((l) => l.id === leafId)!;
expect(leaf.agent).toBe(a.id);
expect(leaf.session).toBe("mock-agent-session-1");
});
expect(await agent.listLiveAgents("p1")).toEqual([
{
agentId: a.id,
nodeId: leafId,
sessionId: "mock-agent-session-1",
},
]);
});
it("explains the missing backend contract when a live agent has no attachable session", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
// Mark the agent live in a DIFFERENT node than the (single) leaf we render.
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
{ agentId: a.id, nodeId: "some-other-node" },
]);
renderGrid(layout, agent);
// The option for the live-elsewhere agent is rendered disabled with a label.
const option = await screen.findByRole("option", {
name: /Busy \(en cours ailleurs\)/,
name: /Busy/,
});
expect((option as HTMLOptionElement).disabled).toBe(true);
expect((option as HTMLOptionElement).disabled).toBe(false);
// Programmatically selecting it must be rejected (agent not pinned).
const select = screen.getByRole("combobox") as HTMLSelectElement;
const setCellAgentSpy = vi.spyOn(layout, "mutateLayout");
fireEvent.change(select, { target: { value: a.id } });
// A notice is shown and no setCellAgent mutation was issued.
expect(await screen.findByRole("status")).toBeTruthy();
expect(
setCellAgentSpy.mock.calls.filter((c) => c[1].type === "setCellAgent"),
).toHaveLength(0);
expect((await screen.findByRole("status")).textContent).toMatch(
/Session active introuvable/,
);
});
it("the agent pinned on THIS cell stays selectable even while live (same node)", async () => {

View File

@ -17,7 +17,7 @@ import type {
LayoutTree,
} from "@/domain";
import { useGateways } from "@/app/di";
import { droppedSessions, leaves, splitOp } from "./layout";
import { leaves, splitOp } from "./layout";
/** What the layout grid UI needs from this hook. */
export interface LayoutViewModel {
@ -39,6 +39,12 @@ export interface LayoutViewModel {
setSession: (target: string, session: string | null) => Promise<void>;
/** Pins or clears an agent on a cell (persisted in layout). */
setCellAgent: (target: string, agent: string | null) => Promise<void>;
/** Shows an already-running agent session in a cell without respawning it. */
attachLiveAgentToCell: (
target: string,
agent: string,
session: string,
) => Promise<void>;
/**
* Records (or clears) the persistent CLI conversation id on a cell (T4b). Used
* to persist the id assigned at first launch so the next open resumes it.
@ -134,23 +140,12 @@ export function useLayout(
);
const merge = useCallback(
async (container: string, keepIndex: number) => {
// Closing a cell collapses its split onto the kept child; the dropped
// child's PTYs must be killed (not just detached) or they'd linger as
// orphan agent processes. Snapshot the sessions to drop BEFORE mutating
// (the tree no longer holds them afterwards), then tear them down.
const dropped = layout ? droppedSessions(layout, container, keepIndex) : [];
// Closing a cell is a VIEW operation: it collapses the layout, but the
// dropped cell's PTY keeps running. TerminalView cleanup detaches the local
// stream; the agent/session can later be re-opened in a cell via IdeA.
await mutate({ type: "merge", container, keepIndex });
if (terminal) {
for (const session of dropped) {
try {
await terminal.closeTerminal(session);
} catch {
/* already gone — ignore */
}
}
}
},
[layout, mutate, terminal],
[mutate],
);
const resize = useCallback(
(container: string, weights: number[]) =>
@ -169,8 +164,10 @@ export function useLayout(
const setCellAgent = useCallback(
async (target: string, agent: string | null) => {
// Find the cell's current agent + session. Changing the agent must reset
// the session (so the remounted TerminalView opens fresh for the new agent
// instead of reattaching to the old PTY) and kill the old PTY.
// the session so the remounted TerminalView opens/attaches for the new
// agent instead of reattaching to the old PTY. If the previous session was
// an agent session, it is only detached into the background; a cell is a
// view, not the process owner.
const cell = layout ? leaves(layout).find((l) => l.id === target) : undefined;
const oldAgent = cell?.agent ?? null;
const oldSession = cell?.session ?? null;
@ -184,8 +181,37 @@ export function useLayout(
{ type: "setCellConversation", target, conversationId: null },
]);
// Best-effort: tear down the previous PTY so no orphan process lingers.
if (oldSession && terminal) {
// Best-effort: tear down a previous plain terminal so no orphan shell
// lingers. Agent sessions keep running in the background.
if (oldSession && !oldAgent && terminal) {
try {
await terminal.closeTerminal(oldSession);
} catch {
/* already gone — ignore */
}
}
},
[layout, mutateChain, terminal],
);
const attachLiveAgentToCell = useCallback(
async (target: string, agent: string, session: string) => {
const cell = layout ? leaves(layout).find((l) => l.id === target) : undefined;
const oldAgent = cell?.agent ?? null;
const oldSession = cell?.session ?? null;
const operations: LayoutOperation[] = [];
if (agent !== oldAgent) {
operations.push(
{ type: "setCellAgent", target, agent },
{ type: "setCellConversation", target, conversationId: null },
);
}
if (session !== oldSession) {
operations.push({ type: "setSession", target, session });
}
await mutateChain(operations);
if (oldSession && oldSession !== session && !oldAgent && terminal) {
try {
await terminal.closeTerminal(oldSession);
} catch {
@ -212,6 +238,7 @@ export function useLayout(
move,
setSession,
setCellAgent,
attachLiveAgentToCell,
setCellConversation,
};
}

View File

@ -18,6 +18,7 @@ import { useCallback, useState } from "react";
import type { MemoryIndexEntry } from "@/domain";
import { Button, Panel, Spinner } from "@/shared";
import { useGateways } from "@/app/di";
import { useEmbedder } from "@/features/embedder";
import { MemoryEditor } from "./MemoryEditor";
import { useMemory } from "./useMemory";
@ -31,6 +32,7 @@ type EditorState = { mode: "create" } | { mode: "edit"; entry: MemoryIndexEntry
export function MemoryPanel({ projectId }: MemoryPanelProps) {
const vm = useMemory(projectId);
const embedderVm = useEmbedder();
const { memory } = useGateways();
const [editorState, setEditorState] = useState<EditorState | null>(null);
@ -87,6 +89,22 @@ export function MemoryPanel({ projectId }: MemoryPanelProps) {
)}
<Panel title="Memory" className="flex flex-col gap-0">
{/* ── Active recall tier (read-only transparency) ── */}
<p
className="border-b border-border px-4 py-2 text-xs text-faint"
data-testid="memory-tier"
>
Tier:{" "}
{embedderVm.active ? (
<span className="text-content">
Vector {embedderVm.active.strategy}
{embedderVm.active.model ? ` ${embedderVm.active.model}` : ""}
</span>
) : (
<span className="text-content">Naïve (None)</span>
)}
</p>
{vm.error && (
<p
role="alert"

View File

@ -18,7 +18,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { MockMemoryGateway } from "@/adapters/mock";
import { MockMemoryGateway, MockEmbedderGateway } from "@/adapters/mock";
import type { GatewayError } from "@/domain";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
@ -28,7 +28,10 @@ const PROJECT_ID = "proj-memory-test";
function renderMemoryPanel(memory?: MockMemoryGateway) {
const mem = memory ?? new MockMemoryGateway();
const gateways = { memory: mem } as unknown as Gateways;
const gateways = {
memory: mem,
embedder: new MockEmbedderGateway(),
} as unknown as Gateways;
return {
memory: mem,
...render(

View File

@ -0,0 +1,115 @@
/**
* `ProjectContextPanel` — edits the shared project context stored in
* `.ideai/CONTEXT.md`.
*
* This context is injected into every agent launch before the agent persona,
* regardless of the selected AI profile/model.
*/
import { useEffect, useState } from "react";
import type { GatewayError } from "@/domain";
import { Button, Panel, Spinner, cn } from "@/shared";
import { useGateways } from "@/app/di";
export interface ProjectContextPanelProps {
/** Project whose `.ideai/CONTEXT.md` should be edited. */
projectId: string;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
export function ProjectContextPanel({ projectId }: ProjectContextPanelProps) {
const { project } = useGateways();
const [content, setContent] = useState("");
const [savedContent, setSavedContent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setBusy(true);
setError(null);
project
.readProjectContext(projectId)
.then((text) => {
if (!cancelled) {
setContent(text);
setSavedContent(text);
}
})
.catch((e) => {
if (!cancelled) setError(describe(e));
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, [project, projectId]);
async function save() {
setBusy(true);
setError(null);
try {
await project.updateProjectContext(projectId, content);
setSavedContent(content);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}
const dirty = content !== savedContent;
return (
<Panel title="Project Context" className="flex flex-col gap-0">
{error && (
<p
role="alert"
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{error}
</p>
)}
<div className="border-b border-border px-4 py-2">
<code className="text-xs text-muted">.ideai/CONTEXT.md</code>
</div>
<div className="flex flex-col gap-3 p-4">
<textarea
aria-label="project context"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={18}
className={cn(
"w-full rounded-md bg-raised px-3 py-2 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary placeholder:text-faint",
"disabled:cursor-not-allowed disabled:opacity-50 resize-y font-mono",
)}
disabled={busy}
/>
<div className="flex items-center justify-end gap-2">
{busy && <Spinner size={14} />}
<Button
variant="primary"
disabled={busy || !dirty}
onClick={() => void save()}
>
Save
</Button>
</div>
</div>
</Panel>
);
}

View File

@ -36,13 +36,16 @@ import { AgentsPanel } from "@/features/agents";
import { TemplatesPanel } from "@/features/templates";
import { SkillsPanel } from "@/features/skills";
import { MemoryPanel } from "@/features/memory";
import { EmbedderSettings } from "@/features/embedder";
import { GitPanel, GitGraphView } from "@/features/git";
import { Button, Input, Panel, Tabs, cn } from "@/shared";
import { useGateways } from "@/app/di";
import { ProjectContextPanel } from "./ProjectContextPanel";
import { useProjects } from "./useProjects";
type SidebarTab =
| "projects"
| "context"
| "agents"
| "templates"
| "skills"
@ -51,6 +54,7 @@ type SidebarTab =
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
{ id: "projects", label: "Projects" },
{ id: "context", label: "Context" },
{ id: "agents", label: "Agents" },
{ id: "templates", label: "Templates" },
{ id: "skills", label: "Skills" },
@ -253,6 +257,14 @@ export function ProjectsView() {
</div>
)}
{/* Project context panel */}
{sidebarTab === "context" && active && (
<ProjectContextPanel projectId={active.id} />
)}
{sidebarTab === "context" && !active && (
<p className="text-sm text-muted">Open a project to edit context.</p>
)}
{/* Agents panel — only rendered when active project exists */}
{sidebarTab === "agents" && active && (
<AgentsPanel projectId={active.id} projectRoot={active.root} />
@ -277,9 +289,12 @@ export function ProjectsView() {
<p className="text-sm text-muted">Open a project to manage skills.</p>
)}
{/* Memory panel */}
{/* Memory panel + embedder settings */}
{sidebarTab === "memory" && active && (
<MemoryPanel projectId={active.id} />
<div className="flex flex-col gap-4">
<MemoryPanel projectId={active.id} />
<EmbedderSettings />
</div>
)}
{sidebarTab === "memory" && !active && (
<p className="text-sm text-muted">Open a project to manage memory.</p>

View File

@ -181,6 +181,13 @@ describe("ProjectsView (with MockProjectGateway)", () => {
);
expect(screen.queryByLabelText("project name")).toBeNull();
// Switch to Context tab — shared project context editor should be visible.
fireEvent.click(screen.getByRole("button", { name: "Context" }));
await waitFor(() =>
expect(screen.getByLabelText("project context")).toBeTruthy(),
);
expect(screen.queryByLabelText("agent name")).toBeNull();
// Switch to Templates tab — the "New template" button should be visible.
fireEvent.click(screen.getByRole("button", { name: "Templates" }));
await waitFor(() =>
@ -199,6 +206,31 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect(screen.getByLabelText("project name")).toBeTruthy();
});
it("edits the shared project context stored under .ideai", async () => {
const project = new MockProjectGateway();
const created = await project.createProject("alpha", "/p/a");
await project.updateProjectContext(created.id, "# Existing context");
renderView(project);
await screen.findByText("/p/a");
fireEvent.click(screen.getByRole("button", { name: "Open" }));
fireEvent.click(screen.getByRole("button", { name: "Context" }));
const editor = (await screen.findByLabelText(
"project context",
)) as HTMLTextAreaElement;
expect(editor.value).toBe("# Existing context");
fireEvent.change(editor, { target: { value: "# Shared\n\nUse pnpm." } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(async () => {
await expect(project.readProjectContext(created.id)).resolves.toBe(
"# Shared\n\nUse pnpm.",
);
});
});
it("Browse… button calls pickFolder and fills the root field with the returned path", async () => {
const system = new MockSystemGateway();
renderView(new MockProjectGateway(), system);