Unverified 提交 df4b87eb authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Adding a tip banner for chat notification (#2901)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2901" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
上级 33393f6f
import * as path from "path";
import * as fs from "fs";
import * as os from "os";
import { expect } from "@playwright/test";
import { test, testWithConfig } from "./helpers/test_helper";
const testWithNotificationsEnabled = testWithConfig({
preLaunchHook: async ({ userDataDir }) => {
fs.mkdirSync(userDataDir, { recursive: true });
fs.writeFileSync(
path.join(userDataDir, "user-settings.json"),
JSON.stringify({ enableChatCompletionNotifications: true }, null, 2),
);
},
});
test("notification banner - skip hides permanently", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
// Banner should be visible since notifications are not enabled
const banner = po.page.getByTestId("notification-tip-banner");
await expect(banner).toBeVisible();
await expect(banner).toContainText(
"Get notified when chat responses finish.",
);
// Record settings before skipping
const beforeSettings = po.settings.recordSettings();
// Click dismiss (X) button
await banner.getByRole("button", { name: "Dismiss" }).click();
// Banner should be hidden
await expect(banner).not.toBeVisible();
// Verify settings were updated with skipNotificationBanner: true
po.settings.snapshotSettingsDelta(beforeSettings);
// Navigate away and back to verify banner stays hidden
await po.navigation.goToSettingsTab();
await po.navigation.goToChatTab();
await expect(banner).not.toBeVisible();
});
test("notification banner - Enable enables notifications and hides banner", async ({
po,
}) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
const banner = po.page.getByTestId("notification-tip-banner");
await expect(banner).toBeVisible();
// Record settings before enabling
const beforeSettings = po.settings.recordSettings();
// Click the Enable button
await banner.getByRole("button", { name: "Enable" }).click();
// On macOS, a notification guide dialog appears — dismiss it
if (os.platform() === "darwin") {
const guideDialog = po.page.getByRole("dialog");
await expect(guideDialog).toBeVisible();
await guideDialog.getByRole("button", { name: "Got it" }).click();
await expect(guideDialog).not.toBeVisible();
}
// Banner should be hidden after enabling
await expect(banner).not.toBeVisible();
// Verify settings were updated with enableChatCompletionNotifications: true
po.settings.snapshotSettingsDelta(beforeSettings);
// Navigate away and back to verify banner stays hidden
await po.navigation.goToSettingsTab();
await po.navigation.goToChatTab();
await expect(banner).not.toBeVisible();
});
testWithNotificationsEnabled(
"notification banner - not shown when notifications already enabled",
async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
// Banner should NOT be visible since notifications are already enabled
await expect(
po.page.getByTestId("notification-tip-banner"),
).not.toBeVisible();
},
);
import { useSettings } from "@/hooks/useSettings";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { MacNotificationGuideDialog } from "./MacNotificationGuideDialog";
import { useEnableNotifications } from "@/hooks/useEnableNotifications";
export function ChatCompletionNotificationSwitch() { export function ChatCompletionNotificationSwitch() {
const { settings, updateSettings } = useSettings(); const { isEnabled, enable, disable, showMacGuide, setShowMacGuide } =
const isEnabled = settings?.enableChatCompletionNotifications === true; useEnableNotifications();
return ( return (
<div className="flex items-center space-x-2"> <>
<Switch <div className="flex items-center space-x-2">
id="chat-completion-notifications" <Switch
checked={isEnabled} id="chat-completion-notifications"
onCheckedChange={async (checked) => { checked={isEnabled}
if (checked) { onCheckedChange={async (checked) => {
if (Notification.permission === "denied") { if (checked) {
return; await enable();
} else {
disable();
} }
if (Notification.permission === "default") { }}
const permission = await Notification.requestPermission(); />
if (permission !== "granted") { <Label htmlFor="chat-completion-notifications">
return; Show notification when chat completes
} </Label>
} </div>
} <MacNotificationGuideDialog
updateSettings({ open={showMacGuide}
enableChatCompletionNotifications: checked, onClose={() => setShowMacGuide(false)}
});
}}
/> />
<Label htmlFor="chat-completion-notifications"> </>
Show notification when chat completes
</Label>
</div>
); );
} }
...@@ -14,6 +14,7 @@ import { ChatInput } from "./chat/ChatInput"; ...@@ -14,6 +14,7 @@ import { ChatInput } from "./chat/ChatInput";
import { VersionPane } from "./chat/VersionPane"; import { VersionPane } from "./chat/VersionPane";
import { ChatError } from "./chat/ChatError"; import { ChatError } from "./chat/ChatError";
import { FreeAgentQuotaBanner } from "./chat/FreeAgentQuotaBanner"; import { FreeAgentQuotaBanner } from "./chat/FreeAgentQuotaBanner";
import { NotificationBanner } from "./chat/NotificationBanner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
...@@ -226,6 +227,7 @@ export function ChatPanel({ ...@@ -226,6 +227,7 @@ export function ChatPanel({
} }
/> />
)} )}
<NotificationBanner />
<ChatInput chatId={chatId} /> <ChatInput chatId={chatId} />
</div> </div>
)} )}
......
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface MacNotificationGuideDialogProps {
open: boolean;
onClose: () => void;
}
export function MacNotificationGuideDialog({
open,
onClose,
}: MacNotificationGuideDialogProps) {
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Allow Notifications on macOS</DialogTitle>
<DialogDescription>
If you didn't receive a test notification, you may need to allow
notifications for Dyad in macOS. Here are two ways to do it:
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="rounded-lg border p-3 space-y-1">
<h4 className="text-sm font-medium">
Option 1: From the Notification Permission Prompt
</h4>
<p className="text-sm text-muted-foreground">
Click the <strong>"Options"</strong> dropdown on the notification
and select <strong>"Allow"</strong>.
</p>
</div>
<div className="rounded-lg border p-3 space-y-1">
<h4 className="text-sm font-medium">Option 2: System Settings</h4>
<p className="text-sm text-muted-foreground">
Open{" "}
<strong>
System Settings → Notifications → Application Notifications →
Dyad
</strong>{" "}
and enable <strong>"Allow Notifications"</strong>.
</p>
</div>
</div>
<DialogFooter>
<Button onClick={onClose}>Got it</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
import { Bell } from "lucide-react";
import { SkippableBanner } from "./SkippableBanner";
import { MacNotificationGuideDialog } from "../MacNotificationGuideDialog";
import { useEnableNotifications } from "@/hooks/useEnableNotifications";
import { useSettings } from "@/hooks/useSettings";
export function NotificationBanner() {
const { settings, updateSettings } = useSettings();
const { enable, showMacGuide, setShowMacGuide } = useEnableNotifications();
const showBanner =
settings &&
settings.enableChatCompletionNotifications !== true &&
settings.skipNotificationBanner !== true;
const handleSkip = () => {
updateSettings({ skipNotificationBanner: true });
};
return (
<>
{showBanner && (
<SkippableBanner
icon={Bell}
message="Get notified when chat responses finish."
enableLabel="Enable"
onEnable={enable}
onSkip={handleSkip}
data-testid="notification-tip-banner"
/>
)}
<MacNotificationGuideDialog
open={showMacGuide}
onClose={() => setShowMacGuide(false)}
/>
</>
);
}
import { X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
interface SkippableBannerProps {
icon: LucideIcon;
message: React.ReactNode;
enableLabel: string;
onEnable: () => void;
onSkip: () => void;
"data-testid"?: string;
}
const colors = {
container: "bg-indigo-50/60 dark:bg-indigo-900/30",
ring: "ring-black/5 dark:ring-white/10",
icon: "text-indigo-600 dark:text-indigo-200 bg-indigo-100 dark:bg-white/15",
text: "text-indigo-900 dark:text-indigo-100",
enableBtn: "bg-white/90 hover:bg-white text-indigo-800 shadow font-semibold",
skipBtn:
"text-indigo-600 dark:text-indigo-200 hover:text-indigo-800 dark:hover:text-indigo-100",
};
export function SkippableBanner({
icon: Icon,
message,
enableLabel,
onEnable,
onSkip,
"data-testid": testId,
}: SkippableBannerProps) {
const c = colors;
return (
<div className="px-3 pt-1 flex justify-center" data-testid={testId}>
<div
className={`max-w-3xl w-full mb-2 rounded-lg ${c.container} ring-1 ring-inset ${c.ring} relative`}
>
<button
type="button"
onClick={onSkip}
className={`absolute -top-2 -right-2 inline-flex items-center justify-center rounded-full p-1 transition-colors duration-150 ${c.skipBtn} cursor-pointer bg-white dark:bg-indigo-800 ring-1 ring-inset ${c.ring} shadow-sm`}
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
<div className="flex items-center gap-3 px-3 py-2 pr-8">
{/* Icon badge */}
<div className={`shrink-0 rounded-lg p-2 ${c.icon}`}>
<Icon className="h-5 w-5" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium leading-snug ${c.text}`}>
{message}
</p>
</div>
{/* Action */}
<button
type="button"
onClick={onEnable}
className={`inline-flex items-center shrink-0 rounded-lg px-4 py-1.5 text-sm font-semibold transition-all duration-150 ${c.enableBtn} cursor-pointer`}
>
{enableLabel}
</button>
</div>
</div>
</div>
);
}
import { useState, useCallback } from "react";
import { useSettings } from "@/hooks/useSettings";
import { detectIsMac } from "@/hooks/useChatModeToggle";
function sendTestNotification() {
if (Notification.permission === "granted") {
new Notification("Dyad", {
body: "Notifications are working! You'll be notified when chat responses complete.",
});
}
}
export function useEnableNotifications() {
const { settings, updateSettings } = useSettings();
const [showMacGuide, setShowMacGuide] = useState(false);
const isEnabled = settings?.enableChatCompletionNotifications === true;
const isMac = detectIsMac();
const openMacGuide = useCallback(() => {
if (isMac) {
setShowMacGuide(true);
}
}, [isMac]);
const enable = useCallback(async () => {
if (Notification.permission === "denied") {
openMacGuide();
return;
}
if (Notification.permission === "default") {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
openMacGuide();
return;
}
}
await updateSettings({ enableChatCompletionNotifications: true });
sendTestNotification();
openMacGuide();
}, [updateSettings, openMacGuide]);
const disable = useCallback(async () => {
await updateSettings({ enableChatCompletionNotifications: false });
}, [updateSettings]);
return { isEnabled, enable, disable, showMacGuide, setShowMacGuide };
}
...@@ -351,6 +351,7 @@ const BaseUserSettingsFields = { ...@@ -351,6 +351,7 @@ const BaseUserSettingsFields = {
.optional(), .optional(),
hideLocalAgentNewChatToast: z.boolean().optional(), hideLocalAgentNewChatToast: z.boolean().optional(),
enableContextCompaction: z.boolean().optional(), enableContextCompaction: z.boolean().optional(),
skipNotificationBanner: z.boolean().optional(),
}; };
/** /**
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论