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:
@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user