From a81deaa9d55d464da764f43b0ed39842174ef636 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Thu, 25 Jun 2026 14:48:38 -0400 Subject: [PATCH 1/2] Fix blank screen on detail view for projects with no filesystem paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core's toFull() omits the `paths` field entirely when a project has no filesystem paths (non-code projects: trips, partnerships, tutorials), but the renderer's ProjectFull type declared `paths: string[]` as always present. ProjectDetailView read `project.paths.length` unguarded, throwing a TypeError on every render. With no React error boundary in the tree, that throw unmounted the whole app — a blank screen with no recourse. - api.ts: ProjectFull.paths is now optional, matching reality and the rest of the renderer's guards (so TypeScript catches this class of bug). - ProjectDetailView.tsx: guard the three `project.paths` reads. - ErrorBoundary.tsx + App.tsx: wrap the view switch so a single view's render throw shows a recoverable card instead of blanking the app. - e2e: regression test — a path-less project's detail view renders fully and the error boundary does not fire. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014iR9LcchEebnoyt5GLRzev --- packages/app/e2e/app.spec.ts | 43 + packages/app/out/main/index.js | 864 ++++++++++++++++-- packages/app/out/preload/index.mjs | 15 +- .../{index-kwRb_dFu.js => index-CmUbUeOd.js} | 554 ++++++++--- ...{index-N6XCNrsE.css => index-Dt_XFKQb.css} | 12 + packages/app/out/renderer/index.html | 4 +- packages/app/src/renderer/App.tsx | 54 +- .../src/renderer/components/ErrorBoundary.tsx | 73 ++ packages/app/src/renderer/lib/api.ts | 5 +- .../src/renderer/views/ProjectDetailView.tsx | 9 +- 10 files changed, 1413 insertions(+), 220 deletions(-) rename packages/app/out/renderer/assets/{index-kwRb_dFu.js => index-CmUbUeOd.js} (98%) rename packages/app/out/renderer/assets/{index-N6XCNrsE.css => index-Dt_XFKQb.css} (99%) create mode 100644 packages/app/src/renderer/components/ErrorBoundary.tsx diff --git a/packages/app/e2e/app.spec.ts b/packages/app/e2e/app.spec.ts index aace3ce..acc688f 100644 --- a/packages/app/e2e/app.spec.ts +++ b/packages/app/e2e/app.spec.ts @@ -256,6 +256,49 @@ args = ["other.js"] } }) + test('opens the detail view for a project with no filesystem paths', async () => { + // Regression: core's toFull omits `paths` for path-less projects (trips, + // partnerships). ProjectDetailView read `project.paths.length` unguarded, + // which threw and — with no error boundary — blanked the whole app. + const home = mkdtempSync(resolve(tmpdir(), 'setlist-e2e-pathless-home-')) + const dbPath = join(home, 'registry.db') + const app = await electron.launch({ + args: [MAIN_ENTRY], + env: { ...process.env, HOME: home, SETLIST_APP_DB_PATH: dbPath } + }) + + try { + const window = await app.firstWindow() + await window.waitForLoadState('domcontentloaded') + + await window.evaluate(() => window.setlist.register({ + name: 'pathless-trip', + display_name: 'Pathless Trip', + type: 'project', + status: 'active', + description: 'A trip with no filesystem path' + })) + await window.reload() + await window.waitForLoadState('domcontentloaded') + + // Click the project card on the Home view. + await window.getByRole('button', { name: /Pathless Trip/ }).first().click() + + // The detail view must render, not blank out: header heading + the tab + // strip (which only exists in the detail view) confirm the tree mounted. + await expect(window.getByRole('heading', { name: 'Pathless Trip' }).first()).toBeVisible() + await expect(window.getByRole('tab', { name: 'Overview' })).toBeVisible() + // The error boundary must not have caught a render throw. + await expect(window.getByText('Something went wrong rendering this view')).toHaveCount(0) + // Force-exit before teardown: menu-bar persistence makes window close + // hide rather than quit, so app.close() can hang waiting for process exit. + await app.evaluate(({ app }) => app.exit(0)).catch(() => {}) + } finally { + await app.close() + rmSync(home, { recursive: true, force: true }) + } + }) + test('reassigns projects before deleting a project type from Settings', async () => { const home = mkdtempSync(resolve(tmpdir(), 'setlist-e2e-type-delete-home-')) const dbPath = join(home, 'registry.db') diff --git a/packages/app/out/main/index.js b/packages/app/out/main/index.js index 1b366e1..bd637e4 100644 --- a/packages/app/out/main/index.js +++ b/packages/app/out/main/index.js @@ -407,7 +407,218 @@ function snapshotRecipe(db, projectTypeId) { snapshot_at: (/* @__PURE__ */ new Date()).toISOString() }; } -const SCHEMA_VERSION = 16; +const VOCAB_FIELDS = [ + "tech_stack", + "patterns", + "topics", + "capability_type" +]; +const CANONICAL_VOCAB = { + tech_stack: [ + "typescript", + "javascript", + "python", + "rust", + "go", + "swift", + "kotlin", + "node", + "electron", + "react", + "tailwind", + "sqlite", + "postgres", + "redis", + "docker", + "fastapi", + "express", + "next", + "vite" + ], + patterns: [ + "mcp-server", + "cli", + "desktop-app", + "web-app", + "library", + "monorepo", + "event-driven", + "repository-pattern", + "factory-pattern", + "observer-pattern", + "singleton", + "adapter", + "plugin", + "rest-api", + "graphql", + "webhook", + "cron", + "queue" + ], + topics: [ + "agents", + "memory", + "registry", + "portfolio", + "spec-driven", + "claude-code", + "mcp", + "electron", + "tailwind", + "typescript", + "react", + "vector-search", + "fts5", + "sqlite", + "bootstrap", + "health" + ], + capability_type: [ + "tool", + "command", + "library", + "export", + "endpoint", + "database", + "event", + "webhook", + "resource" + ] +}; +const VOCAB_ALIASES = { + tech_stack: { + typescript: ["TypeScript", "ts", "TS"], + javascript: ["JavaScript", "js", "JS", "ecmascript"], + python: ["Python", "py", "Py"], + node: ["Node.js", "nodejs", "node-js"], + react: ["React.js", "reactjs", "react-js"], + next: ["Next.js", "nextjs", "next-js"], + postgres: ["postgresql", "pg"], + sqlite: ["SQLite", "sqlite3"], + tailwind: ["tailwindcss", "tailwind-css"], + fastapi: ["FastAPI", "fast-api"] + }, + patterns: { + "mcp-server": ["MCP Server", "mcp_server"], + cli: ["CLI", "command-line"], + "desktop-app": ["Desktop App", "desktop_app"], + "web-app": ["Web App", "webapp", "web_app"], + "repository-pattern": ["Repository Pattern", "repository", "repo-pattern"], + "factory-pattern": ["Factory Pattern", "factory"], + "observer-pattern": ["Observer Pattern", "observer"], + "event-driven": ["Event Driven", "event_driven", "eventdriven"], + "rest-api": ["REST API", "rest", "restapi"], + graphql: ["GraphQL", "gql"] + }, + topics: { + "claude-code": ["Claude Code", "claudecode", "claude_code"], + mcp: ["MCP", "Model Context Protocol", "model-context-protocol"], + typescript: ["TypeScript", "ts"], + react: ["React.js", "reactjs"], + "vector-search": ["Vector Search", "vectors", "embeddings"], + fts5: ["FTS5", "full-text-search"], + "spec-driven": ["Spec Driven", "spec_driven"] + }, + capability_type: { + tool: ["MCP tool", "mcp-tool", "mcp_tool"], + command: ["CLI command", "cli-command", "cli_command", "slash-command"], + export: ["library export", "lib-export", "symbol"], + endpoint: ["API endpoint", "route", "http-endpoint"], + database: ["db", "data-store"], + event: ["lifecycle-event"], + webhook: ["hook"], + resource: ["MCP resource"] + } +}; +function buildReverseMap(field) { + const map = /* @__PURE__ */ new Map(); + for (const canonical of Object.keys(VOCAB_ALIASES[field])) { + map.set(canonical, canonical); + for (const alias of VOCAB_ALIASES[field][canonical]) { + const normAlias = normalizeRaw(alias); + if (normAlias && !map.has(normAlias)) { + map.set(normAlias, canonical); + } + } + } + for (const canonical of CANONICAL_VOCAB[field]) { + if (!map.has(canonical)) + map.set(canonical, canonical); + } + return map; +} +const REVERSE_MAPS = { + tech_stack: buildReverseMap("tech_stack"), + patterns: buildReverseMap("patterns"), + topics: buildReverseMap("topics"), + capability_type: buildReverseMap("capability_type") +}; +function normalizeRaw(value) { + return value.toLowerCase().trim().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); +} +function normalize$1(field, value) { + if (typeof value !== "string") + return null; + const raw = normalizeRaw(value); + if (!raw) + return null; + return REVERSE_MAPS[field].get(raw) ?? raw; +} +function normalizeList(field, values) { + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const v of values) { + const norm = normalize$1(field, v); + if (norm && !seen.has(norm)) { + seen.add(norm); + out.push(norm); + } + } + return out; +} +function normalizeFieldValue(field, value) { + if (value == null) + return value; + if (Array.isArray(value)) { + return normalizeList(field, value.map(String)); + } + if (typeof value === "string") { + if (value.startsWith("[")) { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) + return normalizeList(field, parsed.map(String)); + } catch { + } + } + return normalize$1(field, value) ?? value; + } + return value; +} +function normalizeFreeList(values) { + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const v of values) { + if (typeof v !== "string") + continue; + const lower = v.toLowerCase().trim(); + if (!lower || seen.has(lower)) + continue; + seen.add(lower); + out.push(lower); + } + return out; +} +function normalizeRecord(record) { + const out = { ...record }; + for (const field of VOCAB_FIELDS) { + if (field in out) { + out[field] = normalizeFieldValue(field, out[field]); + } + } + return out; +} +const SCHEMA_VERSION = 17; const DEFAULT_DB_DIR = join(homedir(), ".local", "share", "project-registry"); const DEFAULT_DB_NAME = "registry.db"; function getDbPath() { @@ -682,6 +893,21 @@ CREATE TABLE IF NOT EXISTS project_type_recipe_steps ( UNIQUE(project_type_id, position) ); +-- v17 (spec 0.34): append-only interactions log backing derived recency +-- and frequency signals across every registry read surface. Rows are +-- immutable; recency and frequency derive from this table by query +-- (MAX(at), COUNT(*)) — never stored as mutable counters. Pruned by +-- reflection per the retention policy in #portfolio-memory. +CREATE TABLE IF NOT EXISTS interactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, + surface TEXT NOT NULL, + query TEXT, + at TEXT NOT NULL DEFAULT (datetime('now')), + session_id TEXT, + agent_role TEXT +); + -- Indexes CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(type); CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status); @@ -699,6 +925,10 @@ CREATE INDEX IF NOT EXISTS idx_project_ports_project_id ON project_ports(project CREATE INDEX IF NOT EXISTS idx_project_ports_port ON project_ports(port); CREATE INDEX IF NOT EXISTS idx_project_capabilities_project_id ON project_capabilities(project_id); CREATE INDEX IF NOT EXISTS idx_project_capabilities_type ON project_capabilities(capability_type); +-- v17 (spec 0.34): interactions indexes for recency-per-project queries +-- and global pruning passes. +CREATE INDEX IF NOT EXISTS idx_interactions_project_at ON interactions(project_id, at); +CREATE INDEX IF NOT EXISTS idx_interactions_at ON interactions(at); CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_id, status); CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope, status); CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type, status); @@ -1043,12 +1273,73 @@ function upgradeSchema(db) { } db.prepare(`UPDATE projects SET type = 'project', updated_at = datetime('now') WHERE type = 'area_of_focus'`).run(); } + if (currentVersion >= 16 && currentVersion < 17) { + backfillVocabNormalization(db); + } if (currentVersion >= 13 && currentVersion < 14) { seedBuiltinPrimitives(db); seedBuiltinRecipes(db); } db.prepare("INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION)); } +function backfillVocabNormalization(db) { + const doBackfill = db.transaction(() => { + const capRows = db.prepare("SELECT id, capability_type FROM project_capabilities").all(); + const updateCap = db.prepare("UPDATE project_capabilities SET capability_type = ? WHERE id = ?"); + for (const row of capRows) { + const canon = normalize$1("capability_type", String(row.capability_type ?? "")); + if (canon && canon !== row.capability_type) { + updateCap.run(canon, row.id); + } + } + const projectRows = db.prepare("SELECT id, topics FROM projects").all(); + const updateTopics = db.prepare("UPDATE projects SET topics = ?, updated_at = datetime('now') WHERE id = ?"); + for (const row of projectRows) { + if (!row.topics) + continue; + let parsed; + try { + parsed = JSON.parse(row.topics); + } catch { + continue; + } + if (!Array.isArray(parsed)) + continue; + const stringified = parsed.map((v) => String(v)); + const normalized = normalizeList("topics", stringified); + const next = JSON.stringify(normalized); + if (next !== row.topics) { + updateTopics.run(next, row.id); + } + } + const fieldRows = db.prepare("SELECT id, field_name, field_value FROM project_fields WHERE field_name IN ('tech_stack', 'patterns')").all(); + const updateField = db.prepare("UPDATE project_fields SET field_value = ?, updated_at = datetime('now') WHERE id = ?"); + for (const row of fieldRows) { + if (!row.field_value) + continue; + let nextValue = null; + if (row.field_value.startsWith("[")) { + try { + const parsed = JSON.parse(row.field_value); + if (Array.isArray(parsed)) { + const normalized = normalizeList(row.field_name, parsed.map((v) => String(v))); + nextValue = JSON.stringify(normalized); + } + } catch { + } + } + if (nextValue === null) { + const canon = normalize$1(row.field_name, row.field_value); + if (canon != null) + nextValue = canon; + } + if (nextValue !== null && nextValue !== row.field_value) { + updateField.run(nextValue, row.id); + } + } + }); + doBackfill(); +} function runV10ToV11Migration(db) { db.exec(` CREATE TABLE IF NOT EXISTS areas ( @@ -1520,6 +1811,35 @@ function writeFields(db, projectId, fields, producer) { upsert.run(projectId, name, serializeFieldValue(value), producer); } } +function logInteraction(db, input) { + try { + db.prepare(`INSERT INTO interactions (project_id, surface, query, session_id, agent_role) + VALUES (?, ?, ?, ?, ?)`).run(input.projectId ?? null, input.surface, input.query ?? null, input.sessionId ?? null, input.agentRole ?? null); + } catch { + } +} +const AMBIGUITY_GAP_THRESHOLD = 0.15; +const MAX_ALTERNATIVES = 4; +function detectAmbiguity(candidates) { + if (candidates.length < 2) { + return { ambiguous: false, alternatives: [] }; + } + const top = candidates[0]; + const second = candidates[1]; + if (top.score <= 0 || second.score <= 0) { + return { ambiguous: false, alternatives: [] }; + } + const gap = (top.score - second.score) / top.score; + if (gap > AMBIGUITY_GAP_THRESHOLD) { + return { ambiguous: false, alternatives: [] }; + } + const alternatives = candidates.slice(1, 1 + MAX_ALTERNATIVES).map((c) => ({ + name: c.name, + score: c.score, + why: c.why ?? "" + })); + return { ambiguous: true, alternatives }; +} const PORT_CONFIG_FILES = [ "vite.config.ts", "vite.config.js", @@ -2504,6 +2824,9 @@ function shouldIgnore(name) { } const PORT_RANGE_MIN = 3e3; const PORT_RANGE_MAX = 9999; +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} function normalizeProjectPath(p) { if (p === "~") return homedir(); @@ -2563,7 +2886,7 @@ class Registry { } } if (opts.fields) { - writeFields(db, projectId, opts.fields, producer); + writeFields(db, projectId, normalizeRecord(opts.fields), producer); } return projectId; } finally { @@ -2571,10 +2894,27 @@ class Registry { } } // ── Querying ────────────────────────────────────────────────── - getProject(name, depth = "standard") { + /** + * Public read surface for `get_project`. Logs an `interactions` row per + * S204 — including failed lookups, which carry project_id=NULL. + * + * Internal callers that need the formatted record without polluting the + * interactions signal must use `loadProjectRecord()` instead. Only the + * MCP `get_project` dispatcher (and equivalent UI surfaces a user touches + * directly) should call this. + */ + getProject(name, depth = "standard", opts) { + const shouldLog = opts?.logInteraction !== false; const db = this.open(); try { const record = this.loadRecord(db, { name }); + if (shouldLog) { + logInteraction(db, { + surface: "get_project", + projectId: record?.id ?? null, + query: null + }); + } if (!record) return null; return this.formatRecord(db, record, depth); @@ -2584,11 +2924,22 @@ class Registry { } /** * Get a project by name, throwing NotFoundError with fuzzy suggestion if not found. + * + * Logs an `interactions` row per S204 by default. Pass `{logInteraction: false}` + * for internal callers that should not contribute to the read-surface signal. */ - getProjectOrThrow(name, depth = "standard") { + getProjectOrThrow(name, depth = "standard", opts) { + const shouldLog = opts?.logInteraction !== false; const db = this.open(); try { const record = this.loadRecord(db, { name }); + if (shouldLog) { + logInteraction(db, { + surface: "get_project", + projectId: record?.id ?? null, + query: null + }); + } if (!record) { const allNames = db.prepare("SELECT name FROM projects").all(); const closest = findClosestMatch(name, allNames.map((r) => r.name)); @@ -2689,7 +3040,8 @@ class Registry { db.close(); } return { - project: this.getProjectOrThrow(projectName, "standard"), + // Internal caller — do not pollute the interactions signal. + project: this.getProjectOrThrow(projectName, "standard", { logInteraction: false }), inspection }; } @@ -2746,6 +3098,7 @@ class Registry { } } searchProjects(opts) { + const shouldLog = opts.logInteraction !== false; const db = this.open(); try { const q = `%${opts.query}%`; @@ -2778,6 +3131,14 @@ class Registry { } sql += " ORDER BY p.name"; const rows = db.prepare(sql).all(...params); + if (shouldLog) { + const topId = rows.length > 0 ? rows[0].id : null; + logInteraction(db, { + surface: "search_projects", + projectId: topId, + query: opts.query + }); + } return rows.map((row) => { const record = this.rowToRecord(db, row); return this.formatRecord(db, record, "summary"); @@ -2786,6 +3147,93 @@ class Registry { db.close(); } } + /** + * Spec 0.34 (#cross-project 2.9, S208): search with the ambiguous + * envelope attached. The `result` array is unchanged from + * `searchProjects()`. The envelope adds `ambiguous: boolean` and + * `alternatives: [{name, score, why}]` when the second-place candidate + * is within ~15% of the top. + * + * Score function: a simple keyword-presence count across name, + * description, goals, topics, and field_value. This is intentionally + * blunt — the registry's ambiguity contract is "raise the flag, let the + * LLM decide" (S209). A more sophisticated ranker would be a future + * evolution; the relative-gap detection is what matters today. + * + * Additive: callers reading only `result` see identical output. + */ + searchProjectsAmbiguous(opts) { + const rows = this.searchProjects(opts); + const trimmed = (opts.query ?? "").trim(); + if (trimmed.length < 3) { + return { result: rows, ambiguous: false, alternatives: [] }; + } + const candidates = this.scoreSearchCandidates(rows, opts.query); + const verdict = detectAmbiguity(candidates); + return { result: rows, ambiguous: verdict.ambiguous, alternatives: verdict.alternatives }; + } + /** + * Score every search-result row against the query and return the + * candidates sorted DESC by score. Helper for ambiguity detection; + * not exposed on the public read surfaces. + * + * Review finding #10: scores are differentiated by match quality so the + * top of a short-query result list isn't a tied-at-5 blob that the + * ambiguity detector flags as ambiguous on every common-letter search. + * + * Tier table (highest match wins per row; no double-counting on the + * name-shaped tiers): + * exact name match → 100 + * name starts with query → 50 + * name word-boundary match → 15 + * name substring match → 5 + * description match (any) → +2 additive + * + * With differentiated tiers, "all matches score 5" only happens when + * every match is a substring-only hit — which is the correct signal for + * the ambiguity detector to fire. Common short queries with one strong + * name match now produce a clear top vs. the noise behind it. + */ + scoreSearchCandidates(rows, query) { + const q = query.toLowerCase().trim(); + if (!q) + return rows.map((r) => ({ name: String(r.name), score: 0, why: "" })); + const candidates = rows.map((row) => { + const name = String(row.name); + const nameLower = name.toLowerCase(); + let score = 0; + let why = "fuzzy match"; + if (nameLower === q) { + score = 100; + why = "exact name match"; + } else if (nameLower.startsWith(q)) { + score = 50; + why = `name starts with "${q}"`; + } else if (new RegExp(`(^|[-_\\s])${escapeRegex(q)}([-_\\s]|$)`).test(nameLower)) { + score = 15; + why = `name word match "${q}"`; + } else if (nameLower.includes(q)) { + score = 5; + why = `name contains "${q}"`; + } + const desc = String(row.description ?? "").toLowerCase(); + if (desc.includes(q)) { + score += 2; + if (why === "fuzzy match") + why = `description fragment "${q}"`; + } + if (score === 0) { + const blob = JSON.stringify(row).toLowerCase(); + if (blob.includes(q)) { + score = 1; + why = "indirect match"; + } + } + return { name, score, why }; + }); + candidates.sort((a, b) => b.score - a.score); + return candidates; + } getRegistryStats() { const db = this.open(); try { @@ -3240,10 +3688,10 @@ class Registry { const existingTopics = JSON.parse(row.topics || "[]"); const existingEntities = JSON.parse(row.entities || "[]"); const existingConcerns = JSON.parse(row.concerns || "[]"); - const mergedGoals = profile.goals ? [.../* @__PURE__ */ new Set([...existingGoals, ...profile.goals])] : existingGoals; - const mergedTopics = profile.topics ? [.../* @__PURE__ */ new Set([...existingTopics, ...profile.topics.map((t) => t.toLowerCase())])] : existingTopics; - const mergedEntities = profile.entities ? [.../* @__PURE__ */ new Set([...existingEntities, ...profile.entities.map((e) => e.toLowerCase())])] : existingEntities; - const mergedConcerns = profile.concerns ? [.../* @__PURE__ */ new Set([...existingConcerns, ...profile.concerns.map((c) => c.toLowerCase())])] : existingConcerns; + const mergedGoals = profile.goals ? normalizeFreeList([...existingGoals, ...profile.goals]) : existingGoals; + const mergedTopics = profile.topics ? normalizeList("topics", [...existingTopics, ...profile.topics]) : existingTopics; + const mergedEntities = profile.entities ? normalizeFreeList([...existingEntities, ...profile.entities]) : existingEntities; + const mergedConcerns = profile.concerns ? normalizeFreeList([...existingConcerns, ...profile.concerns]) : existingConcerns; db.prepare(`UPDATE projects SET goals = ?, topics = ?, entities = ?, concerns = ?, updated_at = datetime('now') @@ -3288,7 +3736,7 @@ class Registry { const allNames = db.prepare("SELECT name FROM projects").all(); throw new NotFoundError(name, findClosestMatch(name, allNames.map((r) => r.name))); } - writeFields(db, row.id, fields, producer); + writeFields(db, row.id, normalizeRecord(fields), producer); if (paths) { const insertPath = db.prepare(`INSERT OR IGNORE INTO project_paths (project_id, path, added_by) VALUES (?, ?, ?)`); for (const p of paths) @@ -3490,7 +3938,8 @@ class Registry { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const cap of capabilities) { - insert.run(project.id, cap.name, cap.capability_type, cap.description ?? "", cap.inputs ?? "", cap.outputs ?? "", producer, cap.requires_auth != null ? cap.requires_auth ? 1 : 0 : null, cap.invocation_model ?? "", cap.audience ?? ""); + const normalizedType = normalize$1("capability_type", String(cap.capability_type)) ?? cap.capability_type; + insert.run(project.id, cap.name, normalizedType, cap.description ?? "", cap.inputs ?? "", cap.outputs ?? "", producer, cap.requires_auth != null ? cap.requires_auth ? 1 : 0 : null, cap.invocation_model ?? "", cap.audience ?? ""); } }); doReplace(); @@ -3519,9 +3968,11 @@ class Registry { if (!capabilityType) { throw new Error("registerCapabilitiesForType: capabilityType is required"); } + const canonicalCapabilityType = normalize$1("capability_type", capabilityType) ?? capabilityType; for (const cap of capabilities) { - if (cap.capability_type !== capabilityType) { - throw new Error(`registerCapabilitiesForType: mixed types not allowed — expected '${capabilityType}' but got '${cap.capability_type}' on capability '${cap.name}'`); + const incomingCanonical = normalize$1("capability_type", String(cap.capability_type)) ?? cap.capability_type; + if (incomingCanonical !== canonicalCapabilityType) { + throw new Error(`registerCapabilitiesForType: mixed types not allowed — expected '${canonicalCapabilityType}' but got '${incomingCanonical}' on capability '${cap.name}'`); } } const db = this.open(); @@ -3530,14 +3981,14 @@ class Registry { if (!project) throw new NotFoundError(projectName); const doReplace = db.transaction(() => { - db.prepare("DELETE FROM project_capabilities WHERE project_id = ? AND capability_type = ?").run(project.id, capabilityType); + db.prepare("DELETE FROM project_capabilities WHERE project_id = ? AND capability_type = ?").run(project.id, canonicalCapabilityType); const insert = db.prepare(` INSERT INTO project_capabilities (project_id, name, capability_type, description, inputs, outputs, producer, requires_auth, invocation_model, audience) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const cap of capabilities) { - insert.run(project.id, cap.name, cap.capability_type, cap.description ?? "", cap.inputs ?? "", cap.outputs ?? "", producer, cap.requires_auth != null ? cap.requires_auth ? 1 : 0 : null, cap.invocation_model ?? "", cap.audience ?? ""); + insert.run(project.id, cap.name, canonicalCapabilityType, cap.description ?? "", cap.inputs ?? "", cap.outputs ?? "", producer, cap.requires_auth != null ? cap.requires_auth ? 1 : 0 : null, cap.invocation_model ?? "", cap.audience ?? ""); } }); doReplace(); @@ -3555,13 +4006,18 @@ class Registry { `; const conditions = []; const params = []; + let resolvedProjectId = null; if (opts?.project_name) { conditions.push("p.name = ?"); params.push(opts.project_name); + const row = db.prepare("SELECT id FROM projects WHERE name = ?").get(opts.project_name); + if (row) + resolvedProjectId = row.id; } if (opts?.capability_type) { + const canonicalType = normalize$1("capability_type", opts.capability_type) ?? opts.capability_type; conditions.push("pc.capability_type = ?"); - params.push(opts.capability_type); + params.push(canonicalType); } if (opts?.keyword) { const kw = `%${opts.keyword}%`; @@ -3573,6 +4029,11 @@ class Registry { } sql += " ORDER BY p.name, pc.name"; const rows = db.prepare(sql).all(...params); + logInteraction(db, { + surface: "query_capabilities", + projectId: resolvedProjectId, + query: opts?.keyword ?? opts?.capability_type ?? null + }); return rows.map((row) => { const result = { project: row.project_name, @@ -3596,6 +4057,80 @@ class Registry { db.close(); } } + // ── Vocabulary (spec 0.34, S200) ────────────────────────────── + /** + * Build the in-use count map for one of the four open-vocabulary fields. + * Pure SQL — no LLM, no embedding, no side effects. Reads from: + * + * - `tech_stack`, `patterns`: `project_fields` (JSON arrays per project) + * - `topics`: `projects.topics` (JSON array on the projects row itself) + * - `capability_type`: `project_capabilities.capability_type` (one row per cap) + * + * Returns a map slug → count. Storage-format quirks (legacy comma-strings, + * empty arrays, NULLs) are tolerated quietly. + * + * Spec source: `#capability-declarations` (2.11) "The `vocab` MCP tool" + */ + countVocabInUse(field) { + const counts = /* @__PURE__ */ new Map(); + const db = this.open(); + try { + if (field === "capability_type") { + const rows2 = db.prepare("SELECT capability_type AS slug, COUNT(*) AS n FROM project_capabilities GROUP BY capability_type").all(); + for (const r of rows2) { + if (r.slug) + counts.set(r.slug, r.n); + } + return counts; + } + if (field === "topics") { + const rows2 = db.prepare("SELECT topics FROM projects").all(); + for (const r of rows2) { + if (!r.topics) + continue; + let parsed; + try { + parsed = JSON.parse(r.topics); + } catch { + continue; + } + if (!Array.isArray(parsed)) + continue; + for (const v of parsed) { + if (typeof v === "string" && v) + counts.set(v, (counts.get(v) ?? 0) + 1); + } + } + return counts; + } + const rows = db.prepare("SELECT field_value FROM project_fields WHERE field_name = ?").all(field); + for (const r of rows) { + if (!r.field_value) + continue; + let parsed; + if (r.field_value.startsWith("[")) { + try { + parsed = JSON.parse(r.field_value); + } catch { + parsed = null; + } + } else { + parsed = r.field_value.split(",").map((s) => s.trim()).filter(Boolean); + } + if (Array.isArray(parsed)) { + for (const v of parsed) { + if (typeof v === "string" && v) + counts.set(v, (counts.get(v) ?? 0) + 1); + } + } else if (typeof parsed === "string" && parsed) { + counts.set(parsed, (counts.get(parsed) ?? 0) + 1); + } + } + return counts; + } finally { + db.close(); + } + } // ── Project Digests ────────────────────────────────────────── /** * Read a single project's digest of the given kind. Returns null if no digest @@ -4366,7 +4901,16 @@ class MemoryStore { if (opts.reflect_threshold !== void 0) { upsert.run("reflect_threshold", String(opts.reflect_threshold)); } - const rows = db.prepare("SELECT key, value FROM schema_meta WHERE key IN ('embedding_provider', 'reflect_schedule', 'reflect_threshold')").all(); + if (opts.interactions_retention_days !== void 0) { + upsert.run("interactions_retention_days", String(opts.interactions_retention_days)); + } + if (opts.interactions_max_rows_per_project !== void 0) { + upsert.run("interactions_max_rows_per_project", String(opts.interactions_max_rows_per_project)); + } + const rows = db.prepare(`SELECT key, value FROM schema_meta WHERE key IN ( + 'embedding_provider', 'reflect_schedule', 'reflect_threshold', + 'interactions_retention_days', 'interactions_max_rows_per_project' + )`).all(); const config = {}; for (const r of rows) config[r.key] = r.value; @@ -4375,6 +4919,30 @@ class MemoryStore { db.close(); } } + /** + * Read the interactions retention policy from schema_meta, falling back + * to DEFAULT_RETENTION_POLICY for any unset key. Used by the reflection + * cycle to drive pruneInteractions(). + * + * Spec source: #portfolio-memory (2.12) "Retention policy", S206. + */ + getInteractionsRetentionPolicy() { + const db = this.open(); + try { + const rows = db.prepare(`SELECT key, value FROM schema_meta WHERE key IN ( + 'interactions_retention_days', 'interactions_max_rows_per_project' + )`).all(); + const map = new Map(rows.map((r) => [r.key, r.value])); + const days = Number(map.get("interactions_retention_days")); + const max = Number(map.get("interactions_max_rows_per_project")); + return { + retention_days: Number.isFinite(days) && days > 0 ? days : 90, + max_rows_per_project: Number.isFinite(max) && max > 0 ? max : 1e4 + }; + } finally { + db.close(); + } + } } function newId() { return randomUUID().replace(/-/g, ""); @@ -4456,6 +5024,16 @@ class MemoryRetrieval { break; } this.logRecall(db, opts.query ?? null, isBootstrap ? "bootstrap" : "search", budget, opts.project_id ?? null, results); + let projectIdForLog = null; + if (opts.project_id) { + const row = db.prepare("SELECT id FROM projects WHERE name = ?").get(opts.project_id); + projectIdForLog = row?.id ?? null; + } + logInteraction(db, { + surface: "recall", + projectId: projectIdForLog, + query: opts.query ?? null + }); return results; } finally { db.close(); @@ -4753,17 +5331,35 @@ function planMcpClientInstall(options = {}) { }; const existing = currentServers[serverName]; if (existing && !isSetlistManaged(existing)) { + if (!options.takeOver) { + return completeInstallPlan({ + ...base, + status: "conflict-unmanaged-server", + workflowStatus: ["detected", "conflict", "skipped"], + actions: [ + `${client.name}: leave existing unmanaged '${serverName}' MCP server untouched.` + ], + managedServers, + unmanagedServers, + preservedServers: unmanagedServers, + plannedServers: currentServers + }); + } return completeInstallPlan({ ...base, - status: "conflict-unmanaged-server", - workflowStatus: ["detected", "conflict", "skipped"], + status: "would-take-over", + workflowStatus: ["detected", "would_write"], actions: [ - `${client.name}: leave existing unmanaged '${serverName}' MCP server untouched.` + `${client.name}: would back up config before writing.`, + `${client.name}: would take over existing unmanaged '${serverName}' MCP server.`, + ...unmanagedServers.filter((name) => name !== serverName).map((name) => `${client.name}: would preserve unmanaged '${name}'.`) ], + backupRequired: true, + backupPath: `${configPath}.setlist-backup`, managedServers, unmanagedServers, - preservedServers: unmanagedServers, - plannedServers: currentServers + preservedServers: unmanagedServers.filter((name) => name !== serverName), + plannedServers }); } if (existing && entriesEquivalent(existing, desiredEntry)) { @@ -4945,6 +5541,7 @@ function applyMcpClientInstall(options = {}) { if (backupPath) runtime.copyFile(plan.configPath, backupPath); runtime.writeFile(plan.configPath, nextContents); + const wroteAction = plan.status === "would-take-over" ? `${plan.client.name}: took over unmanaged '${plan.serverName}' MCP server and wrote managed shape.` : `${plan.client.name}: wrote managed '${plan.serverName}' MCP server.`; return completeInstallResult({ operation: "install", client: plan.client, @@ -4958,7 +5555,7 @@ function applyMcpClientInstall(options = {}) { ], actions: [ ...backupPath ? [`${plan.client.name}: backed up config to ${backupPath}.`] : [], - `${plan.client.name}: wrote managed '${plan.serverName}' MCP server.` + wroteAction ], configPresent: plan.configPresent, backupPath, @@ -5163,6 +5760,14 @@ function summarizeRecovery(operation, clientName, status, fileStatus, backupStat if (status === "not-configured") { return `${clientName} was not changed because no managed Setlist MCP server was found.`; } + if (status === "would-take-over") { + if (fileStatus === "written") { + const backup2 = backupStatus === "created" ? ` Original entry preserved at the backup path.` : ""; + return `${clientName}: took over the existing unmanaged Setlist entry.${backup2}`; + } + const backup = backupStatus === "will_create" ? ` A backup will preserve the original first.` : ""; + return `${clientName}: would take over the existing unmanaged Setlist entry.${backup}`; + } if (fileStatus === "written") { const backup = backupStatus === "created" ? " A backup was created first." : " No backup was needed because Setlist created the config file."; return operation === "remove" ? `${clientName} config was updated to remove Setlist.${backup}` : `${clientName} config was written.${backup}`; @@ -5185,7 +5790,8 @@ function refreshPlanForApply(plan, options, runtime) { clients: [plan.client], runtime, serverName: plan.serverName, - server: clientEntryToServer(desiredEntry) + server: clientEntryToServer(desiredEntry), + takeOver: options.takeOver ?? plan.status === "would-take-over" }); if (!refreshed) throw new Error(`Could not refresh install plan for ${plan.client.name}.`); @@ -6427,7 +7033,7 @@ class Bootstrap { throw new BootstrapFolderExistsError(targetPath); } const registry2 = new Registry(this._dbPath); - const existing = registry2.getProject(opts.name); + const existing = registry2.getProject(opts.name, "standard", { logInteraction: false }); if (existing) { throw new RegistryError("DUPLICATE", `A project named '${opts.name}' is already registered.`, `Use update_project() to modify it, or choose a different name.`); } @@ -6500,7 +7106,7 @@ class Bootstrap { if (!config.archive_path_root) { return { ...result, folders_moved }; } - const project = registry2.getProject(name, "standard"); + const project = registry2.getProject(name, "standard", { logInteraction: false }); const paths = project?.paths; if (!paths || paths.length === 0) { return { ...result, folders_moved }; @@ -6633,7 +7239,7 @@ class Bootstrap { }; } const registry2 = new Registry(this._dbPath); - if (registry2.getProject(opts.name)) { + if (registry2.getProject(opts.name, "standard", { logInteraction: false })) { throw new RegistryError("DUPLICATE", `A project named '${opts.name}' is already registered.`, `Use update_project() to modify it, or choose a different name.`); } if (existsSync(targetPath)) { @@ -7462,7 +8068,8 @@ const DEFAULTS = { menu_bar_persistence_enabled: true, dock_visibility_when_hidden: "show", project_shortcuts_enabled: true, - project_shortcuts: { ...DEFAULT_PROJECT_SHORTCUTS } + project_shortcuts: { ...DEFAULT_PROJECT_SHORTCUTS }, + pinned_projects: [] }; let cached = null; let prefsPath = null; @@ -7479,7 +8086,8 @@ function merge(partial) { menu_bar_persistence_enabled: partial.menu_bar_persistence_enabled ?? base.menu_bar_persistence_enabled, dock_visibility_when_hidden: partial.dock_visibility_when_hidden ?? base.dock_visibility_when_hidden, project_shortcuts_enabled: partial.project_shortcuts_enabled ?? base.project_shortcuts_enabled, - project_shortcuts: partial.project_shortcuts ? normalizeProjectShortcuts(partial.project_shortcuts) : base.project_shortcuts + project_shortcuts: partial.project_shortcuts ? normalizeProjectShortcuts(partial.project_shortcuts) : base.project_shortcuts, + pinned_projects: partial.pinned_projects ? normalizePinnedProjects(partial.pinned_projects) : base.pinned_projects }; } function isValidChannel(v) { @@ -7488,6 +8096,19 @@ function isValidChannel(v) { function isValidDockVisibility(v) { return v === "show" || v === "hide"; } +function normalizePinnedProjects(raw) { + if (!Array.isArray(raw)) return []; + const seen = /* @__PURE__ */ new Set(); + const pinned = []; + for (const value of raw) { + if (typeof value !== "string") continue; + const name = value.trim(); + if (!name || seen.has(name)) continue; + seen.add(name); + pinned.push(name); + } + return pinned; +} function normalize(raw) { if (!raw || typeof raw !== "object") return { ...DEFAULTS }; const obj = raw; @@ -7512,7 +8133,8 @@ function normalize(raw) { menu_bar_persistence_enabled: typeof obj.menu_bar_persistence_enabled === "boolean" ? obj.menu_bar_persistence_enabled : DEFAULTS.menu_bar_persistence_enabled, dock_visibility_when_hidden: dockVisibility, project_shortcuts_enabled: typeof obj.project_shortcuts_enabled === "boolean" ? obj.project_shortcuts_enabled : DEFAULTS.project_shortcuts_enabled, - project_shortcuts: normalizeProjectShortcuts(obj.project_shortcuts) + project_shortcuts: normalizeProjectShortcuts(obj.project_shortcuts), + pinned_projects: normalizePinnedProjects(obj.pinned_projects) }; } function loadPrefs() { @@ -7576,6 +8198,12 @@ function setAppBehaviorPrefs(patch) { project_shortcuts: next.project_shortcuts }; } +function getPinnedProjects() { + return [...loadPrefs().pinned_projects]; +} +function setPinnedProjects(projectNames) { + return [...setPrefs({ pinned_projects: normalizePinnedProjects(projectNames) }).pinned_projects]; +} const { autoUpdater } = pkg; let initialized = false; let checkInFlight = false; @@ -7711,6 +8339,9 @@ function installDownloadedUpdate() { quitAndInstall(); return true; } +function isInstallingDownloadedUpdate() { + return installing; +} function registerQuitPrompt(electronApp, getMainWindow) { electronApp.on("before-quit", (event) => { if (installing) return; @@ -7773,6 +8404,20 @@ function selectMcpClients(selection = "all") { function missingPlannedClientNames(clients, plans) { return clients.filter((client) => !plans.has(client.id)).map((client) => client.name); } +function summarizePinnedProject(project) { + if (!project || project.status === "archived" || typeof project.name !== "string") return null; + const paths = Array.isArray(project.paths) ? project.paths.filter((path) => typeof path === "string" && path.trim().length > 0) : []; + return { + name: project.name, + display_name: typeof project.display_name === "string" && project.display_name.trim() ? project.display_name : project.name, + status: typeof project.status === "string" ? project.status : "active", + first_path: paths[0] ?? null + }; +} +function listPinnedProjects() { + const reg = getRegistry(); + return getPinnedProjects().map((projectName) => summarizePinnedProject(reg.getProject(projectName, "full", { logInteraction: false }))).filter((project) => project !== null); +} function registerIpcHandlers(ipcMain2, opts) { const reg = getRegistry(); const mcpInstallPlans = /* @__PURE__ */ new Map(); @@ -7811,7 +8456,7 @@ function registerIpcHandlers(ipcMain2, opts) { }); ipcMain2.handle("updateCore", (_e, name, updates) => { reg.updateCore(name, updates); - return reg.getProject(name, "standard"); + return reg.getProject(name, "standard", { logInteraction: false }); }); ipcMain2.handle("updateFields", (_e, name, fields, producer) => { reg.updateFields(name, fields, producer ?? "setlist-app"); @@ -7897,8 +8542,17 @@ function registerIpcHandlers(ipcMain2, opts) { ipcMain2.handle("getShortcutRegistrationStatuses", () => { return opts?.getShortcutRegistrationStatuses?.() ?? []; }); + ipcMain2.handle("getPinnedProjects", () => getPinnedProjects()); + ipcMain2.handle("setPinnedProjects", (_e, projectNames) => { + const pinned_projects = setPinnedProjects(projectNames); + opts?.onPinnedProjectsChanged?.(); + return pinned_projects; + }); + ipcMain2.handle("listPinnedProjects", () => { + return listPinnedProjects(); + }); ipcMain2.handle("projectActions:openPath", async (_e, projectName, selectedPath) => { - const project = reg.getProject(projectName, "full"); + const project = reg.getProject(projectName, "full", { logInteraction: false }); const paths = Array.isArray(project?.paths) ? project.paths : []; const path = selectedPath ?? paths[0]; if (!path || !paths.includes(path)) { @@ -7908,7 +8562,7 @@ function registerIpcHandlers(ipcMain2, opts) { return { ok: error.length === 0, error: error || null }; }); ipcMain2.handle("projectActions:copyBriefCommand", (_e, projectName) => { - const project = reg.getProject(projectName, "summary"); + const project = reg.getProject(projectName, "summary", { logInteraction: false }); if (!project) throw new Error(`Project not found: ${projectName}`); const command = projectBriefCommand(projectName); clipboard.writeText(command); @@ -7995,12 +8649,15 @@ function registerIpcHandlers(ipcMain2, opts) { ipcMain2.handle("mcpClients:detect", (_e, selection) => { return detectMcpClients({ clients: selectMcpClients(selection) }); }); - ipcMain2.handle("mcpClients:planInstall", (_e, selection) => { - const plans = planMcpClientInstall({ clients: selectMcpClients(selection) }); + ipcMain2.handle("mcpClients:planInstall", (_e, selection, options) => { + const plans = planMcpClientInstall({ + clients: selectMcpClients(selection), + takeOver: options?.takeOver + }); for (const plan of plans) mcpInstallPlans.set(plan.client.id, plan); return plans; }); - ipcMain2.handle("mcpClients:applyInstall", (_e, selection) => { + ipcMain2.handle("mcpClients:applyInstall", (_e, selection, options) => { const clients = selectMcpClients(selection); const missingPlans = missingPlannedClientNames(clients, mcpInstallPlans); if (missingPlans.length > 0) { @@ -8011,7 +8668,11 @@ function registerIpcHandlers(ipcMain2, opts) { throw new Error("No planned MCP client config writes to apply."); } try { - return applyMcpClientInstall({ clients, plans }); + return applyMcpClientInstall({ + clients, + plans, + takeOver: options?.takeOver + }); } finally { for (const client of clients) mcpInstallPlans.delete(client.id); } @@ -8142,6 +8803,7 @@ async function handleMenuCheckForUpdates() { await checkForUpdates(); } let tray = null; +let trayOptions = null; const TRAY_ICON_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAABmJLR0QA/wD/AP+gvaeTAAABCUlEQVQ4ja3UvS4EURjG8R82YgQbCoWPRBQUqr0AlSsQV6DbS3AVOnENEonoNCLCVQiFAqEQspIVRiicEzObsTO72Sd5infOvP95zjlvhgFp6J/nC5jHZHCCCUxhBLc4RFrUXMc+XvFdwQdFkGFcVgREf4WEOW30CImeyyaBRlHMEj3gMRa1Cg0p7tHGG1oBsOt3ezltdonfrBIvXn+Ca5k9Z/QZEqV4wQeecI69UOe0hlO8d0nX6ZOypHVMYxk7JbB18oe9gm3MBFCCcayWfLSBiwgaxZniMypTwt8cLfYJgatsMau/yb7BWCf5qAdAC8dYis3Z30gNW2ExDS+35Ce6jWfcBeDg9QMwIX3t2lBEKAAAAABJRU5ErkJggg=="; function createTrayIcon() { const candidates = [ @@ -8154,28 +8816,66 @@ function createTrayIcon() { icon.setTemplateImage(true); return icon; } +function buildPinnedProjectItems(opts) { + const projects = opts.getPinnedProjects(); + if (projects.length === 0) { + return [{ label: "No Pinned Projects", enabled: false }]; + } + return projects.map((project) => ({ + label: project.display_name, + sublabel: project.first_path ?? void 0, + click: (_menuItem, _window, event) => { + if (event.altKey) { + opts.openProjectInSetlist(project.name); + return; + } + if (!project.first_path) { + console.error(`[tray] Pinned project has no registered folder: ${project.name}`); + return; + } + void opts.openProjectFolder(project.first_path).then((result) => { + if (!result.ok) { + console.error(`[tray] Failed to open pinned project folder: ${result.error ?? project.first_path}`); + } + }); + } + })); +} +function buildTrayMenu(opts) { + return Menu.buildFromTemplate([ + { label: "Open App", click: () => opts.showWindow() }, + { + label: "Pinned Projects", + submenu: buildPinnedProjectItems(opts) + }, + { type: "separator" }, + { label: "Preferences...", click: () => opts.openSettings() }, + { type: "separator" }, + { label: "Quit Setlist", click: () => opts.quit() } + ]); +} +function showTrayMenu() { + if (!tray || !trayOptions) return; + tray.popUpContextMenu(buildTrayMenu(trayOptions)); +} function installTray(opts) { + trayOptions = opts; if (!tray) { const icon = createTrayIcon(); tray = new Tray(icon); tray.setToolTip("Setlist"); - tray.on("click", () => opts.showWindow()); + tray.on("click", showTrayMenu); + tray.on("right-click", showTrayMenu); } - tray.setContextMenu(Menu.buildFromTemplate([ - { label: "Open Setlist", click: () => opts.showWindow() }, - { label: "Settings", click: () => opts.openSettings() }, - { type: "separator" }, - { - label: "Check for Updates...", - click: () => { - void opts.checkForUpdates(); - } - }, - { type: "separator" }, - { label: "Quit Setlist", click: () => opts.quit() } - ])); + tray.setContextMenu(buildTrayMenu(opts)); return tray; } +function shouldHideWindowInsteadOfClosing$1(snapshot) { + return snapshot.menuBarPersistenceEnabled && !snapshot.isQuitting && !snapshot.isInstallingDownloadedUpdate; +} +function shouldKeepRunningInsteadOfQuitting(snapshot) { + return snapshot.menuBarPersistenceEnabled && !snapshot.isQuitting && !snapshot.isInstallingDownloadedUpdate && !snapshot.isUpdateDownloaded; +} const PROJECT_ACTION_SHORTCUT_CHANNEL = "project-action-shortcut"; let registeredShortcuts = []; let statuses = PROJECT_ACTIONS.map((action) => ({ @@ -8247,6 +8947,7 @@ if (!gotLock) { } let mainWindow = null; let isQuitting = false; +const NAVIGATE_TO_PROJECT_CHANNEL = "navigate-to-project"; function onPreventableMinimize(window, handler) { const onMinimize = window.on; onMinimize.call(window, "minimize", handler); @@ -8275,10 +8976,45 @@ function openSettings() { const target = showWindow(); target?.webContents.send("navigate-to-settings"); } +function openProjectInSetlist(projectName) { + const target = showWindow(); + target?.webContents.send(NAVIGATE_TO_PROJECT_CHANNEL, { projectName }); +} +async function openProjectFolder(path) { + const error = await shell.openPath(path); + return { ok: error.length === 0, error: error || null }; +} function quitApp() { isQuitting = true; app.quit(); } +function shouldHideWindowInsteadOfClosing() { + const prefs = getAppBehaviorPrefs(); + return shouldHideWindowInsteadOfClosing$1({ + menuBarPersistenceEnabled: prefs.menu_bar_persistence_enabled, + isQuitting, + isInstallingDownloadedUpdate: isInstallingDownloadedUpdate() + }); +} +function shouldKeepRunningOnQuit() { + const prefs = getAppBehaviorPrefs(); + return shouldKeepRunningInsteadOfQuitting({ + menuBarPersistenceEnabled: prefs.menu_bar_persistence_enabled, + isQuitting, + isInstallingDownloadedUpdate: isInstallingDownloadedUpdate(), + isUpdateDownloaded: isUpdateDownloaded() + }); +} +function refreshTray() { + installTray({ + showWindow, + openSettings, + getPinnedProjects: listPinnedProjects, + openProjectFolder, + openProjectInSetlist, + quit: quitApp + }); +} function refreshShortcuts() { return registerProjectShortcuts({ showWindow }); } @@ -8325,16 +9061,14 @@ function createWindow() { updateDockVisibility(); }); mainWindow.on("close", (event) => { - const prefs = getAppBehaviorPrefs(); - if (prefs.menu_bar_persistence_enabled && !isQuitting) { + if (shouldHideWindowInsteadOfClosing()) { event.preventDefault(); mainWindow?.hide(); updateDockVisibility(); } }); onPreventableMinimize(mainWindow, (event) => { - const prefs = getAppBehaviorPrefs(); - if (prefs.menu_bar_persistence_enabled) { + if (shouldHideWindowInsteadOfClosing()) { event.preventDefault(); mainWindow?.hide(); updateDockVisibility(); @@ -8351,15 +9085,11 @@ app.whenReady().then(() => { loadPrefs(); registerIpcHandlers(ipcMain, { onAppBehaviorPrefsChanged: handleAppBehaviorPrefsChanged, - getShortcutRegistrationStatuses + getShortcutRegistrationStatuses, + onPinnedProjectsChanged: refreshTray }); installAppMenu(handleMenuCheckForUpdates); - installTray({ - showWindow, - openSettings, - checkForUpdates: handleMenuCheckForUpdates, - quit: quitApp - }); + refreshTray(); createWindow(); refreshShortcuts(); if (!process.env.ELECTRON_RENDERER_URL) { @@ -8370,14 +9100,20 @@ app.whenReady().then(() => { showWindow(); }); }); -app.on("before-quit", () => { +app.on("before-quit", (event) => { + if (shouldKeepRunningOnQuit()) { + event.preventDefault(); + mainWindow?.hide(); + updateDockVisibility(); + return; + } isQuitting = true; }); app.on("will-quit", () => { unregisterProjectShortcuts(); }); app.on("window-all-closed", () => { - if (!getAppBehaviorPrefs().menu_bar_persistence_enabled || isQuitting) { + if (!getAppBehaviorPrefs().menu_bar_persistence_enabled || isQuitting || isInstallingDownloadedUpdate()) { app.quit(); } else { updateDockVisibility(); diff --git a/packages/app/out/preload/index.mjs b/packages/app/out/preload/index.mjs index c53ffbe..440de30 100644 --- a/packages/app/out/preload/index.mjs +++ b/packages/app/out/preload/index.mjs @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from "electron"; const UPDATE_EVENT_CHANNEL = "update-event"; const NAVIGATE_TO_SETTINGS_CHANNEL = "navigate-to-settings"; +const NAVIGATE_TO_PROJECT_CHANNEL = "navigate-to-project"; const PROJECT_ACTION_SHORTCUT_CHANNEL = "project-action-shortcut"; const api = { // Project Identity @@ -59,8 +60,8 @@ const api = { appendRecipeStep: (projectTypeId, primitiveId, params) => ipcRenderer.invoke("recipes:append", projectTypeId, primitiveId, params), // MCP client installer workflow detectMcpClients: (selection) => ipcRenderer.invoke("mcpClients:detect", selection), - planMcpClientInstall: (selection) => ipcRenderer.invoke("mcpClients:planInstall", selection), - applyMcpClientInstall: (selection) => ipcRenderer.invoke("mcpClients:applyInstall", selection), + planMcpClientInstall: (selection, options) => ipcRenderer.invoke("mcpClients:planInstall", selection, options), + applyMcpClientInstall: (selection, options) => ipcRenderer.invoke("mcpClients:applyInstall", selection, options), planMcpClientRemove: (selection) => ipcRenderer.invoke("mcpClients:planRemove", selection), applyMcpClientRemove: (selection) => ipcRenderer.invoke("mcpClients:applyRemove", selection), // Auto-Update (#auto-update) @@ -71,6 +72,9 @@ const api = { getAppBehaviorPrefs: () => ipcRenderer.invoke("getAppBehaviorPrefs"), setAppBehaviorPrefs: (patch) => ipcRenderer.invoke("setAppBehaviorPrefs", patch), getShortcutRegistrationStatuses: () => ipcRenderer.invoke("getShortcutRegistrationStatuses"), + getPinnedProjects: () => ipcRenderer.invoke("getPinnedProjects"), + setPinnedProjects: (projectNames) => ipcRenderer.invoke("setPinnedProjects", projectNames), + listPinnedProjects: () => ipcRenderer.invoke("listPinnedProjects"), openProjectPath: (projectName, selectedPath) => ipcRenderer.invoke("projectActions:openPath", projectName, selectedPath), copyProjectBriefCommand: (projectName) => ipcRenderer.invoke("projectActions:copyBriefCommand", projectName), onUpdateEvent: (handler) => { @@ -89,6 +93,13 @@ const api = { ipcRenderer.removeListener(NAVIGATE_TO_SETTINGS_CHANNEL, wrapped); }; }, + onNavigateToProject: (handler) => { + const wrapped = (_e, payload) => handler(payload); + ipcRenderer.on(NAVIGATE_TO_PROJECT_CHANNEL, wrapped); + return () => { + ipcRenderer.removeListener(NAVIGATE_TO_PROJECT_CHANNEL, wrapped); + }; + }, onProjectActionShortcut: (handler) => { const wrapped = (_e, payload) => handler(payload); ipcRenderer.on(PROJECT_ACTION_SHORTCUT_CHANNEL, wrapped); diff --git a/packages/app/out/renderer/assets/index-kwRb_dFu.js b/packages/app/out/renderer/assets/index-CmUbUeOd.js similarity index 98% rename from packages/app/out/renderer/assets/index-kwRb_dFu.js rename to packages/app/out/renderer/assets/index-CmUbUeOd.js index 06df6f3..7a2ef0e 100644 --- a/packages/app/out/renderer/assets/index-kwRb_dFu.js +++ b/packages/app/out/renderer/assets/index-CmUbUeOd.js @@ -17733,6 +17733,9 @@ const AREA_COLOR_PALETTE = [ "#84cc16", "#8b5cf6" ]; +function optionalPinnedBridge() { + return window.setlist; +} const api = { listProjects: (opts) => window.setlist.listProjects(opts), getProject: (name, depth) => window.setlist.getProject(name, depth), @@ -17781,8 +17784,8 @@ const api = { appendRecipeStep: (projectTypeId, primitiveId, params) => window.setlist.appendRecipeStep(projectTypeId, primitiveId, params), // ── MCP client installer workflow ───────────────────────────── detectMcpClients: (selection = "all") => window.setlist.detectMcpClients(selection), - planMcpClientInstall: (selection = "all") => window.setlist.planMcpClientInstall(selection), - applyMcpClientInstall: (selection = "all") => window.setlist.applyMcpClientInstall(selection), + planMcpClientInstall: (selection = "all", options) => window.setlist.planMcpClientInstall(selection, options), + applyMcpClientInstall: (selection = "all", options) => window.setlist.applyMcpClientInstall(selection, options), planMcpClientRemove: (selection = "all") => window.setlist.planMcpClientRemove(selection), applyMcpClientRemove: (selection = "all") => window.setlist.applyMcpClientRemove(selection), assessHealth: (name, opts) => window.setlist.assessHealth(name, opts), @@ -17798,6 +17801,28 @@ const api = { getShortcutRegistrationStatuses: () => window.setlist.getShortcutRegistrationStatuses(), openProjectPath: (projectName, selectedPath) => window.setlist.openProjectPath(projectName, selectedPath), copyProjectBriefCommand: (projectName) => window.setlist.copyProjectBriefCommand(projectName), + getPinnedProjects: async () => { + const fn = optionalPinnedBridge().getPinnedProjects; + return fn ? fn() : []; + }, + setPinnedProjects: async (projectNames) => { + const fn = optionalPinnedBridge().setPinnedProjects; + if (!fn) throw new Error("Pinned projects are not available in this app build"); + return fn(projectNames); + }, + listPinnedProjects: async () => { + const fn = optionalPinnedBridge().listPinnedProjects; + return fn ? fn() : []; + }, + onNavigateToProject: (handler) => { + const fn = optionalPinnedBridge().onNavigateToProject; + if (!fn) return () => { + }; + return fn((payload) => { + const projectName = typeof payload === "string" ? payload : payload.projectName ?? payload.name; + if (projectName) handler(projectName); + }); + }, onProjectActionShortcut: (handler) => window.setlist.onProjectActionShortcut(handler), onUpdateEvent: (handler) => window.setlist.onUpdateEvent(handler) }; @@ -19504,6 +19529,96 @@ function HeaderButton({ } ); } +function SignalsFromDisk({ + report, + loading, + onRefresh, + refreshLabel = "Refresh", + headerLabel = "Signals from disk", + emptyLabel = "No inspection available yet." +}) { + if (!report) { + return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "rounded-md border border-[var(--color-border)] bg-[var(--color-bg-card)] p-3 space-y-2", children: [ + /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex items-center justify-between gap-3", children: [ + /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "text-xs font-medium text-[var(--color-text-primary)]", children: headerLabel }), + onRefresh && /* @__PURE__ */ jsxRuntimeExports.jsx( + "button", + { + onClick: onRefresh, + disabled: loading, + className: "px-2 py-0.5 rounded text-[11px]\n bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)]\n border border-[var(--color-border)] hover:border-[var(--color-border-strong)]\n disabled:opacity-50 transition-colors", + children: loading ? "Refreshing..." : refreshLabel + } + ) + ] }), + /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "text-xs text-[var(--color-text-tertiary)]", children: emptyLabel }) + ] }); + } + const path = report.paths[0]; + const codeSignals = path?.code; + const folderSignals = path?.folder; + const documentCounts = folderSignals ? Object.entries(folderSignals.document_counts).map(([ext, count2]) => `${count2} ${ext}`).join(", ") : ""; + const gaps = path?.gaps ?? []; + return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "rounded-md border border-[var(--color-border)] bg-[var(--color-bg-card)] p-3 space-y-2", children: [ + /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex items-center justify-between gap-3", children: [ + /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "text-xs font-medium text-[var(--color-text-primary)]", children: headerLabel }), + /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [ + /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[11px] uppercase tracking-wider text-[var(--color-text-tertiary)]", children: path?.status ?? report.summary.kind }), + onRefresh && /* @__PURE__ */ jsxRuntimeExports.jsx( + "button", + { + onClick: onRefresh, + disabled: loading, + className: "px-2 py-0.5 rounded text-[11px]\n bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)]\n border border-[var(--color-border)] hover:border-[var(--color-border-strong)]\n disabled:opacity-50 transition-colors", + children: loading ? "Refreshing..." : refreshLabel + } + ) + ] }) + ] }), + path?.error && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "text-xs text-[var(--color-error)]", children: path.error }), + codeSignals && /* @__PURE__ */ jsxRuntimeExports.jsx( + SignalRow, + { + label: "Code signals", + value: [ + codeSignals.package_name, + codeSignals.likely_languages.slice(0, 3).join(", "), + codeSignals.framework_hints.slice(0, 3).join(", "), + codeSignals.test_commands.length > 0 ? `${codeSignals.test_commands.length} test command${codeSignals.test_commands.length === 1 ? "" : "s"}` : "" + ].filter(Boolean).join(" · ") || "No code manifests or language files found" + } + ), + folderSignals && /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [ + /* @__PURE__ */ jsxRuntimeExports.jsx( + SignalRow, + { + label: "Folder signals", + value: [ + folderSignals.top_level_dirs.length > 0 ? `${folderSignals.top_level_dirs.length} top-level folder${folderSignals.top_level_dirs.length === 1 ? "" : "s"}` : "", + folderSignals.top_level_files.length > 0 ? `${folderSignals.top_level_files.length} top-level file${folderSignals.top_level_files.length === 1 ? "" : "s"}` : "", + documentCounts + ].filter(Boolean).join(" · ") || "No visible folder signals found" + } + ), + folderSignals.notable_files.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsx(SignalRow, { label: "Notable files", value: folderSignals.notable_files.slice(0, 4).join(", ") }), + folderSignals.material_hints.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsx(SignalRow, { label: "Workspace hints", value: folderSignals.material_hints.join(", ") }) + ] }), + gaps.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsx(SignalRow, { label: "Gaps", value: gaps.join(", "), warning: true }), + /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "text-[11px] text-[var(--color-text-tertiary)]", children: [ + "Scanned shallow directory metadata only: ", + path?.entries_scanned ?? 0, + " visible entr", + (path?.entries_scanned ?? 0) === 1 ? "y" : "ies", + "." + ] }) + ] }); +} +function SignalRow({ label, value, warning }) { + return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "grid grid-cols-[92px_1fr] gap-2 text-xs", children: [ + /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[var(--color-text-tertiary)]", children: label }), + /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: warning ? "text-[var(--color-warning)]" : "text-[var(--color-text-secondary)]", children: value }) + ] }); +} function parseArray(value) { if (Array.isArray(value)) return value; if (typeof value === "string") { @@ -19518,7 +19633,7 @@ function parseArray(value) { } return []; } -function OverviewTab({ project, brief, onNavigate, healthRefreshNonce = 0 }) { +function OverviewTab({ project, brief, onNavigate, healthRefreshNonce = 0, onRefreshSignals }) { const health = useProjectHealth(project.name, project.updated_at, healthRefreshNonce); const goals = parseArray(project.goals); const topics = parseArray(project.topics); @@ -19534,7 +19649,14 @@ function OverviewTab({ project, brief, onNavigate, healthRefreshNonce = 0 }) { return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "grid gap-4 xl:grid-cols-[minmax(0,1.7fr)_minmax(280px,0.8fr)]", children: [ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "space-y-4", children: [ brief && /* @__PURE__ */ jsxRuntimeExports.jsx(AgentBriefSection, { brief }), - /* @__PURE__ */ jsxRuntimeExports.jsx(HealthSection, { health }) + /* @__PURE__ */ jsxRuntimeExports.jsx(HealthSection, { health }), + /* @__PURE__ */ jsxRuntimeExports.jsx( + SignalsFromDiskSection, + { + report: brief?.source_inspection ?? null, + onRefresh: onRefreshSignals + } + ) ] }), /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "space-y-4", children: [ hasStructural && /* @__PURE__ */ jsxRuntimeExports.jsx(Section, { title: "Structure", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "space-y-2 text-sm", children: [ @@ -19693,6 +19815,32 @@ function DimensionCell({ label, dim }) { /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "text-xs text-[var(--color-text-secondary)]", children: TIER_LABEL[dim.tier] }) ] }); } +function SignalsFromDiskSection({ + report, + onRefresh +}) { + const [refreshing, setRefreshing] = reactExports.useState(false); + const handleRefresh = onRefresh ? () => { + const result = onRefresh(); + if (result && typeof result.then === "function") { + setRefreshing(true); + result.finally(() => setRefreshing(false)); + } + } : void 0; + return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "rounded-[10px] border border-[var(--color-border)] bg-[var(--color-bg-card)] p-4", children: [ + /* @__PURE__ */ jsxRuntimeExports.jsx("h3", { className: "mb-3 text-xs font-semibold uppercase text-[var(--color-text-tertiary)]", children: "Signals from disk" }), + /* @__PURE__ */ jsxRuntimeExports.jsx( + SignalsFromDisk, + { + report, + loading: refreshing, + onRefresh: handleRefresh, + headerLabel: "Workspace report", + emptyLabel: "No workspace inspection has been captured yet for this project's paths." + } + ) + ] }); +} function Tag({ label, accent, warning }) { const color = warning ? "text-[var(--color-warning)] bg-[var(--color-warning)]/10" : accent ? "text-[var(--color-accent)] bg-[var(--color-accent-muted)]" : "text-[var(--color-text-secondary)] bg-[var(--color-bg-elevated)]"; return /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `text-xs px-2 py-0.5 rounded ${color} border border-[var(--color-border)]`, children: label }); @@ -20151,11 +20299,12 @@ function ProjectDetailView({ }; const openProjectFolder = async (selectedPath) => { if (!project) return; - if (!selectedPath && project.paths.length > 1) { + const paths = project.paths ?? []; + if (!selectedPath && paths.length > 1) { setPathChoicesOpen(true); return; } - const result = await api.openProjectPath(project.name, selectedPath ?? project.paths[0]); + const result = await api.openProjectPath(project.name, selectedPath ?? paths[0]); if (!result.ok) throw new Error(result.error ?? "Failed to open project folder"); setPathChoicesOpen(false); }; @@ -20232,7 +20381,7 @@ function ProjectDetailView({ void copyBriefCommand().catch((err) => flashNotice(err instanceof Error ? err.message : "Failed to copy brief command")); }, onRefreshHealth: refreshHealth, - hasFolder: project.paths.length > 0 + hasFolder: (project.paths?.length ?? 0) > 0 } ), notice && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "mb-4 text-sm text-[var(--color-success)] bg-[var(--color-bg-card)] rounded-md p-2", children: notice }), @@ -20274,7 +20423,7 @@ function ProjectDetailView({ ports.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsx(Count, { n: ports.length }) ] }) ] }), - /* @__PURE__ */ jsxRuntimeExports.jsx(Content$1, { value: "overview", className: "focus:outline-none", children: /* @__PURE__ */ jsxRuntimeExports.jsx(OverviewTab, { project, brief, onNavigate, healthRefreshNonce }) }), + /* @__PURE__ */ jsxRuntimeExports.jsx(Content$1, { value: "overview", className: "focus:outline-none", children: /* @__PURE__ */ jsxRuntimeExports.jsx(OverviewTab, { project, brief, onNavigate, healthRefreshNonce, onRefreshSignals: refresh }) }), /* @__PURE__ */ jsxRuntimeExports.jsx(Content$1, { value: "memory", className: "focus:outline-none", children: /* @__PURE__ */ jsxRuntimeExports.jsx(MemoryTab, { memories }) }), /* @__PURE__ */ jsxRuntimeExports.jsx(Content$1, { value: "capabilities", className: "focus:outline-none", children: /* @__PURE__ */ jsxRuntimeExports.jsx(CapabilitiesTab, { capabilities }) }), /* @__PURE__ */ jsxRuntimeExports.jsx(Content$1, { value: "ports", className: "focus:outline-none", children: /* @__PURE__ */ jsxRuntimeExports.jsx(PortsTab, { ports }) }) @@ -20309,7 +20458,7 @@ function ProjectDetailView({ children: [ /* @__PURE__ */ jsxRuntimeExports.jsx(Title, { className: "text-base font-semibold text-[var(--color-text-primary)] mb-1", children: "Choose folder" }), /* @__PURE__ */ jsxRuntimeExports.jsx(Description, { className: "text-xs text-[var(--color-text-tertiary)] mb-3", children: project.display_name }), - /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "space-y-2", children: project.paths.map((path) => /* @__PURE__ */ jsxRuntimeExports.jsx( + /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "space-y-2", children: (project.paths ?? []).map((path) => /* @__PURE__ */ jsxRuntimeExports.jsx( "button", { onClick: () => { @@ -21950,6 +22099,29 @@ function McpClientsSection({ onError, onSuccess }) { setWorking(null); } }; + const takeOver = async (selection) => { + setWorking({ operation: "take-over", selection }); + try { + const nextPlans = await api.planMcpClientInstall(selection, { takeOver: true }); + setInstallPlans((current) => mergeByClient(current, nextPlans)); + const nextResults = await api.applyMcpClientInstall(selection, { takeOver: true }); + setInstallResults((current) => mergeByClient(current, nextResults)); + const [nextDetections, refreshedInstallPlans, refreshedRemovePlans] = await Promise.all([ + api.detectMcpClients(selection), + api.planMcpClientInstall(selection), + api.planMcpClientRemove(selection) + ]); + setDetections((current) => mergeByClient(current, nextDetections)); + setInstallPlans((current) => mergeByClient(current, refreshedInstallPlans)); + setRemovePlans((current) => mergeByClient(current, refreshedRemovePlans)); + setRemoveResults((current) => clearBySelection(current, selection)); + reportApplyOutcome(nextResults, "install", onSuccess, onError); + } catch (e) { + onError(e instanceof Error ? e.message : "Failed to take over MCP client config"); + } finally { + setWorking(null); + } + }; const planRemove = async (selection) => { setWorking({ operation: "plan-remove", selection }); try { @@ -22064,6 +22236,7 @@ function McpClientsSection({ onError, onSuccess }) { working, onPlanInstall: () => void planInstall(id), onApplyInstall: () => void applyInstall(id), + onTakeOver: () => void takeOver(id), onPlanRemove: () => void planRemove(id), onApplyRemove: () => void applyRemove(id) }, @@ -22081,6 +22254,7 @@ function ClientRow({ working, onPlanInstall, onApplyInstall, + onTakeOver, onPlanRemove, onApplyRemove }) { @@ -22114,8 +22288,10 @@ function ClientRow({ busy, planning: isWorking(working, "plan-install", client2.id), applying: isWorking(working, "apply-install", client2.id), + takingOver: isWorking(working, "take-over", client2.id), onPlan: onPlanInstall, - onApply: onApplyInstall + onApply: onApplyInstall, + onTakeOver } ), /* @__PURE__ */ jsxRuntimeExports.jsx( @@ -22143,12 +22319,15 @@ function WorkflowPanel({ busy, planning, applying, + takingOver, onPlan, - onApply + onApply, + onTakeOver }) { const source = result ?? plan; const canApply = Boolean(plan?.workflowStatus.includes("would_write")); const title = operation === "install" ? "Install" : "Remove"; + const showTakeOver = operation === "install" && Boolean(onTakeOver) && plan?.status === "conflict-unmanaged-server"; return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "rounded border border-[var(--color-border)] bg-[var(--color-bg-card)] p-3 min-w-0", children: [ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex items-start justify-between gap-2", children: [ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "min-w-0", children: [ @@ -22168,6 +22347,16 @@ function WorkflowPanel({ children: planning ? "Planning..." : operation === "install" ? "Plan" : "Preview" } ), + showTakeOver && onTakeOver && /* @__PURE__ */ jsxRuntimeExports.jsx( + "button", + { + onClick: onTakeOver, + disabled: busy, + "data-testid": "mcp-client-take-over", + className: "px-3 py-1 rounded text-xs font-medium text-white\n bg-[var(--color-warning,#b45309)] hover:opacity-90\n disabled:opacity-50 transition-colors", + children: takingOver ? "Taking over..." : "Take over" + } + ), /* @__PURE__ */ jsxRuntimeExports.jsx( "button", { @@ -22264,10 +22453,12 @@ function formatPlanStatus(status, operation) { return "Setlist can add its managed MCP entry."; case "would-update-managed-server": return "Managed Setlist entry can be updated."; + case "would-take-over": + return "Setlist would take over the existing unmanaged setlist entry."; case "already-configured": return "Managed Setlist entry is already configured."; case "conflict-unmanaged-server": - return "Unmanaged setlist entry needs manual action."; + return "Unmanaged setlist entry — click Take over to overwrite (backup preserved) or remove the entry manually first."; case "not-installed": return "Client was not detected."; case "unreadable-config": @@ -22341,7 +22532,7 @@ function AppBehaviorSection({ onError, onSuccess }) { /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "flex items-start justify-between gap-4 py-2 border-b border-[var(--color-border)]", children: [ /* @__PURE__ */ jsxRuntimeExports.jsxs("span", { children: [ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "block text-sm font-medium text-[var(--color-text-primary)]", children: "Keep running in menu bar" }), - /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "block text-xs text-[var(--color-text-tertiary)] mt-0.5", children: "Close and minimize hide the control panel instead of quitting." }) + /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "block text-xs text-[var(--color-text-tertiary)] mt-0.5", children: "Close, minimize, and Cmd-Q keep Setlist available from the menu bar." }) ] }), /* @__PURE__ */ jsxRuntimeExports.jsx( "input", @@ -23110,7 +23301,7 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS } ) ] }), - inspection && /* @__PURE__ */ jsxRuntimeExports.jsx(InspectionPreview, { report: inspection }) + inspection && /* @__PURE__ */ jsxRuntimeExports.jsx(SignalsFromDisk, { report: inspection, headerLabel: "Local inspection preview" }) ] }) ] }), error && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "text-sm text-[var(--color-error)] bg-[var(--color-bg-card)] rounded-md p-2", children: error }), @@ -23143,61 +23334,6 @@ function Field({ label, children }) { children ] }); } -function InspectionPreview({ report }) { - const path = report.paths[0]; - const codeSignals = path?.code; - const folderSignals = path?.folder; - const documentCounts = folderSignals ? Object.entries(folderSignals.document_counts).map(([ext, count2]) => `${count2} ${ext}`).join(", ") : ""; - const gaps = path?.gaps ?? []; - return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "rounded-md border border-[var(--color-border)] bg-[var(--color-bg-card)] p-3 space-y-2", children: [ - /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex items-center justify-between gap-3", children: [ - /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "text-xs font-medium text-[var(--color-text-primary)]", children: "Local inspection preview" }), - /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[11px] uppercase tracking-wider text-[var(--color-text-tertiary)]", children: path?.status ?? report.summary.kind }) - ] }), - path?.error && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "text-xs text-[var(--color-error)]", children: path.error }), - codeSignals && /* @__PURE__ */ jsxRuntimeExports.jsx( - PreviewRow, - { - label: "Code signals", - value: [ - codeSignals.package_name, - codeSignals.likely_languages.slice(0, 3).join(", "), - codeSignals.framework_hints.slice(0, 3).join(", "), - codeSignals.test_commands.length > 0 ? `${codeSignals.test_commands.length} test command${codeSignals.test_commands.length === 1 ? "" : "s"}` : "" - ].filter(Boolean).join(" · ") || "No code manifests or language files found" - } - ), - folderSignals && /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [ - /* @__PURE__ */ jsxRuntimeExports.jsx( - PreviewRow, - { - label: "Folder signals", - value: [ - folderSignals.top_level_dirs.length > 0 ? `${folderSignals.top_level_dirs.length} top-level folder${folderSignals.top_level_dirs.length === 1 ? "" : "s"}` : "", - folderSignals.top_level_files.length > 0 ? `${folderSignals.top_level_files.length} top-level file${folderSignals.top_level_files.length === 1 ? "" : "s"}` : "", - documentCounts - ].filter(Boolean).join(" · ") || "No visible folder signals found" - } - ), - folderSignals.notable_files.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsx(PreviewRow, { label: "Notable files", value: folderSignals.notable_files.slice(0, 4).join(", ") }), - folderSignals.material_hints.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsx(PreviewRow, { label: "Workspace hints", value: folderSignals.material_hints.join(", ") }) - ] }), - gaps.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsx(PreviewRow, { label: "Gaps", value: gaps.join(", "), warning: true }), - /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "text-[11px] text-[var(--color-text-tertiary)]", children: [ - "Scanned shallow directory metadata only: ", - path?.entries_scanned ?? 0, - " visible entr", - (path?.entries_scanned ?? 0) === 1 ? "y" : "ies", - "." - ] }) - ] }); -} -function PreviewRow({ label, value, warning }) { - return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "grid grid-cols-[92px_1fr] gap-2 text-xs", children: [ - /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[var(--color-text-tertiary)]", children: label }), - /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: warning ? "text-[var(--color-warning)]" : "text-[var(--color-text-secondary)]", children: value }) - ] }); -} function UpdateToast() { const [visible, setVisible] = reactExports.useState(false); const [version, setVersion] = reactExports.useState(null); @@ -23370,6 +23506,32 @@ function ProjectActionPicker({ actionId, open, onOpenChange, onSelect }) { ) ] }) }); } +const RECENT_FALLBACK_LIMIT = 5; +function isActiveProject(project) { + return project.status !== "archived"; +} +function resolveVisiblePinnedProjects({ + pinnedProjectNames, + pinnedProjects, + projects +}) { + const projectByName = new Map(projects.map((project) => [project.name, project])); + const pinnedByName = new Map(pinnedProjects.map((project) => [project.name, project])); + return pinnedProjectNames.map((name) => pinnedByName.get(name) ?? projectByName.get(name)).filter((project) => project !== void 0 && isActiveProject(project)); +} +function resolveRecentFallbackProjects(projects, currentProject, limit = RECENT_FALLBACK_LIMIT) { + return [...projects].filter((project) => isActiveProject(project)).sort((a, b) => { + if (currentProject) { + if (a.name === currentProject) return -1; + if (b.name === currentProject) return 1; + } + return Date.parse(b.updated_at) - Date.parse(a.updated_at); + }).slice(0, limit); +} +function resolveNextPinnedProjectNames(latestNames, projectName, shouldPin) { + const withoutProject = latestNames.filter((name) => name !== projectName); + return shouldPin ? [...withoutProject, projectName] : withoutProject; +} function AppSidebar({ currentProject, filter, @@ -23382,21 +23544,31 @@ function AppSidebar({ onSettings }) { const [projects, setProjects] = reactExports.useState([]); + const [pinnedProjects, setPinnedProjects] = reactExports.useState([]); + const [pinnedProjectNames, setPinnedProjectNames] = reactExports.useState([]); const [areas, setAreas] = reactExports.useState([]); + const [pinningError, setPinningError] = reactExports.useState(null); reactExports.useEffect(() => { let cancelled = false; const load = () => { Promise.all([ api.listProjects({ depth: "standard" }), - api.listAreas() - ]).then(([projectRows, areaRows]) => { + api.listAreas(), + api.listPinnedProjects(), + api.getPinnedProjects() + ]).then(([projectRows, areaRows, pinnedRows, pinnedNames]) => { if (cancelled) return; setProjects(projectRows); setAreas(areaRows); + setPinnedProjects(pinnedRows); + setPinnedProjectNames(pinnedNames); + setPinningError(null); }).catch(() => { if (cancelled) return; setProjects([]); setAreas([]); + setPinnedProjects([]); + setPinnedProjectNames([]); }); }; load(); @@ -23409,6 +23581,20 @@ function AppSidebar({ window.removeEventListener("setlist:areas-changed", handleAreasChanged); }; }, [currentProject]); + const refreshPinnedProjects = reactExports.useCallback(() => { + Promise.all([ + api.listProjects({ depth: "standard" }), + api.listPinnedProjects(), + api.getPinnedProjects() + ]).then(([projectRows, pinnedRows, pinnedNames]) => { + setProjects(projectRows); + setPinnedProjects(pinnedRows); + setPinnedProjectNames(pinnedNames); + setPinningError(null); + }).catch(() => { + setPinningError("Pinned projects could not be refreshed."); + }); + }, []); const areaCounts = reactExports.useMemo(() => { const counts = /* @__PURE__ */ new Map(); for (const project of projects) { @@ -23430,12 +23616,35 @@ function AppSidebar({ onAreaFilterChange(null); } }, [areas, onAreaFilterChange, selectedArea]); - const pinned = reactExports.useMemo(() => { - const active = projects.filter((project) => project.status !== "archived").sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")); - const current = currentProject ? active.find((project) => project.name === currentProject) : void 0; - const rest = active.filter((project) => project.name !== currentProject).slice(0, current ? 3 : 4); - return current ? [current, ...rest] : rest; - }, [currentProject, projects]); + const projectByName = reactExports.useMemo(() => { + return new Map(projects.map((project) => [project.name, project])); + }, [projects]); + const pinnedNameSet = reactExports.useMemo(() => new Set(pinnedProjectNames), [pinnedProjectNames]); + const pinned = reactExports.useMemo(() => resolveVisiblePinnedProjects({ + pinnedProjectNames, + pinnedProjects, + projects + }), [pinnedProjectNames, pinnedProjects, projects]); + const recentFallback = reactExports.useMemo( + () => resolveRecentFallbackProjects(projects, currentProject), + [currentProject, projects] + ); + const quickAccessProjects = pinned.length > 0 ? pinned : recentFallback; + const quickAccessTitle = pinned.length > 0 ? "Pinned" : "Recent"; + const showingPinnedProjects = pinned.length > 0; + const currentProjectRow = currentProject ? projectByName.get(currentProject) ?? null : null; + const currentProjectPinned = currentProject ? pinnedNameSet.has(currentProject) : false; + const togglePinnedProject = reactExports.useCallback((projectName, shouldPin) => { + setPinningError(null); + api.getPinnedProjects().then((latestNames) => { + const nextNames = resolveNextPinnedProjectNames(latestNames, projectName, shouldPin); + return api.setPinnedProjects(nextNames); + }).then(() => { + refreshPinnedProjects(); + }).catch((error) => { + setPinningError(error instanceof Error ? error.message : "Pinned projects could not be updated."); + }); + }, [refreshPinnedProjects]); return /* @__PURE__ */ jsxRuntimeExports.jsxs("aside", { className: "w-64 shrink-0 border-r border-[var(--color-border)] bg-[var(--color-bg)] px-4 pb-4 pt-11 flex flex-col", children: [ /* @__PURE__ */ jsxRuntimeExports.jsxs( "button", @@ -23507,25 +23716,30 @@ function AppSidebar({ } ) ] }), - /* @__PURE__ */ jsxRuntimeExports.jsx(SidebarSection, { title: "Pinned", children: pinned.map((project) => /* @__PURE__ */ jsxRuntimeExports.jsxs( - "button", + currentProjectRow && !currentProjectPinned && /* @__PURE__ */ jsxRuntimeExports.jsx(SidebarSection, { title: "Current", children: /* @__PURE__ */ jsxRuntimeExports.jsx( + ProjectSidebarRow, { - type: "button", - onClick: () => onProjectClick(project.name), - className: `titlebar-no-drag flex h-7 w-full items-center gap-2 rounded px-1.5 text-left transition-colors ${project.name === currentProject ? "bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)]" : "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card-hover)] hover:text-[var(--color-text-primary)]"}`, - children: [ - /* @__PURE__ */ jsxRuntimeExports.jsx( - "span", - { - className: `h-2 w-2 shrink-0 rounded-full ${project.status === "paused" ? "bg-[var(--color-warning)]" : "bg-[var(--color-success)]"}`, - "aria-hidden": true - } - ), - /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "min-w-0 truncate text-[13px] leading-[16px]", children: project.display_name || project.name }) - ] - }, - project.name - )) }) + project: currentProjectRow, + active: true, + pinned: currentProjectPinned, + onClick: () => onProjectClick(currentProjectRow.name), + onTogglePinned: () => togglePinnedProject(currentProjectRow.name, !currentProjectPinned) + } + ) }), + /* @__PURE__ */ jsxRuntimeExports.jsxs(SidebarSection, { title: quickAccessTitle, children: [ + quickAccessProjects.length > 0 ? quickAccessProjects.map((project) => /* @__PURE__ */ jsxRuntimeExports.jsx( + ProjectSidebarRow, + { + project, + active: project.name === currentProject, + pinned: showingPinnedProjects, + onClick: () => onProjectClick(project.name), + onTogglePinned: () => togglePinnedProject(project.name, !showingPinnedProjects) + }, + project.name + )) : /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "px-1.5 py-1 text-[12px] leading-[15px] text-[var(--color-text-tertiary)]", children: "No recent projects" }), + pinningError && /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "px-1.5 pt-1 text-[11px] leading-[14px] text-[var(--color-error)]", children: pinningError }) + ] }) ] }), /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "mt-4 border-t border-[var(--color-border)] pt-2", children: /* @__PURE__ */ jsxRuntimeExports.jsxs( "button", @@ -23541,6 +23755,51 @@ function AppSidebar({ ) }) ] }); } +function ProjectSidebarRow({ + project, + active, + pinned, + onClick, + onTogglePinned +}) { + return /* @__PURE__ */ jsxRuntimeExports.jsxs( + "div", + { + className: `titlebar-no-drag group flex h-7 w-full items-center gap-1 rounded px-1.5 transition-colors ${active ? "bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)]" : "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card-hover)] hover:text-[var(--color-text-primary)]"}`, + children: [ + /* @__PURE__ */ jsxRuntimeExports.jsxs( + "button", + { + type: "button", + onClick, + className: "flex min-w-0 flex-1 items-center gap-2 text-left", + children: [ + /* @__PURE__ */ jsxRuntimeExports.jsx( + "span", + { + className: `h-2 w-2 shrink-0 rounded-full ${project.status === "paused" ? "bg-[var(--color-warning)]" : "bg-[var(--color-success)]"}`, + "aria-hidden": true + } + ), + /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "min-w-0 truncate text-[13px] leading-[16px]", children: project.display_name || project.name }) + ] + } + ), + /* @__PURE__ */ jsxRuntimeExports.jsx( + "button", + { + type: "button", + onClick: onTogglePinned, + "aria-label": pinned ? `Unpin ${project.display_name || project.name}` : `Pin ${project.display_name || project.name}`, + title: pinned ? "Unpin project" : "Pin project", + className: `h-6 shrink-0 rounded px-1.5 text-[11px] leading-[14px] transition-colors ${pinned ? "text-[var(--color-accent)] hover:bg-[var(--color-bg-card-hover)]" : "text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-card-hover)] hover:text-[var(--color-text-primary)]"}`, + children: pinned ? "Pinned" : "Pin" + } + ) + ] + } + ); +} function SetlistMark({ className }) { return /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `flex items-center justify-center rounded-md bg-[var(--color-accent)] ${className ?? ""}`, "aria-hidden": true, children: /* @__PURE__ */ jsxRuntimeExports.jsx("svg", { viewBox: "0 0 62.64 51.74", className: "h-[13px] w-[16px]", fill: "white", children: /* @__PURE__ */ jsxRuntimeExports.jsx("path", { d: "M56.83,7.7l.08,2.09c.19,5.04-2.73,8.03-7.69,8.21l-18.35.68c-1.15.04-2.5.67-2.46,1.82v.29c.05,1.15,1.16,1.9,2.24,1.93l18.9.6c7.8.5,12.77,4.43,13.07,12.41.39,10.51-6.24,14.35-16.75,14.74l-34.04,1.26c-4.97.18-10.19-2.65-10.37-7.69l-.07-1.8c-.19-5.04,2.74-7.81,7.7-7.99l19.43-.72c1.01-.04,1.7-.78,1.66-1.86v-.14c-.04-1.01-1.08-1.69-2.3-1.72l-15.22-.45C4.51,28.8.32,24.78.01,16.43-.4,5.28,8.11,1.43,19.26,1.02L46.46,0c5.32-.2,10.19,2.65,10.37,7.69h0Z" }) }) }); } @@ -23616,6 +23875,42 @@ function SidebarRow({ } ); } +class ErrorBoundary extends reactExports.Component { + state = { error: null }; + static getDerivedStateFromError(error) { + return { error }; + } + componentDidUpdate(prev) { + if (this.state.error && prev.resetKey !== this.props.resetKey) { + this.setState({ error: null }); + } + } + componentDidCatch(error, info) { + console.error("Renderer error boundary caught:", error, info.componentStack); + } + handleReset = () => { + this.setState({ error: null }); + this.props.onReset?.(); + }; + render() { + if (this.state.error) { + return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "py-10", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "mx-auto max-w-lg rounded-[10px] border border-[var(--color-border)] bg-[var(--color-bg-card)] p-5", children: [ + /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { className: "mb-1 text-sm font-semibold text-[var(--color-error)]", children: "Something went wrong rendering this view" }), + /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "mb-3 text-xs text-[var(--color-text-tertiary)]", children: "The rest of the app is still running. Go back and try again." }), + /* @__PURE__ */ jsxRuntimeExports.jsx("pre", { className: "mb-4 overflow-auto rounded-md bg-[var(--color-bg-elevated)] p-2 text-[11px] text-[var(--color-text-secondary)]", children: this.state.error.message }), + /* @__PURE__ */ jsxRuntimeExports.jsx( + "button", + { + onClick: this.handleReset, + className: "rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-accent-hover)]", + children: "Back to projects" + } + ) + ] }) }); + } + return this.props.children; + } +} function App() { const [view, setView] = reactExports.useState({ kind: "home" }); const [showRegister, setShowRegister] = reactExports.useState(false); @@ -23648,6 +23943,12 @@ function App() { }); return unsubscribe; }, []); + reactExports.useEffect(() => { + const unsubscribe = api.onNavigateToProject((projectName) => { + setView({ kind: "detail", projectName }); + }); + return unsubscribe; + }, []); reactExports.useEffect(() => { const unsubscribe = api.onProjectActionShortcut(({ actionId }) => { if (view.kind === "detail") { @@ -23711,35 +24012,42 @@ function App() { ), /* @__PURE__ */ jsxRuntimeExports.jsx("main", { className: "min-w-0 flex-1 overflow-auto bg-[var(--color-bg-main)]", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "min-h-full min-w-0 px-7 pb-7", children: [ actionError && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "mb-4 rounded-md bg-[var(--color-bg-card)] p-2 text-[13px] leading-[16px] text-[var(--color-error)]", children: actionError }), - view.kind === "home" ? /* @__PURE__ */ jsxRuntimeExports.jsx( - HomeView, - { - onProjectClick: navigateToProject, - onRegister: openRegister, - onSettings: () => setView({ kind: "settings" }), - filter, - onFilterChange: setFilter, - statusFilters, - onStatusFiltersChange: setStatusFilters$1, - selectedArea: areaFilter, - sort, - sortDir, - onSortChange: setSort, - onSortDirChange: setSortDir$1, - onRefreshRef: (fn) => { - refreshRef.current = fn; - } - } - ) : view.kind === "settings" ? /* @__PURE__ */ jsxRuntimeExports.jsx(SettingsView, { onBack: navigateHome }) : /* @__PURE__ */ jsxRuntimeExports.jsx( - ProjectDetailView, + /* @__PURE__ */ jsxRuntimeExports.jsx( + ErrorBoundary, { - projectName: view.projectName, - onBack: navigateHome, - onNavigate: navigateToProject, - actionRequest: pendingDetailAction, - onActionRequestHandled: (nonce) => { - setPendingDetailAction((current) => current?.nonce === nonce ? null : current); - } + resetKey: view.kind === "detail" ? `detail:${view.projectName}` : view.kind, + onReset: navigateHome, + children: view.kind === "home" ? /* @__PURE__ */ jsxRuntimeExports.jsx( + HomeView, + { + onProjectClick: navigateToProject, + onRegister: openRegister, + onSettings: () => setView({ kind: "settings" }), + filter, + onFilterChange: setFilter, + statusFilters, + onStatusFiltersChange: setStatusFilters$1, + selectedArea: areaFilter, + sort, + sortDir, + onSortChange: setSort, + onSortDirChange: setSortDir$1, + onRefreshRef: (fn) => { + refreshRef.current = fn; + } + } + ) : view.kind === "settings" ? /* @__PURE__ */ jsxRuntimeExports.jsx(SettingsView, { onBack: navigateHome }) : /* @__PURE__ */ jsxRuntimeExports.jsx( + ProjectDetailView, + { + projectName: view.projectName, + onBack: navigateHome, + onNavigate: navigateToProject, + actionRequest: pendingDetailAction, + onActionRequestHandled: (nonce) => { + setPendingDetailAction((current) => current?.nonce === nonce ? null : current); + } + } + ) } ) ] }) }) diff --git a/packages/app/out/renderer/assets/index-N6XCNrsE.css b/packages/app/out/renderer/assets/index-Dt_XFKQb.css similarity index 99% rename from packages/app/out/renderer/assets/index-N6XCNrsE.css rename to packages/app/out/renderer/assets/index-Dt_XFKQb.css index 59eb93e..8574808 100644 --- a/packages/app/out/renderer/assets/index-N6XCNrsE.css +++ b/packages/app/out/renderer/assets/index-Dt_XFKQb.css @@ -358,6 +358,10 @@ position: relative; } + .static { + position: static; + } + .inset-0 { inset: calc(var(--spacing) * 0); } @@ -604,6 +608,10 @@ height: calc(var(--spacing) * 5); } + .h-6 { + height: calc(var(--spacing) * 6); + } + .h-7 { height: calc(var(--spacing) * 7); } @@ -1204,6 +1212,10 @@ } } + .bg-\[var\(--color-warning\,\#b45309\)\] { + background-color: var(--color-warning, #b45309); + } + .bg-black\/40 { background-color: #0006; } diff --git a/packages/app/out/renderer/index.html b/packages/app/out/renderer/index.html index db30bd9..6c0dd1a 100644 --- a/packages/app/out/renderer/index.html +++ b/packages/app/out/renderer/index.html @@ -4,8 +4,8 @@ Setlist - - + +
diff --git a/packages/app/src/renderer/App.tsx b/packages/app/src/renderer/App.tsx index 569b9af..9e2d69f 100644 --- a/packages/app/src/renderer/App.tsx +++ b/packages/app/src/renderer/App.tsx @@ -6,6 +6,7 @@ import { RegisterProjectDialog } from './components/RegisterProjectDialog'; import { UpdateToast } from './components/UpdateToast'; import { ProjectActionPicker } from './components/ProjectActionPicker'; import { AppSidebar } from './components/AppSidebar'; +import { ErrorBoundary } from './components/ErrorBoundary'; import api from './lib/api'; import type { SortField, SortDir } from './hooks/useProjects'; import type { ProjectActionId } from '../shared/project-actions'; @@ -140,30 +141,35 @@ export function App() { )} - {view.kind === 'home' ? ( - setView({ kind: 'settings' })} - filter={filter} onFilterChange={setFilter} - statusFilters={statusFilters} onStatusFiltersChange={setStatusFilters} - selectedArea={areaFilter} - sort={sort} sortDir={sortDir} onSortChange={setSort} onSortDirChange={setSortDir} - onRefreshRef={(fn) => { refreshRef.current = fn; }} - /> - ) : view.kind === 'settings' ? ( - - ) : ( - { - setPendingDetailAction(current => current?.nonce === nonce ? null : current); - }} - /> - )} + + {view.kind === 'home' ? ( + setView({ kind: 'settings' })} + filter={filter} onFilterChange={setFilter} + statusFilters={statusFilters} onStatusFiltersChange={setStatusFilters} + selectedArea={areaFilter} + sort={sort} sortDir={sortDir} onSortChange={setSort} onSortDirChange={setSortDir} + onRefreshRef={(fn) => { refreshRef.current = fn; }} + /> + ) : view.kind === 'settings' ? ( + + ) : ( + { + setPendingDetailAction(current => current?.nonce === nonce ? null : current); + }} + /> + )} + diff --git a/packages/app/src/renderer/components/ErrorBoundary.tsx b/packages/app/src/renderer/components/ErrorBoundary.tsx new file mode 100644 index 0000000..4ce0511 --- /dev/null +++ b/packages/app/src/renderer/components/ErrorBoundary.tsx @@ -0,0 +1,73 @@ +import { Component } from 'react'; +import type { ErrorInfo, ReactNode } from 'react'; + +interface ErrorBoundaryProps { + children: ReactNode; + /** Bumping this value (e.g. the active view key) clears a caught error so + * navigating away from the offending view recovers without a full reload. */ + resetKey?: string | number; + /** Optional escape hatch shown alongside the reset button. */ + onReset?: () => void; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * Catches render-time throws so a single bad view can't blank the entire app. + * Without this, an unguarded property access in any view (the failure mode that + * blanked the detail view for path-less projects) unmounts the whole React tree. + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidUpdate(prev: ErrorBoundaryProps) { + // When the caller navigates (resetKey changes), drop the caught error so + // the new view renders instead of the error card. + if (this.state.error && prev.resetKey !== this.props.resetKey) { + this.setState({ error: null }); + } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // Surface to the renderer console / DevTools for diagnosis. + console.error('Renderer error boundary caught:', error, info.componentStack); + } + + private handleReset = () => { + this.setState({ error: null }); + this.props.onReset?.(); + }; + + render() { + if (this.state.error) { + return ( +
+
+

+ Something went wrong rendering this view +

+

+ The rest of the app is still running. Go back and try again. +

+
+              {this.state.error.message}
+            
+ +
+
+ ); + } + return this.props.children; + } +} diff --git a/packages/app/src/renderer/lib/api.ts b/packages/app/src/renderer/lib/api.ts index 9a497f3..06f182c 100644 --- a/packages/app/src/renderer/lib/api.ts +++ b/packages/app/src/renderer/lib/api.ts @@ -312,7 +312,10 @@ type OptionalPinnedProjectsBridge = Omit; capabilities: Capability[]; ports: PortClaim[]; diff --git a/packages/app/src/renderer/views/ProjectDetailView.tsx b/packages/app/src/renderer/views/ProjectDetailView.tsx index 483bbc4..5b24359 100644 --- a/packages/app/src/renderer/views/ProjectDetailView.tsx +++ b/packages/app/src/renderer/views/ProjectDetailView.tsx @@ -44,11 +44,12 @@ export function ProjectDetailView({ const openProjectFolder = async (selectedPath?: string) => { if (!project) return; - if (!selectedPath && project.paths.length > 1) { + const paths = project.paths ?? []; + if (!selectedPath && paths.length > 1) { setPathChoicesOpen(true); return; } - const result = await api.openProjectPath(project.name, selectedPath ?? project.paths[0]); + const result = await api.openProjectPath(project.name, selectedPath ?? paths[0]); if (!result.ok) throw new Error(result.error ?? 'Failed to open project folder'); setPathChoicesOpen(false); }; @@ -135,7 +136,7 @@ export function ProjectDetailView({ void copyBriefCommand().catch(err => flashNotice(err instanceof Error ? err.message : 'Failed to copy brief command')); }} onRefreshHealth={refreshHealth} - hasFolder={project.paths.length > 0} + hasFolder={(project.paths?.length ?? 0) > 0} /> {notice && ( @@ -225,7 +226,7 @@ export function ProjectDetailView({ {project.display_name}
- {project.paths.map(path => ( + {(project.paths ?? []).map(path => (