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:
@ -59,4 +59,14 @@ describe("TauriAgentGateway invoke payloads", () => {
|
||||
agentId: "a",
|
||||
});
|
||||
});
|
||||
|
||||
it("attach_live_agent wraps project, agent and target node in the request DTO", async () => {
|
||||
invoke.mockResolvedValueOnce([
|
||||
{ agentId: "agent-2", nodeId: "node-3", sessionId: "session-4" },
|
||||
]);
|
||||
await new TauriAgentGateway().attachLiveAgent("proj-1", "agent-2", "node-3");
|
||||
expect(invoke).toHaveBeenCalledWith("attach_live_agent", {
|
||||
request: { projectId: "proj-1", agentId: "agent-2", nodeId: "node-3" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -45,6 +45,22 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
return invoke<LiveAgent[]>("list_live_agents", { projectId });
|
||||
}
|
||||
|
||||
attachLiveAgent(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
nodeId: string,
|
||||
): Promise<LiveAgent> {
|
||||
return invoke<LiveAgent[]>("attach_live_agent", {
|
||||
request: { projectId, agentId, nodeId },
|
||||
}).then((list) => {
|
||||
const attached = list[0];
|
||||
if (!attached) {
|
||||
throw new Error(`running session for agent ${agentId} was not returned`);
|
||||
}
|
||||
return attached;
|
||||
});
|
||||
}
|
||||
|
||||
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||
// The `create_agent` command takes a single `request` DTO; `projectId` must
|
||||
// live *inside* it (camelCase), not at the top level.
|
||||
|
||||
35
frontend/src/adapters/embedder.ts
Normal file
35
frontend/src/adapters/embedder.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Tauri adapter for {@link EmbedderGateway} (L14 / lot C2). Like the sibling
|
||||
* adapters this is one of the *only* places that calls `invoke()`; components
|
||||
* reach it exclusively through the port.
|
||||
*
|
||||
* Commands use snake_case (Tauri convention); payload keys are camelCase
|
||||
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`). The mutating
|
||||
* command `save_embedder_profile` wraps its field under a `request` key (as
|
||||
* `save_profile` does); `delete_embedder_profile` passes its arg flat.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { EmbedderEngines, EmbedderProfile } from "@/domain";
|
||||
import type { EmbedderGateway } from "@/ports";
|
||||
|
||||
export class TauriEmbedderGateway implements EmbedderGateway {
|
||||
listEmbedderProfiles(): Promise<EmbedderProfile[]> {
|
||||
return invoke<EmbedderProfile[]>("list_embedder_profiles");
|
||||
}
|
||||
|
||||
saveEmbedderProfile(profile: EmbedderProfile): Promise<EmbedderProfile> {
|
||||
return invoke<EmbedderProfile>("save_embedder_profile", {
|
||||
request: { profile },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEmbedderProfile(embedderId: string): Promise<void> {
|
||||
await invoke("delete_embedder_profile", { embedderId });
|
||||
}
|
||||
|
||||
describeEmbedderEngines(): Promise<EmbedderEngines> {
|
||||
return invoke<EmbedderEngines>("describe_embedder_engines");
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,7 @@ import { TauriProfileGateway } from "./profile";
|
||||
import { TauriTemplateGateway } from "./template";
|
||||
import { TauriSkillGateway } from "./skill";
|
||||
import { TauriMemoryGateway } from "./memory";
|
||||
import { TauriEmbedderGateway } from "./embedder";
|
||||
import { TauriGitGateway } from "./git";
|
||||
|
||||
function notImplemented(what: string): never {
|
||||
@ -51,6 +52,7 @@ export function createTauriGateways(): Gateways {
|
||||
template: new TauriTemplateGateway(),
|
||||
skill: new TauriSkillGateway(),
|
||||
memory: new TauriMemoryGateway(),
|
||||
embedder: new TauriEmbedderGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -64,5 +66,6 @@ export {
|
||||
TauriTemplateGateway,
|
||||
TauriSkillGateway,
|
||||
TauriMemoryGateway,
|
||||
TauriEmbedderGateway,
|
||||
TauriGitGateway,
|
||||
};
|
||||
|
||||
@ -9,6 +9,8 @@ import type {
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
DomainEvent,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
FirstRunState,
|
||||
GatewayError,
|
||||
GitBranches,
|
||||
@ -38,6 +40,7 @@ import type {
|
||||
CreateAgentInput,
|
||||
CreateMemoryInput,
|
||||
CreateSkillInput,
|
||||
EmbedderGateway,
|
||||
CreateTemplateInput,
|
||||
Gateways,
|
||||
LiveAgent,
|
||||
@ -178,6 +181,8 @@ export class MockAgentGateway implements AgentGateway {
|
||||
* Only populated when the caller supplies a `nodeId`.
|
||||
*/
|
||||
private liveByAgent = new Map<string, string>();
|
||||
/** Live PTY session id per agent (`agentId → sessionId`). */
|
||||
private liveSessionByAgent = new Map<string, string>();
|
||||
|
||||
private getAgents(projectId: string): Agent[] {
|
||||
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
||||
@ -198,7 +203,36 @@ export class MockAgentGateway implements AgentGateway {
|
||||
const known = new Set(this.getAgents(projectId).map((a) => a.id));
|
||||
return [...this.liveByAgent.entries()]
|
||||
.filter(([agentId]) => known.has(agentId))
|
||||
.map(([agentId, nodeId]) => ({ agentId, nodeId }));
|
||||
.map(([agentId, nodeId]) => ({
|
||||
agentId,
|
||||
nodeId,
|
||||
sessionId: this.liveSessionByAgent.get(agentId),
|
||||
}));
|
||||
}
|
||||
|
||||
async attachLiveAgent(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
nodeId: string,
|
||||
): Promise<LiveAgent> {
|
||||
const list = this.getAgents(projectId);
|
||||
if (!list.some((a) => a.id === agentId)) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent ${agentId} not found in project ${projectId}`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
const sessionId = this.liveSessionByAgent.get(agentId);
|
||||
if (!sessionId || !this.sessions.has(sessionId)) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent ${agentId} has no live session`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
this.liveByAgent.set(agentId, nodeId);
|
||||
return { agentId, nodeId, sessionId };
|
||||
}
|
||||
|
||||
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||
@ -356,7 +390,10 @@ export class MockAgentGateway implements AgentGateway {
|
||||
this.sessions.set(sessionId, session);
|
||||
// Record liveness for `listLiveAgents` + the guard above (only when the
|
||||
// caller pins a node).
|
||||
if (options.nodeId) this.liveByAgent.set(agentId, options.nodeId);
|
||||
if (options.nodeId) {
|
||||
this.liveByAgent.set(agentId, options.nodeId);
|
||||
this.liveSessionByAgent.set(agentId, sessionId);
|
||||
}
|
||||
// Greet so something is visible immediately (mirrors MockTerminalGateway).
|
||||
queueMicrotask(() =>
|
||||
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
||||
@ -365,6 +402,7 @@ export class MockAgentGateway implements AgentGateway {
|
||||
this.sessions.delete(sessionId);
|
||||
if (this.liveByAgent.get(agentId) === options.nodeId) {
|
||||
this.liveByAgent.delete(agentId);
|
||||
this.liveSessionByAgent.delete(agentId);
|
||||
}
|
||||
});
|
||||
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a
|
||||
@ -393,7 +431,15 @@ export class MockAgentGateway implements AgentGateway {
|
||||
}
|
||||
const scrollback = session.reattach(onData);
|
||||
return {
|
||||
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
|
||||
handle: makeMockHandle(session, () => {
|
||||
this.sessions.delete(sessionId);
|
||||
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
|
||||
if (liveSessionId === sessionId) {
|
||||
this.liveSessionByAgent.delete(agentId);
|
||||
this.liveByAgent.delete(agentId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
scrollback,
|
||||
};
|
||||
}
|
||||
@ -513,6 +559,7 @@ export class MockTerminalGateway implements TerminalGateway {
|
||||
|
||||
export class MockProjectGateway implements ProjectGateway {
|
||||
private projects: Project[] = [];
|
||||
private contexts = new Map<string, string>();
|
||||
|
||||
async listProjects(): Promise<Project[]> {
|
||||
return [...this.projects];
|
||||
@ -550,6 +597,28 @@ export class MockProjectGateway implements ProjectGateway {
|
||||
}
|
||||
|
||||
async closeProject(): Promise<void> {}
|
||||
|
||||
async readProjectContext(projectId: string): Promise<string> {
|
||||
if (!this.projects.some((p) => p.id === projectId)) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `project ${projectId} not found`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
return this.contexts.get(projectId) ?? "";
|
||||
}
|
||||
|
||||
async updateProjectContext(projectId: string, content: string): Promise<void> {
|
||||
if (!this.projects.some((p) => p.id === projectId)) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `project ${projectId} not found`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
this.contexts.set(projectId, content);
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal per-project layout store entry. */
|
||||
@ -1321,6 +1390,80 @@ export class MockMemoryGateway implements MemoryGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory {@link EmbedderGateway} (L14 / lot C2). Seeds a curated set of
|
||||
* recommended ONNX engines (e5-small recommended) with both feature flags on by
|
||||
* default; tests can pass overrides to simulate a build where a strategy is not
|
||||
* compiled in.
|
||||
*/
|
||||
export class MockEmbedderGateway implements EmbedderGateway {
|
||||
private profiles = new Map<string, EmbedderProfile>();
|
||||
private engines: EmbedderEngines;
|
||||
|
||||
constructor(
|
||||
engines?: Partial<EmbedderEngines>,
|
||||
seedProfiles: EmbedderProfile[] = [],
|
||||
) {
|
||||
this.engines = {
|
||||
recommendedOnnx: [
|
||||
{
|
||||
id: "e5-small",
|
||||
displayName: "E5 Small (multilingual)",
|
||||
dimension: 384,
|
||||
approxSizeMb: 120,
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "bge-small",
|
||||
displayName: "BGE Small",
|
||||
dimension: 384,
|
||||
approxSizeMb: 130,
|
||||
recommended: false,
|
||||
},
|
||||
],
|
||||
ollamaDetected: false,
|
||||
onnxCachedModels: [],
|
||||
vectorHttpEnabled: true,
|
||||
vectorOnnxEnabled: true,
|
||||
...engines,
|
||||
};
|
||||
for (const p of seedProfiles) this.profiles.set(p.id, p);
|
||||
}
|
||||
|
||||
async listEmbedderProfiles(): Promise<EmbedderProfile[]> {
|
||||
return structuredClone([...this.profiles.values()]);
|
||||
}
|
||||
|
||||
async saveEmbedderProfile(
|
||||
profile: EmbedderProfile,
|
||||
): Promise<EmbedderProfile> {
|
||||
if (!profile.id.trim() || !profile.name.trim()) {
|
||||
const err: GatewayError = {
|
||||
code: "INVALID",
|
||||
message: "embedder profile id/name must not be empty",
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
if (profile.dimension <= 0) {
|
||||
const err: GatewayError = {
|
||||
code: "INVALID",
|
||||
message: "embedder profile dimension must be > 0",
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
this.profiles.set(profile.id, structuredClone(profile));
|
||||
return structuredClone(profile);
|
||||
}
|
||||
|
||||
async deleteEmbedderProfile(embedderId: string): Promise<void> {
|
||||
this.profiles.delete(embedderId);
|
||||
}
|
||||
|
||||
async describeEmbedderEngines(): Promise<EmbedderEngines> {
|
||||
return structuredClone(this.engines);
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the full set of mock gateways. */
|
||||
export function createMockGateways(): Gateways {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
@ -1336,6 +1479,7 @@ export function createMockGateways(): Gateways {
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
skill: new MockSkillGateway(agentGateway),
|
||||
memory: new MockMemoryGateway(),
|
||||
embedder: new MockEmbedderGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -12,9 +12,10 @@ import { createMockGateways, MockSystemGateway } from "./index";
|
||||
const gateways: Gateways = createMockGateways();
|
||||
|
||||
describe("createMockGateways", () => {
|
||||
it("exposes all eleven gateways", () => {
|
||||
it("exposes all twelve gateways", () => {
|
||||
expect(Object.keys(gateways).sort()).toEqual([
|
||||
"agent",
|
||||
"embedder",
|
||||
"git",
|
||||
"layout",
|
||||
"memory",
|
||||
|
||||
27
frontend/src/adapters/project.test.ts
Normal file
27
frontend/src/adapters/project.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invoke(...args),
|
||||
}));
|
||||
|
||||
import { TauriProjectGateway } from "./project";
|
||||
|
||||
describe("TauriProjectGateway invoke payloads", () => {
|
||||
beforeEach(() => invoke.mockReset().mockResolvedValue({}));
|
||||
|
||||
it("reads and updates the project context under .ideai", async () => {
|
||||
invoke.mockResolvedValueOnce("# shared");
|
||||
await expect(new TauriProjectGateway().readProjectContext("proj-1")).resolves.toBe(
|
||||
"# shared",
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith("read_project_context", {
|
||||
projectId: "proj-1",
|
||||
});
|
||||
|
||||
await new TauriProjectGateway().updateProjectContext("proj-1", "# next");
|
||||
expect(invoke).toHaveBeenCalledWith("update_project_context", {
|
||||
request: { projectId: "proj-1", content: "# next" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -27,4 +27,14 @@ export class TauriProjectGateway implements ProjectGateway {
|
||||
async closeProject(projectId: string): Promise<void> {
|
||||
await invoke("close_project", { projectId });
|
||||
}
|
||||
|
||||
readProjectContext(projectId: string): Promise<string> {
|
||||
return invoke<string>("read_project_context", { projectId });
|
||||
}
|
||||
|
||||
async updateProjectContext(projectId: string, content: string): Promise<void> {
|
||||
await invoke("update_project_context", {
|
||||
request: { projectId, content },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -313,6 +313,65 @@ export interface MemoryIndexEntry {
|
||||
/** 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`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
481
frontend/src/features/embedder/EmbedderSettings.tsx
Normal file
481
frontend/src/features/embedder/EmbedderSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
frontend/src/features/embedder/embedder.test.tsx
Normal file
237
frontend/src/features/embedder/embedder.test.tsx
Normal 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/));
|
||||
});
|
||||
});
|
||||
4
frontend/src/features/embedder/index.ts
Normal file
4
frontend/src/features/embedder/index.ts
Normal 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";
|
||||
118
frontend/src/features/embedder/useEmbedder.ts
Normal file
118
frontend/src/features/embedder/useEmbedder.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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(
|
||||
|
||||
115
frontend/src/features/projects/ProjectContextPanel.tsx
Normal file
115
frontend/src/features/projects/ProjectContextPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -13,6 +13,8 @@ import type {
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
DomainEvent,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
FirstRunState,
|
||||
GitBranches,
|
||||
GitCommit,
|
||||
@ -78,6 +80,20 @@ export interface AgentGateway {
|
||||
* launched a second time — one live session per agent).
|
||||
*/
|
||||
listLiveAgents(projectId: string): Promise<LiveAgent[]>;
|
||||
/**
|
||||
* Rebinds an already-live agent session to another visible layout cell without
|
||||
* respawning the CLI process. Used when a background agent is opened in a cell,
|
||||
* or when a live session is moved from a now-detached/closed cell.
|
||||
*
|
||||
* Backends that do not yet support agent-session rebinding may omit this
|
||||
* method; the UI will show the session as running elsewhere but cannot attach
|
||||
* it from a different cell.
|
||||
*/
|
||||
attachLiveAgent?(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
nodeId: string,
|
||||
): Promise<LiveAgent>;
|
||||
/** Creates a new agent from scratch; returns the created agent. */
|
||||
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>;
|
||||
/** Reads an agent's `.md` context by agent id. */
|
||||
@ -156,6 +172,11 @@ export interface LiveAgent {
|
||||
agentId: string;
|
||||
/** The node (layout leaf) hosting the agent's live session. */
|
||||
nodeId: string;
|
||||
/**
|
||||
* The live PTY session id, when the backend exposes it. Required for opening
|
||||
* a background/running agent in a new visible cell without respawning it.
|
||||
*/
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -253,6 +274,10 @@ export interface ProjectGateway {
|
||||
openProject(projectId: string): Promise<Project>;
|
||||
/** Closes a project by id. */
|
||||
closeProject(projectId: string): Promise<void>;
|
||||
/** Reads the shared project context stored at `.ideai/CONTEXT.md`. */
|
||||
readProjectContext(projectId: string): Promise<string>;
|
||||
/** Overwrites the shared project context stored at `.ideai/CONTEXT.md`. */
|
||||
updateProjectContext(projectId: string, content: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Layout: load the terminal grid tree and apply mutating operations (L4). */
|
||||
@ -458,6 +483,29 @@ export interface ProfileGateway {
|
||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
|
||||
}
|
||||
|
||||
/** Input for {@link EmbedderGateway.saveEmbedderProfile}. */
|
||||
export interface SaveEmbedderProfileInput {
|
||||
profile: EmbedderProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedder configuration (L14 / lot C2). Drives the memory/embedder settings
|
||||
* panel: lists the configured embedder profiles, the available engines (with
|
||||
* build-time feature flags so unavailable strategies can be shown disabled),
|
||||
* and persists / removes profiles. Changing the active embedder takes effect at
|
||||
* the next app start. Mirrors the four backend embedder commands.
|
||||
*/
|
||||
export interface EmbedderGateway {
|
||||
/** Lists the configured embedder profiles (transparent: no secrets). */
|
||||
listEmbedderProfiles(): Promise<EmbedderProfile[]>;
|
||||
/** Creates or replaces (by id) a single profile; returns the saved profile. */
|
||||
saveEmbedderProfile(profile: EmbedderProfile): Promise<EmbedderProfile>;
|
||||
/** Deletes an embedder profile by id. */
|
||||
deleteEmbedderProfile(embedderId: string): Promise<void>;
|
||||
/** Describes the available engines + build feature flags + detection. */
|
||||
describeEmbedderEngines(): Promise<EmbedderEngines>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full set of gateways the app depends on, injected via the DI provider.
|
||||
* The composition (real vs mock) is chosen in `app/`.
|
||||
@ -474,4 +522,5 @@ export interface Gateways {
|
||||
template: TemplateGateway;
|
||||
skill: SkillGateway;
|
||||
memory: MemoryGateway;
|
||||
embedder: EmbedderGateway;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user