feat: add notification when task paused

This commit is contained in:
d-kimsuon
2025-09-07 17:43:18 +09:00
parent ca31602933
commit 8b6b03b61d
6 changed files with 300 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import Link from "next/link";
import type { FC } from "react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { useTaskNotifications } from "@/hooks/useTaskNotifications";
import { Badge } from "../../../../../../components/ui/badge";
import { honoClient } from "../../../../../../lib/api/client";
import { useProject } from "../../../hooks/useProject";
@@ -50,6 +51,9 @@ export const SessionPageContent: FC<{
const { isRunningTask, isPausedTask } = useAliveTask(sessionId);
// Set up task completion notifications
useTaskNotifications(isRunningTask);
const [previousConversationLength, setPreviousConversationLength] =
useState(0);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);

View File

@@ -1,6 +1,7 @@
"use client";
import type { FC } from "react";
import { NotificationSettings } from "@/components/NotificationSettings";
import { SettingsControls } from "@/components/SettingsControls";
export const SettingsTab: FC<{
@@ -24,6 +25,15 @@ export const SettingsTab: FC<{
<SettingsControls openingProjectId={openingProjectId} />
</div>
{/* Notification Settings */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Notifications
</h3>
<NotificationSettings />
</div>
</div>
</div>
);

View File

@@ -0,0 +1,104 @@
"use client";
import { useAtom } from "jotai";
import { type FC, useCallback, useId } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
type NotificationSoundType,
notificationSettingsAtom,
} from "@/lib/atoms/notifications";
import {
getAvailableSoundTypes,
getSoundDisplayName,
playNotificationSound,
} from "@/lib/notifications";
interface NotificationSettingsProps {
showLabels?: boolean;
showDescriptions?: boolean;
className?: string;
}
export const NotificationSettings: FC<NotificationSettingsProps> = ({
showLabels = true,
showDescriptions = true,
className = "",
}: NotificationSettingsProps) => {
const selectId = useId();
const [settings, setSettings] = useAtom(notificationSettingsAtom);
const handleSoundTypeChange = useCallback(
(value: NotificationSoundType) => {
setSettings((prev) => ({
...prev,
soundType: value,
}));
},
[setSettings],
);
const handleTestSound = useCallback(() => {
if (settings.soundType !== "none") {
playNotificationSound(settings.soundType);
}
}, [settings.soundType]);
const availableSoundTypes = getAvailableSoundTypes();
return (
<div className={`space-y-4 ${className}`}>
<div className="space-y-2">
{showLabels && (
<label
htmlFor={selectId}
className="text-sm font-medium leading-none"
>
Task completion sound
</label>
)}
<div className="flex items-center gap-2">
<Select
value={settings.soundType}
onValueChange={handleSoundTypeChange}
>
<SelectTrigger id={selectId} className="w-[180px]">
<SelectValue placeholder="音を選択" />
</SelectTrigger>
<SelectContent>
{availableSoundTypes.map((soundType) => (
<SelectItem key={soundType} value={soundType}>
{getSoundDisplayName(soundType)}
</SelectItem>
))}
</SelectContent>
</Select>
{settings.soundType !== "none" && (
<Button
variant="outline"
size="sm"
onClick={handleTestSound}
className="px-3"
>
</Button>
)}
</div>
{showDescriptions && (
<p className="text-xs text-muted-foreground">
Claude Code
</p>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,34 @@
import { useAtomValue } from "jotai";
import { useEffect, useRef } from "react";
import {
notificationSettingsAtom,
soundNotificationsEnabledAtom,
} from "@/lib/atoms/notifications";
import { playNotificationSound } from "@/lib/notifications";
/**
* Hook to handle task completion sound notifications
* Monitors task state changes and triggers sound when tasks complete
*/
export const useTaskNotifications = (isRunningTask: boolean) => {
const settings = useAtomValue(notificationSettingsAtom);
const soundEnabled = useAtomValue(soundNotificationsEnabledAtom);
// Track previous running state to detect completion
const prevIsRunningRef = useRef<boolean>(isRunningTask);
// Monitor task state changes
useEffect(() => {
const prevIsRunning = prevIsRunningRef.current;
const currentIsRunning = isRunningTask;
// Update the ref for next comparison
prevIsRunningRef.current = currentIsRunning;
// Detect task completion: was running, now not running
if (prevIsRunning && !currentIsRunning && soundEnabled) {
// Play notification sound
playNotificationSound(settings.soundType);
}
}, [isRunningTask, soundEnabled, settings.soundType]);
};

View File

@@ -0,0 +1,34 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
/**
* Available sound types for notifications
*/
export type NotificationSoundType = "none" | "beep" | "chime" | "ping" | "pop";
/**
* Notification settings stored in localStorage
*/
export interface NotificationSettings {
soundType: NotificationSoundType;
}
const defaultSettings: NotificationSettings = {
soundType: "none",
};
/**
* Atom for notification settings with localStorage persistence
*/
export const notificationSettingsAtom = atomWithStorage<NotificationSettings>(
"claude-code-viewer-notification-settings",
defaultSettings,
);
/**
* Derived atom to check if sound notifications are enabled
*/
export const soundNotificationsEnabledAtom = atom((get) => {
const settings = get(notificationSettingsAtom);
return settings.soundType !== "none";
});

114
src/lib/notifications.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* Audio notification utilities for task completion alerts
*/
import type { NotificationSoundType } from "./atoms/notifications";
/**
* Sound configuration for different notification types
*/
const soundConfigs: Record<
Exclude<NotificationSoundType, "none">,
{
frequency: number[];
duration: number;
type: OscillatorType;
volume: number;
}
> = {
beep: {
frequency: [800],
duration: 0.15,
type: "sine",
volume: 0.3,
},
chime: {
frequency: [523, 659, 784], // C, E, G notes
duration: 0.4,
type: "sine",
volume: 0.2,
},
ping: {
frequency: [1000],
duration: 0.1,
type: "triangle",
volume: 0.4,
},
pop: {
frequency: [400, 600],
duration: 0.08,
type: "square",
volume: 0.2,
},
};
/**
* Play a notification sound based on the sound type
*/
export function playNotificationSound(soundType: NotificationSoundType) {
if (soundType === "none") {
return;
}
try {
const config = soundConfigs[soundType];
if (!config) {
console.warn(`Unknown sound type: ${soundType}`);
return;
}
const audioContext = new (
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext })
.webkitAudioContext
)();
// Play multiple frequencies if specified (for chords/sequences)
config.frequency.forEach((freq, index) => {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.setValueAtTime(freq, audioContext.currentTime);
oscillator.type = config.type;
// Set volume and fade out
const startTime = audioContext.currentTime + index * 0.05; // Slight delay for sequences
gainNode.gain.setValueAtTime(config.volume, startTime);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
startTime + config.duration,
);
// Play the sound
oscillator.start(startTime);
oscillator.stop(startTime + config.duration);
});
} catch (error) {
console.warn("Failed to play notification sound:", error);
}
}
/**
* Get display name for sound types
*/
export function getSoundDisplayName(soundType: NotificationSoundType): string {
const displayNames: Record<NotificationSoundType, string> = {
none: "なし",
beep: "ビープ",
chime: "チャイム",
ping: "ピン",
pop: "ポップ",
};
return displayNames[soundType];
}
/**
* Get all available sound types
*/
export function getAvailableSoundTypes(): NotificationSoundType[] {
return ["none", "beep", "chime", "ping", "pop"];
}