openpencil/apps/desktop/git/merge-orchestrator.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

274 lines
9.2 KiB
TypeScript

// apps/desktop/git/merge-orchestrator.ts
//
// Single-file mode merge orchestration. Loads three PenDocument blobs from
// git, calls pen-core's mergeDocuments, and produces the wire-format
// ConflictBag with stable ids. Also applies user resolutions to a merged
// document during applyMerge.
import {
mergeDocuments,
type MergeResult,
type NodeConflict,
type DocFieldConflict,
} from '@zseven-w/pen-core';
import type { PenDocument, PenNode } from '@zseven-w/pen-types';
import { GitError } from './error';
import { readBlobAtCommit, type IsoRepoHandle } from './git-iso';
import {
buildConflictBag,
parseConflictId,
type ConflictBag,
type ConflictResolution,
} from './merge-session';
/**
* Load the tracked file's content at three commits, JSON.parse, and run the
* pen-core merge. Returns BOTH the raw MergeResult (so the caller can stash
* it for later applyResolutions) AND the wire-format ConflictBag (so the
* caller can return it across IPC immediately).
*/
export async function runMerge(opts: {
handle: IsoRepoHandle;
filepath: string;
oursCommit: string;
theirsCommit: string;
baseCommit: string;
}): Promise<{
result: MergeResult;
bag: ConflictBag;
conflictMap: Map<string, NodeConflict | DocFieldConflict>;
}> {
const { handle, filepath, oursCommit, theirsCommit, baseCommit } = opts;
const [oursStr, theirsStr, baseStr] = await Promise.all([
readBlobAtCommit({ handle, filepath, commitHash: oursCommit }),
readBlobAtCommit({ handle, filepath, commitHash: theirsCommit }),
readBlobAtCommit({ handle, filepath, commitHash: baseCommit }),
]);
let ours: PenDocument;
let theirs: PenDocument;
let base: PenDocument;
try {
ours = JSON.parse(oursStr) as PenDocument;
theirs = JSON.parse(theirsStr) as PenDocument;
base = JSON.parse(baseStr) as PenDocument;
} catch (err) {
throw new GitError('engine-crash', `Failed to parse PenDocument blobs for merge`, {
cause: err,
detail: { filepath, oursCommit, theirsCommit, baseCommit },
});
}
const result = mergeDocuments({ base, ours, theirs });
const { bag, conflictMap } = buildConflictBag(result);
return { result, bag, conflictMap };
}
/**
* Apply the user's conflict resolutions to a merged document. Returns a new
* PenDocument with the chosen versions substituted in. Does NOT mutate the
* input merged document.
*
* Resolution semantics:
* - 'ours' → leave the merged tree unchanged at that node/field (pen-core's
* mergeDocuments already places ours as the placeholder for unresolved
* conflicts, so 'ours' is a no-op).
* - 'theirs' → replace the conflicted node/field with the theirs version.
* - 'manual-node' → replace the conflicted node with the user's edited version.
* - 'manual-field' → set the doc-field value to the user's choice.
*
* If a conflict has no resolution in the map, we default to 'ours' (the
* pen-core placeholder). The applyMerge engine fn enforces "all conflicts
* must be resolved" before calling this — but defaulting here makes the
* function safe to call in tests with partial resolution maps.
*/
export function applyResolutions(opts: {
merged: PenDocument;
conflictMap: Map<string, NodeConflict | DocFieldConflict>;
resolutions: Map<string, ConflictResolution>;
}): PenDocument {
const { conflictMap, resolutions } = opts;
// We build a new document by deep-cloning via JSON round-trip. The merge
// result is already a fresh object from pen-core, but we don't want to
// mutate it in case the caller still holds a reference for diagnostics.
let doc = JSON.parse(JSON.stringify(opts.merged)) as PenDocument;
for (const [id, resolution] of resolutions) {
const parsed = parseConflictId(id);
const conflict = conflictMap.get(id);
if (!conflict) {
throw new GitError('engine-crash', `resolveConflict: unknown id ${id}`);
}
if (parsed.kind === 'node') {
const nodeConflict = conflict as NodeConflict;
const target = pickNodeForResolution(resolution, nodeConflict);
if (target === null) {
// 'ours' (with ours being null = deleted) — drop the node from the tree.
doc = removeNodeById(doc, parsed.pageId, parsed.nodeId);
} else {
doc = replaceNodeById(doc, parsed.pageId, parsed.nodeId, target);
}
} else {
// doc-field conflict
const fieldConflict = conflict as DocFieldConflict;
const value = pickFieldForResolution(resolution, fieldConflict);
doc = setDocFieldByPath(doc, parsed.field, parsed.path, value);
}
}
return doc;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function pickNodeForResolution(
resolution: ConflictResolution,
conflict: NodeConflict,
): PenNode | null {
if (resolution.kind === 'ours') return conflict.ours;
if (resolution.kind === 'theirs') return conflict.theirs;
if (resolution.kind === 'manual-node') return resolution.node;
// manual-field is a programming error for node conflicts; treat as ours.
return conflict.ours;
}
function pickFieldForResolution(
resolution: ConflictResolution,
conflict: DocFieldConflict,
): unknown {
if (resolution.kind === 'ours') return conflict.ours;
if (resolution.kind === 'theirs') return conflict.theirs;
if (resolution.kind === 'manual-field') return resolution.value;
// manual-node is a programming error for doc-field conflicts; treat as ours.
return conflict.ours;
}
/**
* Replace a node in the document tree by id. Walks both the legacy
* single-page `children` and the multi-page `pages` shape.
*
* NOTE: we do NOT use pen-core's `updateNodeInTree` here because its
* semantics are shallow-merge (`{...oldNode, ...updates}`), which would
* leave stale fields from the old node if the replacement changes type or
* omits properties. Conflict resolution requires wholesale replacement —
* the user's chosen node (from theirs or manual edit) must fully supplant
* the old one with no residual fields leaking through.
*/
function replaceNodeById(
doc: PenDocument,
pageId: string | null,
nodeId: string,
replacement: PenNode,
): PenDocument {
if (doc.pages && pageId !== null) {
return {
...doc,
pages: doc.pages.map((page) =>
page.id === pageId
? { ...page, children: replaceNodeInArray(page.children, nodeId, replacement) }
: page,
),
};
}
// Legacy single-page or null pageId.
return {
...doc,
children: replaceNodeInArray(doc.children ?? [], nodeId, replacement),
};
}
/**
* Recursive tree walker that swaps a node by id with a wholesale replacement.
* Returns a new array — does not mutate the input.
*/
function replaceNodeInArray(nodes: PenNode[], id: string, replacement: PenNode): PenNode[] {
return nodes.map((n) => {
if (n.id === id) return replacement;
if ('children' in n && n.children) {
return {
...n,
children: replaceNodeInArray(n.children, id, replacement),
} as PenNode;
}
return n;
});
}
/**
* Remove a node from the document tree by id. Returns a new document.
*/
function removeNodeById(doc: PenDocument, pageId: string | null, nodeId: string): PenDocument {
if (doc.pages && pageId !== null) {
return {
...doc,
pages: doc.pages.map((page) =>
page.id === pageId
? { ...page, children: removeNodeFromArray(page.children, nodeId) }
: page,
),
};
}
return {
...doc,
children: removeNodeFromArray(doc.children ?? [], nodeId),
};
}
function removeNodeFromArray(nodes: PenNode[], id: string): PenNode[] {
const out: PenNode[] = [];
for (const n of nodes) {
if (n.id === id) continue;
if ('children' in n && n.children) {
out.push({ ...n, children: removeNodeFromArray(n.children, id) } as PenNode);
} else {
out.push(n);
}
}
return out;
}
/**
* Set a doc-field value by its dotted path. Used for variables/themes/etc.
* The path format matches DocFieldConflict.path (e.g.
* 'variables.color-1.value' or 'pages').
*/
function setDocFieldByPath(
doc: PenDocument,
field: string,
path: string,
value: unknown,
): PenDocument {
// Split the path into segments. The first segment matches `field`; the
// remaining segments navigate into nested object properties.
const segments = path.split('.');
if (segments.length === 0 || segments[0] !== field) {
// Top-level field, no nesting (e.g. field === 'name', path === 'name').
return { ...doc, [field]: value } as unknown as PenDocument;
}
if (segments.length === 1) {
return { ...doc, [field]: value } as unknown as PenDocument;
}
// Clone the field value and navigate to the parent of the leaf.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const docAny = doc as any;
const fieldValue = docAny[field];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cloned: any = JSON.parse(JSON.stringify(fieldValue ?? {}));
let cursor = cloned;
for (let i = 1; i < segments.length - 1; i++) {
const seg = segments[i];
if (cursor[seg] == null) cursor[seg] = {};
cursor = cursor[seg];
}
cursor[segments[segments.length - 1]] = value;
return { ...doc, [field]: cloned } as unknown as PenDocument;
}