fix: include project name in desktop notifications (#3072)
Desktop notifications now display "GSD — projectName" instead of just "GSD", making it clear which project a notification belongs to when multiple projects are active. Closes #2708 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5f660bf3ce
commit
501fb83606
5 changed files with 75 additions and 8 deletions
|
|
@ -93,6 +93,7 @@ export interface LoopDeps {
|
|||
body: string,
|
||||
kind: string,
|
||||
category: string,
|
||||
projectName?: string,
|
||||
) => void;
|
||||
setActiveMilestoneId: (basePath: string, mid: string) => void;
|
||||
pruneQueueOrder: (basePath: string, pendingIds: string[]) => void;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { runUnit } from "./run-unit.js";
|
|||
import { debugLog } from "../debug-logger.js";
|
||||
import { PROJECT_FILES } from "../detection.js";
|
||||
import { MergeConflictError } from "../git-service.js";
|
||||
import { join } from "node:path";
|
||||
import { join, basename } from "node:path";
|
||||
import { existsSync, cpSync } from "node:fs";
|
||||
import { logWarning, logError } from "../workflow-logger.js";
|
||||
import { gsdRoot } from "../paths.js";
|
||||
|
|
@ -230,6 +230,7 @@ export async function runPreDispatch(
|
|||
`Milestone ${s.currentMilestoneId} complete!`,
|
||||
"success",
|
||||
"milestone",
|
||||
basename(s.originalBasePath || s.basePath),
|
||||
);
|
||||
deps.logCmuxEvent(
|
||||
prefs,
|
||||
|
|
@ -388,6 +389,7 @@ export async function runPreDispatch(
|
|||
"All milestones complete!",
|
||||
"success",
|
||||
"milestone",
|
||||
basename(s.originalBasePath || s.basePath),
|
||||
);
|
||||
deps.logCmuxEvent(
|
||||
prefs,
|
||||
|
|
@ -411,7 +413,7 @@ export async function runPreDispatch(
|
|||
const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
|
||||
await deps.stopAuto(ctx, pi, blockerMsg);
|
||||
ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
|
||||
deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
|
||||
deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention", basename(s.originalBasePath || s.basePath));
|
||||
deps.logCmuxEvent(prefs, blockerMsg, "error");
|
||||
} else {
|
||||
const ids = incomplete.map((m: { id: string }) => m.id).join(", ");
|
||||
|
|
@ -492,6 +494,7 @@ export async function runPreDispatch(
|
|||
`Milestone ${mid} complete!`,
|
||||
"success",
|
||||
"milestone",
|
||||
basename(s.originalBasePath || s.basePath),
|
||||
);
|
||||
deps.logCmuxEvent(
|
||||
prefs,
|
||||
|
|
@ -509,7 +512,7 @@ export async function runPreDispatch(
|
|||
const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
|
||||
await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
|
||||
ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
|
||||
deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
|
||||
deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention", basename(s.originalBasePath || s.basePath));
|
||||
deps.logCmuxEvent(prefs, blockerMsg, "error");
|
||||
debugLog("autoLoop", { phase: "exit", reason: "blocked" });
|
||||
deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "terminal", data: { reason: "blocked", blockers: state.blockers } });
|
||||
|
|
@ -755,7 +758,7 @@ export async function runGuards(
|
|||
// 100% — special enforcement logic (halt/pause/warn)
|
||||
const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
|
||||
if (budgetEnforcementAction === "halt") {
|
||||
deps.sendDesktopNotification("GSD", msg, "error", "budget");
|
||||
deps.sendDesktopNotification("GSD", msg, "error", "budget", basename(s.originalBasePath || s.basePath));
|
||||
await deps.stopAuto(ctx, pi, "Budget ceiling reached");
|
||||
debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
|
||||
return { action: "break", reason: "budget-halt" };
|
||||
|
|
@ -765,14 +768,14 @@ export async function runGuards(
|
|||
`${msg} Pausing auto-mode — /gsd auto to override and continue.`,
|
||||
"warning",
|
||||
);
|
||||
deps.sendDesktopNotification("GSD", msg, "warning", "budget");
|
||||
deps.sendDesktopNotification("GSD", msg, "warning", "budget", basename(s.originalBasePath || s.basePath));
|
||||
deps.logCmuxEvent(prefs, msg, "warning");
|
||||
await deps.pauseAuto(ctx, pi);
|
||||
debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
|
||||
return { action: "break", reason: "budget-pause" };
|
||||
}
|
||||
ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
|
||||
deps.sendDesktopNotification("GSD", msg, "warning", "budget");
|
||||
deps.sendDesktopNotification("GSD", msg, "warning", "budget", basename(s.originalBasePath || s.basePath));
|
||||
deps.logCmuxEvent(prefs, msg, "warning");
|
||||
} else if (threshold.pct < 100) {
|
||||
// Sub-100% — simple notification
|
||||
|
|
@ -783,6 +786,7 @@ export async function runGuards(
|
|||
msg,
|
||||
threshold.notifyLevel,
|
||||
"budget",
|
||||
basename(s.originalBasePath || s.basePath),
|
||||
);
|
||||
deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
|
||||
}
|
||||
|
|
@ -812,6 +816,7 @@ export async function runGuards(
|
|||
`Context ${contextUsage.percent}% — paused`,
|
||||
"warning",
|
||||
"attention",
|
||||
basename(s.originalBasePath || s.basePath),
|
||||
);
|
||||
await deps.pauseAuto(ctx, pi);
|
||||
debugLog("autoLoop", { phase: "exit", reason: "context-window" });
|
||||
|
|
|
|||
|
|
@ -23,7 +23,13 @@ export function sendDesktopNotification(
|
|||
message: string,
|
||||
level: NotifyLevel = "info",
|
||||
kind: NotificationKind = "complete",
|
||||
projectName?: string,
|
||||
): void {
|
||||
// When a projectName is provided and the title is the default "GSD",
|
||||
// replace it with a project-qualified title for multi-project clarity.
|
||||
if (projectName && title === "GSD") {
|
||||
title = formatNotificationTitle(projectName);
|
||||
}
|
||||
const loaded = loadEffectiveGSDPreferences()?.preferences;
|
||||
if (!shouldSendDesktopNotification(kind, loaded?.notifications)) return;
|
||||
|
||||
|
|
@ -64,6 +70,16 @@ export function shouldSendDesktopNotification(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a notification title that includes the project name for context.
|
||||
* Returns "GSD — projectName" when a project name is available, otherwise "GSD".
|
||||
*/
|
||||
export function formatNotificationTitle(projectName?: string): string {
|
||||
const trimmed = projectName?.trim();
|
||||
if (trimmed) return `GSD — ${trimmed}`;
|
||||
return "GSD";
|
||||
}
|
||||
|
||||
export function buildDesktopNotificationCommand(
|
||||
platform: NodeJS.Platform,
|
||||
title: string,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
|||
import {
|
||||
buildDesktopNotificationCommand,
|
||||
shouldSendDesktopNotification,
|
||||
formatNotificationTitle,
|
||||
} from "../notifications.js";
|
||||
import type { NotificationPreferences } from "../types.js";
|
||||
|
||||
|
|
@ -87,3 +88,47 @@ test("buildDesktopNotificationCommand preserves literal shell characters on linu
|
|||
test("buildDesktopNotificationCommand skips unsupported platforms", () => {
|
||||
assert.equal(buildDesktopNotificationCommand("win32", "Title", "Message"), null);
|
||||
});
|
||||
|
||||
// ─── formatNotificationTitle — project context in notifications (#2708) ──────
|
||||
|
||||
test("formatNotificationTitle returns 'GSD' when no project name is given", () => {
|
||||
assert.equal(formatNotificationTitle(), "GSD");
|
||||
assert.equal(formatNotificationTitle(undefined), "GSD");
|
||||
assert.equal(formatNotificationTitle(""), "GSD");
|
||||
});
|
||||
|
||||
test("formatNotificationTitle includes project name when provided", () => {
|
||||
assert.equal(formatNotificationTitle("my-app"), "GSD — my-app");
|
||||
});
|
||||
|
||||
test("formatNotificationTitle trims whitespace from project name", () => {
|
||||
assert.equal(formatNotificationTitle(" spaced "), "GSD — spaced");
|
||||
});
|
||||
|
||||
test("buildDesktopNotificationCommand includes project name in title on linux", () => {
|
||||
const command = buildDesktopNotificationCommand(
|
||||
"linux",
|
||||
formatNotificationTitle("my-project"),
|
||||
"All milestones complete!",
|
||||
"success",
|
||||
);
|
||||
assert.ok(command);
|
||||
assert.equal(command.args[2], "GSD — my-project");
|
||||
assert.equal(command.args[3], "All milestones complete!");
|
||||
});
|
||||
|
||||
test("buildDesktopNotificationCommand includes project name in title on macOS", () => {
|
||||
const command = buildDesktopNotificationCommand(
|
||||
"darwin",
|
||||
formatNotificationTitle("my-project"),
|
||||
"Budget 90%",
|
||||
"warning",
|
||||
);
|
||||
assert.ok(command);
|
||||
if (command.file.includes("terminal-notifier")) {
|
||||
const titleIdx = command.args.indexOf("-title");
|
||||
assert.equal(command.args[titleIdx + 1], "GSD — my-project");
|
||||
} else {
|
||||
assert.match(command.args[1], /GSD — my-project/);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import type { ExtensionCommandContext, ExtensionAPI } from "@gsd/pi-coding-agent";
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { join, basename } from "node:path";
|
||||
import { nativeRevertCommit, nativeRevertAbort } from "./native-git-bridge.js";
|
||||
import { parseUnitId } from "./unit-id.js";
|
||||
import { deriveState } from "./state.js";
|
||||
|
|
@ -133,7 +133,7 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
|
|||
}
|
||||
|
||||
ctx.ui.notify(results.join("\n"), "success");
|
||||
sendDesktopNotification("GSD", `Undone: ${unitType} (${unitId})`, "info", "complete");
|
||||
sendDesktopNotification("GSD", `Undone: ${unitType} (${unitId})`, "info", "complete", basename(basePath));
|
||||
}
|
||||
|
||||
// ─── Targeted State Reset ────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue