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:
iliya 2026-02-24 15:38:58 +02:00 committed by Илия
parent 63c7204c1a
commit 3a05f113bc
9 changed files with 150 additions and 45 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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('');

View file

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

View file

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

View file

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

View file

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