feat: enhance notifications and task management features
- Added a section for task completion notifications in the settings, allowing users to receive native OS notifications when tasks are completed. - Implemented a button to install the `claude-notifications-go` plugin for enhanced notification capabilities. - Updated the `TeamDetailView` to include a "Mark all as read" feature, improving message management. - Refactored task creation dialog to support an optional immediate start parameter, enhancing task scheduling flexibility. - Improved Kanban board functionality by allowing task addition with pre-set start conditions based on the column context.
This commit is contained in:
parent
63c7204c1a
commit
3a05f113bc
9 changed files with 150 additions and 45 deletions
|
|
@ -64,6 +64,34 @@ export const NotificationsSection = ({
|
|||
}: NotificationsSectionProps): React.JSX.Element => {
|
||||
return (
|
||||
<div>
|
||||
{/* Task Completion Notifications */}
|
||||
<SettingsSectionHeader title="Task Completion Notifications" />
|
||||
<div
|
||||
className="mb-4 rounded-lg border p-4"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
}}
|
||||
>
|
||||
<p className="mb-3 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar
|
||||
badges. Works on macOS, Linux, and Windows.
|
||||
</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
void api.openExternal('https://github.com/777genius/claude-notifications-go')
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-border-emphasis)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Install claude-notifications-go plugin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notification Triggers */}
|
||||
<NotificationTriggerSettings
|
||||
triggers={safeConfig.notifications.triggers || []}
|
||||
|
|
@ -131,33 +159,6 @@ export const NotificationsSection = ({
|
|||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingsSectionHeader title="Task Completion Notifications" />
|
||||
<div
|
||||
className="mb-4 rounded-lg border p-4"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
}}
|
||||
>
|
||||
<p className="mb-3 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar
|
||||
badges. Works on macOS, Linux, and Windows.
|
||||
</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
void api.openExternal('https://github.com/777genius/claude-notifications-go')
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-border-emphasis)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Install claude-notifications-go plugin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SettingsSectionHeader title="Ignored Repositories" />
|
||||
<p className="mb-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Notifications from these repositories will be ignored
|
||||
|
|
|
|||
|
|
@ -49,17 +49,13 @@ export const CollapsibleTeamSection = ({
|
|||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
{secondaryBadge != null && secondaryBadge >= 0 && (
|
||||
{secondaryBadge != null && secondaryBadge > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
secondaryBadge > 0
|
||||
? 'bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-normal leading-none text-blue-400'
|
||||
: 'px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]'
|
||||
}
|
||||
title={secondaryBadge > 0 ? `${secondaryBadge} unread` : undefined}
|
||||
className="bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-normal leading-none text-blue-400"
|
||||
title={`${secondaryBadge} unread`}
|
||||
>
|
||||
{secondaryBadge}
|
||||
{secondaryBadge} new
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,17 @@ import { cn } from '@renderer/lib/utils';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { GitBranch, Pencil, Play, Plus, Search, Trash2, UserPlus, X } from 'lucide-react';
|
||||
import {
|
||||
CheckCheck,
|
||||
GitBranch,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
|
||||
|
|
@ -56,6 +66,7 @@ interface CreateTaskDialogState {
|
|||
defaultSubject: string;
|
||||
defaultDescription: string;
|
||||
defaultOwner: string;
|
||||
defaultStartImmediately?: boolean;
|
||||
}
|
||||
|
||||
interface TimeWindow {
|
||||
|
|
@ -342,15 +353,21 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
return list;
|
||||
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||
|
||||
const { readSet, markRead } = useTeamMessagesRead(teamName ?? '');
|
||||
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? '');
|
||||
const messagesUnreadCount = useMemo(
|
||||
() => filteredMessages.filter((m) => !readSet.has(toMessageKey(m))).length,
|
||||
() => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length,
|
||||
[filteredMessages, readSet]
|
||||
);
|
||||
const handleMessageVisible = useCallback(
|
||||
(message: InboxMessage) => markRead(toMessageKey(message)),
|
||||
[markRead]
|
||||
);
|
||||
const handleMarkAllRead = useCallback(() => {
|
||||
const keys = filteredMessages
|
||||
.filter((m) => !m.read && !readSet.has(toMessageKey(m)))
|
||||
.map((m) => toMessageKey(m));
|
||||
markAllRead(keys);
|
||||
}, [filteredMessages, readSet, markAllRead]);
|
||||
|
||||
const kanbanDisplayTasks = useMemo(() => {
|
||||
const query = kanbanSearch.trim();
|
||||
|
|
@ -385,12 +402,18 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
if (changed) setPendingRepliesByMember(next);
|
||||
}, [data, pendingRepliesByMember]);
|
||||
|
||||
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
|
||||
const openCreateTaskDialog = (
|
||||
subject = '',
|
||||
description = '',
|
||||
owner = '',
|
||||
startImmediately?: boolean
|
||||
): void => {
|
||||
setCreateTaskDialog({
|
||||
open: true,
|
||||
defaultSubject: subject,
|
||||
defaultDescription: description,
|
||||
defaultOwner: owner,
|
||||
defaultStartImmediately: startImmediately,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -400,6 +423,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
defaultSubject: '',
|
||||
defaultDescription: '',
|
||||
defaultOwner: '',
|
||||
defaultStartImmediately: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -768,6 +792,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
}
|
||||
}}
|
||||
onTaskClick={(task) => setSelectedTask(task)}
|
||||
onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)}
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
|
|
@ -780,6 +805,23 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
defaultOpen
|
||||
action={
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
{messagesUnreadCount > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAllRead();
|
||||
}}
|
||||
>
|
||||
<CheckCheck size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Mark all as read</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
|
|
@ -915,6 +957,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
defaultSubject={createTaskDialog.defaultSubject}
|
||||
defaultDescription={createTaskDialog.defaultDescription}
|
||||
defaultOwner={createTaskDialog.defaultOwner}
|
||||
defaultStartImmediately={createTaskDialog.defaultStartImmediately}
|
||||
onClose={closeCreateTaskDialog}
|
||||
onSubmit={handleCreateTask}
|
||||
submitting={creatingTask}
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ export const ActivityTimeline = ({
|
|||
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
|
||||
const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
|
||||
const isUnread = readState
|
||||
? !readState.readSet.has(readState.getMessageKey(message))
|
||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||
: !message.read;
|
||||
return (
|
||||
<MessageRowWithObserver
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ interface CreateTaskDialogProps {
|
|||
defaultSubject?: string;
|
||||
defaultDescription?: string;
|
||||
defaultOwner?: string;
|
||||
defaultStartImmediately?: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
subject: string,
|
||||
|
|
@ -60,6 +61,7 @@ export const CreateTaskDialog = ({
|
|||
defaultSubject = '',
|
||||
defaultDescription = '',
|
||||
defaultOwner = '',
|
||||
defaultStartImmediately,
|
||||
onClose,
|
||||
onSubmit,
|
||||
submitting = false,
|
||||
|
|
@ -86,7 +88,7 @@ export const CreateTaskDialog = ({
|
|||
setOwner(defaultOwner);
|
||||
setBlockedBy([]);
|
||||
setRelated([]);
|
||||
setStartImmediately(isTeamAlive);
|
||||
setStartImmediately(defaultStartImmediately ?? isTeamAlive);
|
||||
promptDraft.clearDraft();
|
||||
setBlockedBySearch('');
|
||||
setRelatedSearch('');
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ interface KanbanBoardProps {
|
|||
onColumnOrderChange?: (columnId: KanbanColumnId, orderedTaskIds: string[]) => void;
|
||||
/** Слот слева в одной строке с фильтром и переключателем вида (например, поле поиска). */
|
||||
toolbarLeft?: React.ReactNode;
|
||||
/** Opens the create-task dialog with pre-set startImmediately value. */
|
||||
onAddTask?: (startImmediately: boolean) => void;
|
||||
}
|
||||
|
||||
type KanbanViewMode = 'grid' | 'columns';
|
||||
|
|
@ -218,6 +220,7 @@ export const KanbanBoard = ({
|
|||
onTaskClick,
|
||||
onColumnOrderChange,
|
||||
toolbarLeft,
|
||||
onAddTask,
|
||||
}: KanbanBoardProps): React.JSX.Element => {
|
||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||
|
||||
|
|
@ -395,6 +398,12 @@ export const KanbanBoard = ({
|
|||
{COLUMNS.map((column) => {
|
||||
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||
const accent = COLUMN_ACCENTS[column.id];
|
||||
const addHandler =
|
||||
onAddTask && column.id === 'todo'
|
||||
? () => onAddTask(false)
|
||||
: onAddTask && column.id === 'in_progress'
|
||||
? () => onAddTask(true)
|
||||
: undefined;
|
||||
return (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
|
|
@ -403,6 +412,7 @@ export const KanbanBoard = ({
|
|||
icon={accent.icon}
|
||||
headerBg={accent.headerBg}
|
||||
bodyBg={accent.bodyBg}
|
||||
onAddTask={addHandler}
|
||||
>
|
||||
{renderCards(column.id, columnTasks)}
|
||||
</KanbanColumn>
|
||||
|
|
@ -414,6 +424,12 @@ export const KanbanBoard = ({
|
|||
{COLUMNS.map((column) => {
|
||||
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||
const accent = COLUMN_ACCENTS[column.id];
|
||||
const addHandler =
|
||||
onAddTask && column.id === 'todo'
|
||||
? () => onAddTask(false)
|
||||
: onAddTask && column.id === 'in_progress'
|
||||
? () => onAddTask(true)
|
||||
: undefined;
|
||||
return (
|
||||
<div key={column.id} className="w-64 shrink-0">
|
||||
<KanbanColumn
|
||||
|
|
@ -422,6 +438,7 @@ export const KanbanBoard = ({
|
|||
icon={accent.icon}
|
||||
headerBg={accent.headerBg}
|
||||
bodyBg={accent.bodyBg}
|
||||
onAddTask={addHandler}
|
||||
>
|
||||
{renderCards(column.id, columnTasks)}
|
||||
</KanbanColumn>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
title: string;
|
||||
|
|
@ -7,6 +8,7 @@ interface KanbanColumnProps {
|
|||
icon?: React.ReactNode;
|
||||
headerBg?: string;
|
||||
bodyBg?: string;
|
||||
onAddTask?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -16,6 +18,7 @@ export const KanbanColumn = ({
|
|||
icon,
|
||||
headerBg,
|
||||
bodyBg,
|
||||
onAddTask,
|
||||
children,
|
||||
}: KanbanColumnProps): React.JSX.Element => {
|
||||
return (
|
||||
|
|
@ -34,9 +37,21 @@ export const KanbanColumn = ({
|
|||
{icon}
|
||||
{title}
|
||||
</h4>
|
||||
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
|
||||
{count}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onAddTask ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddTask}
|
||||
className="inline-flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] transition-colors hover:bg-white/10 hover:text-[var(--color-text)]"
|
||||
aria-label={`Add task to ${title}`}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex max-h-[480px] flex-col gap-2 overflow-auto p-2">{children}</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import { useCallback, useMemo, useState } from 'react';
|
|||
|
||||
import {
|
||||
getReadSet as getReadSetStorage,
|
||||
markBulkRead as markBulkReadStorage,
|
||||
markRead as markReadStorage,
|
||||
} from '@renderer/utils/teamMessageReadStorage';
|
||||
|
||||
export function useTeamMessagesRead(teamName: string): {
|
||||
readSet: Set<string>;
|
||||
markRead: (messageKey: string) => void;
|
||||
markAllRead: (messageKeys: string[]) => void;
|
||||
} {
|
||||
const [version, setVersion] = useState(0);
|
||||
const readSet = useMemo(() => {
|
||||
|
|
@ -27,6 +29,24 @@ export function useTeamMessagesRead(teamName: string): {
|
|||
[teamName]
|
||||
);
|
||||
|
||||
const markAllRead = useCallback(
|
||||
(messageKeys: string[]) => {
|
||||
if (!teamName || messageKeys.length === 0) return;
|
||||
const existing = getReadSetStorage(teamName);
|
||||
let changed = false;
|
||||
for (const key of messageKeys) {
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
markBulkReadStorage(teamName, existing);
|
||||
setVersion((v) => v + 1);
|
||||
},
|
||||
[teamName]
|
||||
);
|
||||
|
||||
const effectiveReadSet = !teamName ? new Set<string>() : readSet;
|
||||
return { readSet: effectiveReadSet, markRead };
|
||||
return { readSet: effectiveReadSet, markRead, markAllRead };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,3 +36,14 @@ export function markRead(teamName: string, messageKey: string, fullSet?: Set<str
|
|||
// quota or disabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a full set of read keys at once (bulk mark-all-as-read).
|
||||
*/
|
||||
export function markBulkRead(teamName: string, fullSet: Set<string>): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey(teamName), JSON.stringify([...fullSet]));
|
||||
} catch {
|
||||
// quota or disabled
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue