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:
Tom Boucher 2026-03-30 16:40:58 -04:00 committed by GitHub
parent 5f660bf3ce
commit 501fb83606
5 changed files with 75 additions and 8 deletions

View file

@ -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;

View file

@ -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" });

View file

@ -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,

View file

@ -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/);
}
});

View file

@ -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 ────────────────────────────────────────────────────