openpencil/apps/desktop/git/merge-session.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

133 lines
4.6 KiB
TypeScript

// apps/desktop/git/merge-session.ts
//
// In-flight merge state for a single repo. Stored on RepoSession.inflightMerge
// during the conflict resolution loop. The conflict id codec lives here too
// because it's the only string format both the engine and the tests share.
import type { MergeResult, NodeConflict, DocFieldConflict } from '@zseven-w/pen-core';
import type { PenNode } from '@zseven-w/pen-types';
/**
* The user's choice for resolving a single conflict. Mirrors the spec's
* ConflictResolution union.
*/
export type ConflictResolution =
| { kind: 'ours' }
| { kind: 'theirs' }
| { kind: 'manual-node'; node: PenNode } // for node conflicts
| { kind: 'manual-field'; value: unknown }; // for doc-field conflicts
/**
* Wire-format conflict bag returned across IPC. The renderer wraps this in
* Maps for resolution tracking. Each conflict has a stable id that the
* renderer passes back via resolveConflict().
*/
export interface ConflictBag {
nodeConflicts: Array<NodeConflict & { id: string }>;
docFieldConflicts: Array<DocFieldConflict & { id: string }>;
}
export interface InflightMerge {
/** The current HEAD commit at the time branchMerge was invoked. */
oursCommit: string;
/** The branch tip we're merging in. */
theirsCommit: string;
/** Common ancestor commit. */
baseCommit: string;
/** Raw output from pen-core's mergeDocuments. */
mergeResult: MergeResult;
/** O(1) lookup of conflict by id. Built once at branchMerge time. */
conflictMap: Map<string, NodeConflict | DocFieldConflict>;
/** Accumulated user choices. Empty until resolveConflict is called. */
resolutions: Map<string, ConflictResolution>;
/** Default commit message for applyMerge. The renderer can override later. */
defaultMessage: string;
}
// ---------------------------------------------------------------------------
// Conflict id codec
//
// Encoding rules (matches spec line 836-841 verbatim):
// Node conflict: `node:${pageId ?? '_'}:${nodeId}`
// Doc-field conflict: `field:${field}:${path}`
//
// Stable, deterministic, both engine and renderer agree.
// ---------------------------------------------------------------------------
export function encodeNodeConflictId(conflict: NodeConflict): string {
return `node:${conflict.pageId ?? '_'}:${conflict.nodeId}`;
}
export function encodeDocFieldConflictId(conflict: DocFieldConflict): string {
return `field:${conflict.field}:${conflict.path}`;
}
export type ParsedConflictId =
| { kind: 'node'; pageId: string | null; nodeId: string }
| { kind: 'field'; field: string; path: string };
/**
* Parse a conflict id back into its components. Used by resolveConflict to
* locate the conflict in session state. Throws if the id is malformed —
* callers should treat that as a programming error (the renderer always
* passes back ids the engine just emitted).
*/
export function parseConflictId(id: string): ParsedConflictId {
if (id.startsWith('node:')) {
const rest = id.slice('node:'.length);
const colonIdx = rest.indexOf(':');
if (colonIdx === -1) {
throw new Error(`Malformed node conflict id: ${id}`);
}
const rawPage = rest.slice(0, colonIdx);
const nodeId = rest.slice(colonIdx + 1);
return {
kind: 'node',
pageId: rawPage === '_' ? null : rawPage,
nodeId,
};
}
if (id.startsWith('field:')) {
const rest = id.slice('field:'.length);
const colonIdx = rest.indexOf(':');
if (colonIdx === -1) {
throw new Error(`Malformed field conflict id: ${id}`);
}
return {
kind: 'field',
field: rest.slice(0, colonIdx),
path: rest.slice(colonIdx + 1),
};
}
throw new Error(`Unknown conflict id prefix: ${id}`);
}
/**
* Build a wire-format ConflictBag from a MergeResult by attaching ids. Used
* by branchMerge before stashing the InflightMerge in session state.
*
* Returns BOTH the bag AND the conflict map (id → conflict) so the caller
* can hydrate the InflightMerge in one pass without re-walking the result.
*/
export function buildConflictBag(result: MergeResult): {
bag: ConflictBag;
conflictMap: Map<string, NodeConflict | DocFieldConflict>;
} {
const conflictMap = new Map<string, NodeConflict | DocFieldConflict>();
const nodeConflicts = result.nodeConflicts.map((c) => {
const id = encodeNodeConflictId(c);
conflictMap.set(id, c);
return { ...c, id };
});
const docFieldConflicts = result.docFieldConflicts.map((c) => {
const id = encodeDocFieldConflictId(c);
conflictMap.set(id, c);
return { ...c, id };
});
return { bag: { nodeConflicts, docFieldConflicts }, conflictMap };
}