openpencil/apps/desktop/git/repo-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

146 lines
4.7 KiB
TypeScript

// apps/desktop/git/repo-session.ts
//
// In-memory registry of open repositories. Each entry is allocated by
// engineDetect/Init/Open and disposed by engineClose. The renderer treats
// repoId as an opaque string and passes it back on every subsequent IPC
// call.
//
// This map is module-level (singleton). It does NOT survive a main-process
// restart, which matches the spec's session-scoped contract for repoId.
import { randomUUID } from 'node:crypto';
import type { IsoRepoHandle } from './git-iso';
import type { InflightMerge } from './merge-session';
export interface CandidateFileInfo {
/** absolute path */
path: string;
/** path relative to RepoSession.handle.dir, used for UI display */
relativePath: string;
/** # of commits on refs/heads/<currentBranch> that include this file */
milestoneCount: number;
/** # of commits on refs/openpencil/autosaves/<currentBranch> for this file */
autosaveCount: number;
/** timestamp (seconds) of the most recent touching commit, null if never */
lastCommitAt: number | null;
lastCommitMessage: string | null;
}
export interface RepoSession {
repoId: string;
handle: IsoRepoHandle;
/** Which .op file the panel is currently tracking. May be null right after
* an `open`/`clone` call where no auto-binding happened — the renderer
* must call bindTrackedFile to set it before commit/restore work. */
trackedFilePath: string | null;
/** Cached candidate list, populated by engineDetect/Init/Open and refreshed
* on demand by engineListCandidates. */
candidateFiles: CandidateFileInfo[];
/** Engine kind reported to the renderer. In Phase 2a always 'iso'. */
engineKind: 'iso' | 'sys';
/** In-flight merge state. Set by engineBranchMerge when conflicts are
* present, cleared by engineApplyMerge / engineAbortMerge. Null when no
* merge is in progress. */
inflightMerge: InflightMerge | null;
}
const sessions = new Map<string, RepoSession>();
/**
* Allocate a fresh session for a newly-opened repo. Returns the new repoId.
* The caller fills in trackedFilePath / candidateFiles before returning to
* the IPC layer.
*/
export function registerSession(args: {
handle: IsoRepoHandle;
trackedFilePath: string | null;
candidateFiles: CandidateFileInfo[];
engineKind: 'iso' | 'sys';
}): RepoSession {
const repoId = randomUUID();
const session: RepoSession = {
repoId,
handle: args.handle,
trackedFilePath: args.trackedFilePath,
candidateFiles: args.candidateFiles,
engineKind: args.engineKind,
inflightMerge: null,
};
sessions.set(repoId, session);
return session;
}
/**
* Look up an existing session by repoId. Returns undefined if the id is
* unknown — the engine layer translates this to a GitError('no-file').
*/
export function getSession(repoId: string): RepoSession | undefined {
return sessions.get(repoId);
}
/**
* Mutate the trackedFilePath of an existing session. Used by
* engineBindTrackedFile.
*/
export function updateTrackedFile(repoId: string, trackedFilePath: string): boolean {
const session = sessions.get(repoId);
if (!session) return false;
session.trackedFilePath = trackedFilePath;
return true;
}
/**
* Mutate the candidateFiles cache of an existing session. Used by
* engineListCandidates after re-walking the worktree.
*/
export function updateCandidates(repoId: string, candidateFiles: CandidateFileInfo[]): boolean {
const session = sessions.get(repoId);
if (!session) return false;
session.candidateFiles = candidateFiles;
return true;
}
/**
* Stash an in-flight merge on the session. Used by engineBranchMerge after
* detecting .op-level conflicts.
*/
export function setInflightMerge(repoId: string, merge: InflightMerge): boolean {
const session = sessions.get(repoId);
if (!session) return false;
session.inflightMerge = merge;
return true;
}
/**
* Clear the in-flight merge. Used by engineApplyMerge (after a successful
* commit) and engineAbortMerge (after the user discards).
*/
export function clearInflightMerge(repoId: string): boolean {
const session = sessions.get(repoId);
if (!session) return false;
session.inflightMerge = null;
return true;
}
/**
* Drop a session from the registry. Called by engineClose. The handle's
* gitdir/file descriptors are managed by isomorphic-git internally — there's
* nothing to flush here.
*/
export function unregisterSession(repoId: string): boolean {
return sessions.delete(repoId);
}
/**
* Wipe the entire registry. Used in tests' afterEach to keep sessions from
* leaking between cases. Production code should never call this.
*/
export function clearAllSessions(): void {
sessions.clear();
}
/** Test/debug helper: count active sessions. Not part of the IPC surface. */
export function sessionCount(): number {
return sessions.size;
}