feat: enhance task notification and management features

- Updated task assignment notifications to skip inbox messages when leads assign tasks to themselves, improving user experience for solo teams.
- Refactored notification logic in TeamAgentToolsInstaller and TeamDataService to ensure clarity and maintainability.
- Introduced new functionality in GlobalTaskList to manage pinned and archived tasks, enhancing task visibility and organization.
- Added renaming capabilities for tasks in SidebarTaskItem, allowing users to edit task subjects directly.
- Improved overall task filtering and grouping logic to support better task management practices.
This commit is contained in:
iliya 2026-03-04 16:15:40 +02:00
parent bd781aed2f
commit 9b27378087
9 changed files with 676 additions and 108 deletions

1
.gitignore vendored
View file

@ -43,6 +43,7 @@ notification_example/
temp/
.claude/*.local.json
.claude/agent-memory/*
.claude/worktrees/
eslint-fix/

View file

@ -1002,29 +1002,32 @@ async function main() {
if (notify && task.owner) {
const from =
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths);
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
if (rawDesc && rawDesc !== task.subject) {
parts.push('\nDescription:\n' + rawDesc);
// Skip inbox notification when lead assigns a task to themselves (solo teams)
if (task.owner.toLowerCase() !== from.toLowerCase()) {
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
if (rawDesc && rawDesc !== task.subject) {
parts.push('\nDescription:\n' + rawDesc);
}
const prompt = typeof args.flags.prompt === 'string' ? args.flags.prompt.trim() : '';
if (prompt) {
parts.push('\nInstructions:\n' + prompt);
}
parts.push(
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
'Update task status using:',
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
${JSON.stringify(AGENT_BLOCK_CLOSE)}
);
sendInboxMessage(paths, teamName, {
to: task.owner,
text: parts.join('\n'),
summary: 'New task #' + String(task.id) + ' assigned',
from,
});
}
const prompt = typeof args.flags.prompt === 'string' ? args.flags.prompt.trim() : '';
if (prompt) {
parts.push('\nInstructions:\n' + prompt);
}
parts.push(
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
'Update task status using:',
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
${JSON.stringify(AGENT_BLOCK_CLOSE)}
);
sendInboxMessage(paths, teamName, {
to: task.owner,
text: parts.join('\n'),
summary: 'New task #' + String(task.id) + ' assigned',
from,
});
}
process.stdout.write(JSON.stringify(task, null, 2) + '\n');
return;

View file

@ -779,35 +779,39 @@ export class TeamDataService {
if (shouldStart && request.owner) {
try {
const toolPath = await this.toolsInstaller.ensureInstalled();
// Build notification with full context — inbox is the primary delivery
// channel to agents (Claude Code monitors inbox via fs.watch)
const parts = [`New task assigned to you: #${task.id} "${task.subject}".`];
if (request.description?.trim()) {
parts.push(`\nDescription:\n${request.description.trim()}`);
}
if (request.prompt?.trim()) {
parts.push(`\nInstructions:\n${request.prompt.trim()}`);
}
parts.push(
`\n${AGENT_BLOCK_OPEN}`,
`Update task status using:`,
`node "${toolPath}" --team ${teamName} task start ${task.id}`,
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
AGENT_BLOCK_CLOSE
);
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, {
member: request.owner,
from: leadName,
text: parts.join('\n'),
summary: `New task #${task.id} assigned`,
});
// Skip inbox notification when lead assigns a task to themselves (solo teams)
if (!this.isLeadOwner(request.owner, leadName)) {
const toolPath = await this.toolsInstaller.ensureInstalled();
// Build notification with full context — inbox is the primary delivery
// channel to agents (Claude Code monitors inbox via fs.watch)
const parts = [`New task assigned to you: #${task.id} "${task.subject}".`];
if (request.description?.trim()) {
parts.push(`\nDescription:\n${request.description.trim()}`);
}
if (request.prompt?.trim()) {
parts.push(`\nInstructions:\n${request.prompt.trim()}`);
}
parts.push(
`\n${AGENT_BLOCK_OPEN}`,
`Update task status using:`,
`node "${toolPath}" --team ${teamName} task start ${task.id}`,
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
AGENT_BLOCK_CLOSE
);
await this.sendMessage(teamName, {
member: request.owner,
from: leadName,
text: parts.join('\n'),
summary: `New task #${task.id} assigned`,
});
}
} catch {
// Best-effort notification — don't fail task creation if message fails
}
@ -830,24 +834,28 @@ export class TeamDataService {
if (task.owner) {
try {
const toolPath = await this.toolsInstaller.ensureInstalled();
const parts = [`Task #${task.id} "${task.subject}" has been started.`];
if (task.description?.trim()) {
parts.push(`\nDetails:\n${task.description.trim()}`);
}
parts.push(
`\n${AGENT_BLOCK_OPEN}`,
`Update task status using:`,
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
AGENT_BLOCK_CLOSE
);
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, {
member: task.owner,
from: leadName,
text: parts.join('\n'),
summary: `Task #${task.id} started`,
});
// Skip inbox notification when lead starts their own task (solo teams)
if (!this.isLeadOwner(task.owner, leadName)) {
const toolPath = await this.toolsInstaller.ensureInstalled();
const parts = [`Task #${task.id} "${task.subject}" has been started.`];
if (task.description?.trim()) {
parts.push(`\nDetails:\n${task.description.trim()}`);
}
parts.push(
`\n${AGENT_BLOCK_OPEN}`,
`Update task status using:`,
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
AGENT_BLOCK_CLOSE
);
await this.sendMessage(teamName, {
member: task.owner,
from: leadName,
text: parts.join('\n'),
summary: `Task #${task.id} started`,
});
}
} catch {
// Best-effort notification
}

View file

@ -58,7 +58,7 @@ const STDOUT_RING_LIMIT = 64 * 1024;
const LOG_PROGRESS_THROTTLE_MS = 300;
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const SHELL_ENV_TIMEOUT_MS = 12000;
const CLI_PREPARE_TIMEOUT_MS = 10000;
// const CLI_PREPARE_TIMEOUT_MS = 10000;
const PROBE_CACHE_TTL_MS = 60_000;
const PREFLIGHT_TIMEOUT_MS = 30000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
@ -69,6 +69,9 @@ const TASK_WAIT_FALLBACK_MS = 15_000;
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024;
const PREFLIGHT_PING_PROMPT = 'Reply with the single word PONG and nothing else';
const PREFLIGHT_PING_ARGS = ['-p', PREFLIGHT_PING_PROMPT, '--output-format', 'text'] as const;
const PREFLIGHT_EXPECTED = 'PONG';
const execFileAsync = promisify(execFile);
@ -3873,6 +3876,7 @@ export class TeamProvisioningService {
/**
* Two-stage preflight check:
* 1. `claude --version` verifies binary is executable and returns version info.
* (currently disabled for speed; keep commented for debugging)
* 2. `claude -p "ping"` verifies that `-p` mode is actually authenticated.
* This catches the common case where interactive `claude` works (OAuth/keychain)
* but `-p` mode fails with "Not logged in" due to missing env vars.
@ -3885,19 +3889,19 @@ export class TeamProvisioningService {
// Stage 1: verify binary works (awaited first for clearer errors)
// Important: keep this sequential with Stage 2 to avoid auth/credential-store races
// when multiple `claude` processes start simultaneously (most visible on Windows).
const versionProbe = await this.spawnProbe(
claudePath,
['--version'],
cwd,
env,
CLI_PREPARE_TIMEOUT_MS
);
if (versionProbe.exitCode !== 0) {
const errorText =
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
`Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
throw new Error(`Failed to warm up Claude CLI: ${errorText}`);
}
// const versionProbe = await this.spawnProbe(
// claudePath,
// ['--version'],
// cwd,
// env,
// CLI_PREPARE_TIMEOUT_MS
// );
// if (versionProbe.exitCode !== 0) {
// const errorText =
// buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
// `Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
// throw new Error(`Failed to warm up Claude CLI: ${errorText}`);
// }
// Stage 2: verify `-p` mode auth actually works (with retry for stale locks after Ctrl+C)
for (let attempt = 1; attempt <= PREFLIGHT_AUTH_MAX_RETRIES; attempt++) {
@ -3905,7 +3909,7 @@ export class TeamProvisioningService {
try {
pingProbe = await this.spawnProbe(
claudePath,
['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'],
[...PREFLIGHT_PING_ARGS],
cwd,
env,
PREFLIGHT_TIMEOUT_MS
@ -3956,7 +3960,7 @@ export class TeamProvisioningService {
}
const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim();
const isPong = pongCandidate.toUpperCase() === 'PONG';
const isPong = pongCandidate.toUpperCase() === PREFLIGHT_EXPECTED;
if (!isPong) {
return {
warning:

View file

@ -1,5 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize';
@ -10,12 +12,13 @@ import {
groupTasksByProject,
sortTasksByFreshness,
} from '@renderer/utils/taskGrouping';
import { ListTodo, Search, X } from 'lucide-react';
import { Archive, ListTodo, Pin, Search, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import { SidebarTaskItem } from './SidebarTaskItem';
import { TaskContextMenu } from './TaskContextMenu';
import { TaskFiltersPopover } from './TaskFiltersPopover';
import {
defaultTaskFiltersState,
@ -118,9 +121,12 @@ export const GlobalTaskList = ({
const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
const [searchQuery, setSearchQuery] = useState('');
const [groupingMode, setGroupingModeState] = useState<TaskGroupingMode>(loadGroupingMode);
const [showArchived, setShowArchived] = useState(false);
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const hasFetchedRef = useRef(false);
const readState = useReadStateSnapshot();
const taskLocalState = useTaskLocalState();
// Local project filter (independent from sessions tab)
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(null);
@ -130,6 +136,11 @@ export const GlobalTaskList = ({
saveGroupingMode(mode);
};
const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => {
taskLocalState.renameTask(teamName, taskId, newSubject);
setRenamingTaskKey(null);
};
// Fetch tasks on mount — loading guard in the store action prevents
// duplicate IPC calls when the centralized init chain is already fetching.
useEffect(() => {
@ -181,6 +192,12 @@ export const GlobalTaskList = ({
);
}
result = applySearch(result, searchQuery);
// Archive filtering
if (showArchived) {
result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id));
} else {
result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id));
}
return result;
}, [
globalTasks,
@ -190,19 +207,32 @@ export const GlobalTaskList = ({
filters.unreadOnly,
searchQuery,
readState,
showArchived,
taskLocalState,
]);
const sortedFlat = useMemo(() => sortTasksByFreshness(filtered), [filtered]);
const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]);
// Split into pinned and normal (non-pinned) tasks
const pinnedTasks = useMemo(
() => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)),
[filtered, taskLocalState]
);
const normalTasks = useMemo(
() => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)),
[filtered, taskLocalState]
);
const sortedFlat = useMemo(() => sortTasksByFreshness(normalTasks), [normalTasks]);
const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]);
const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
const projectGroups = useMemo(() => groupTasksByProject(filtered), [filtered]);
const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]);
const hasContent =
groupingMode === 'none'
pinnedTasks.length > 0 ||
(groupingMode === 'none'
? sortedFlat.length > 0
: groupingMode === 'time'
? categories.length > 0
: projectGroups.some((g) => g.tasks.length > 0);
: projectGroups.some((g) => g.tasks.length > 0));
return (
<div className="flex size-full min-w-0 flex-col">
@ -266,6 +296,35 @@ export const GlobalTaskList = ({
/>
</div>
{/* Pinned tasks section */}
{pinnedTasks.length > 0 && !showArchived && (
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
<div className="flex items-center gap-1 px-2 py-1">
<Pin className="size-3 text-text-muted" />
<span className="text-[11px] text-text-muted">Pinned</span>
</div>
{sortTasksByFreshness(pinnedTasks).map((task) => (
<TaskContextMenu
key={`pinned-${task.teamName}-${task.id}`}
task={task}
isPinned={true}
isArchived={false}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
>
<SidebarTaskItem
task={task}
showTeamName
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)}
/>
</TaskContextMenu>
))}
</div>
)}
{/* Grouping mode — compact segmented toggle */}
<div className="flex shrink-0 items-center gap-1.5 px-2 py-1">
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
@ -293,6 +352,28 @@ export const GlobalTaskList = ({
);
})}
</div>
{/* Archive toggle */}
<div className="ml-auto">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowArchived(!showArchived)}
className={cn(
'rounded p-0.5 transition-colors',
showArchived
? 'bg-surface-raised text-text-secondary'
: 'text-text-muted hover:text-text-secondary'
)}
>
<Archive className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
{showArchived ? 'Hide archived' : 'Show archived'}
</TooltipContent>
</Tooltip>
</div>
</div>
{/* Content */}
@ -316,7 +397,23 @@ export const GlobalTaskList = ({
{groupingMode === 'none' &&
sortedFlat.map((task) => (
<SidebarTaskItem key={`${task.teamName}-${task.id}`} task={task} showTeamName />
<TaskContextMenu
key={`${task.teamName}-${task.id}`}
task={task}
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
>
<SidebarTaskItem
task={task}
showTeamName
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)}
/>
</TaskContextMenu>
))}
{groupingMode === 'project' &&
@ -347,7 +444,24 @@ export const GlobalTaskList = ({
Team: {task.teamDisplayName}
</div>
)}
<SidebarTaskItem task={task} hideTeamName />
<TaskContextMenu
task={task}
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
>
<SidebarTaskItem
task={task}
hideTeamName
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
getDisplaySubject={(t) =>
taskLocalState.getRenamedSubject(t.teamName, t.id)
}
/>
</TaskContextMenu>
</div>
);
})}
@ -380,7 +494,23 @@ export const GlobalTaskList = ({
Team: {task.teamDisplayName}
</div>
)}
<SidebarTaskItem task={task} />
<TaskContextMenu
task={task}
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
>
<SidebarTaskItem
task={task}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
getDisplaySubject={(t) =>
taskLocalState.getRenamedSubject(t.teamName, t.id)
}
/>
</TaskContextMenu>
</div>
);
})}

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
@ -56,16 +56,49 @@ interface SidebarTaskItemProps {
task: GlobalTask;
hideTeamName?: boolean;
showTeamName?: boolean;
/** The composite key "teamName:taskId" of the task being renamed, or null */
renamingKey?: string | null;
/** Called when rename is completed with Enter or blur */
onRenameComplete?: (teamName: string, taskId: string, newSubject: string) => void;
/** Called when rename is cancelled with Escape */
onRenameCancel?: () => void;
/** Returns a custom display subject if the task was renamed locally */
getDisplaySubject?: (task: GlobalTask) => string | undefined;
}
export const SidebarTaskItem = ({
task,
hideTeamName,
showTeamName,
renamingKey,
onRenameComplete,
onRenameCancel,
getDisplaySubject,
}: SidebarTaskItemProps): React.JSX.Element => {
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const teamMembers = useStore((s) => s.teamByName[task.teamName]?.members);
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
const isRenaming = renamingKey === `${task.teamName}:${task.id}`;
const displaySubject = getDisplaySubject?.(task) ?? task.subject;
const [editValue, setEditValue] = useState(displaySubject);
const inputRef = useRef<HTMLInputElement>(null);
// Focus input when rename starts
useEffect(() => {
if (isRenaming && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isRenaming]);
// Reset edit value when renaming starts
useEffect(() => {
if (isRenaming) {
setEditValue(displaySubject);
}
}, [isRenaming, displaySubject]);
const cfg =
task.kanbanColumn === 'approved'
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
@ -105,25 +138,66 @@ export const SidebarTaskItem = ({
type="button"
className={`flex w-full cursor-pointer flex-col justify-center border-b px-3 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''}`}
style={{ borderColor: 'var(--color-border)' }}
onClick={() => openGlobalTaskDetail(task.teamName, task.id)}
onClick={() => {
if (!isRenaming) {
openGlobalTaskDetail(task.teamName, task.id);
}
}}
>
{/* Row 1: status + subject */}
<div className="flex w-full items-start gap-1.5 overflow-hidden">
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
<Tooltip>
<TooltipTrigger asChild>
<span
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{task.subject}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{task.subject}
</TooltipContent>
</Tooltip>
{unreadCount > 0 && (
{isRenaming ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const trimmed = editValue.trim();
if (trimmed && trimmed !== task.subject) {
onRenameComplete?.(task.teamName, task.id, trimmed);
} else {
onRenameCancel?.();
}
} else if (e.key === 'Escape') {
e.preventDefault();
onRenameCancel?.();
}
}}
onBlur={() => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== task.subject) {
onRenameComplete?.(task.teamName, task.id, trimmed);
} else {
onRenameCancel?.();
}
}}
className="min-w-0 flex-1 rounded border bg-transparent px-1 py-0 text-[13px] font-medium leading-tight text-text focus:outline-none"
style={{
borderColor: 'var(--color-border-emphasis)',
backgroundColor: 'var(--color-surface-raised)',
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{displaySubject}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{displaySubject}
</TooltipContent>
</Tooltip>
)}
{unreadCount > 0 && !isRenaming && (
<span
className="size-1.5 shrink-0 rounded-full bg-blue-400"
title={`${unreadCount} unread`}

View file

@ -0,0 +1,74 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@renderer/components/ui/context-menu';
import { Archive, ArchiveRestore, Pencil, Pin, PinOff } from 'lucide-react';
import type { GlobalTask } from '@shared/types';
export interface TaskContextMenuProps {
task: GlobalTask;
isPinned: boolean;
isArchived: boolean;
onTogglePin: () => void;
onToggleArchive: () => void;
onRename: () => void;
children: React.ReactNode;
}
export const TaskContextMenu = ({
task: _task,
isPinned,
isArchived,
onTogglePin,
onToggleArchive,
onRename,
children,
}: TaskContextMenuProps): React.JSX.Element => {
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="w-full">{children}</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={onTogglePin}>
{isPinned ? (
<>
<PinOff className="size-3.5 shrink-0" />
<span>Unpin</span>
</>
) : (
<>
<Pin className="size-3.5 shrink-0" />
<span>Pin</span>
</>
)}
</ContextMenuItem>
<ContextMenuItem onSelect={onRename}>
<Pencil className="size-3.5 shrink-0" />
<span>Rename</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onToggleArchive}>
{isArchived ? (
<>
<ArchiveRestore className="size-3.5 shrink-0" />
<span>Unarchive</span>
</>
) : (
<>
<Archive className="size-3.5 shrink-0" />
<span>Archive</span>
</>
)}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};

View file

@ -0,0 +1,124 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { cn } from '@renderer/lib/utils';
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuContent = React.forwardRef<
React.ComponentRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-[160px] rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-overlay)] p-1 text-[12px] text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ComponentRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-[12px] text-[var(--color-text-secondary)] outline-none transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-[var(--color-text)] focus:bg-[rgba(255,255,255,0.06)] focus:text-[var(--color-text)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ComponentRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn('my-1 h-px bg-[var(--color-border)]', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuSubTrigger = React.forwardRef<
React.ComponentRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-[12px] text-[var(--color-text-secondary)] outline-none transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-[var(--color-text)] focus:bg-[rgba(255,255,255,0.06)] focus:text-[var(--color-text)]',
inset && 'pl-8',
className
)}
{...props}
>
{children}
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ComponentRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[160px] rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-overlay)] p-1 text-[12px] text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuLabel = React.forwardRef<
React.ComponentRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-[11px] font-semibold text-[var(--color-text-muted)]',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
export {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
};
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */

View file

@ -0,0 +1,150 @@
import { useCallback, useState } from 'react';
const PINNED_KEY = 'taskPinnedIds';
const ARCHIVED_KEY = 'taskArchivedIds';
const RENAMED_KEY = 'taskRenamedSubjects';
function makeCompositeKey(teamName: string, taskId: string): string {
return `${teamName}:${taskId}`;
}
function loadSet(key: string): Set<string> {
try {
const raw = localStorage.getItem(key);
if (!raw) return new Set();
const arr: unknown = JSON.parse(raw);
if (Array.isArray(arr)) return new Set(arr.filter((v): v is string => typeof v === 'string'));
} catch {
/* ignore */
}
return new Set();
}
function saveSet(key: string, set: Set<string>): void {
try {
localStorage.setItem(key, JSON.stringify([...set]));
} catch {
/* ignore */
}
}
function loadMap(key: string): Map<string, string> {
try {
const raw = localStorage.getItem(key);
if (!raw) return new Map();
const obj: unknown = JSON.parse(raw);
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
return new Map(
Object.entries(obj as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
);
}
} catch {
/* ignore */
}
return new Map();
}
function saveMap(key: string, map: Map<string, string>): void {
try {
localStorage.setItem(key, JSON.stringify(Object.fromEntries(map)));
} catch {
/* ignore */
}
}
export interface TaskLocalState {
pinnedIds: Set<string>;
archivedIds: Set<string>;
renamedSubjects: Map<string, string>;
isPinned: (teamName: string, taskId: string) => boolean;
isArchived: (teamName: string, taskId: string) => boolean;
getRenamedSubject: (teamName: string, taskId: string) => string | undefined;
togglePin: (teamName: string, taskId: string) => void;
toggleArchive: (teamName: string, taskId: string) => void;
renameTask: (teamName: string, taskId: string, newSubject: string) => void;
}
export function useTaskLocalState(): TaskLocalState {
const [pinnedIds, setPinnedIds] = useState<Set<string>>(() => loadSet(PINNED_KEY));
const [archivedIds, setArchivedIds] = useState<Set<string>>(() => loadSet(ARCHIVED_KEY));
const [renamedSubjects, setRenamedSubjects] = useState<Map<string, string>>(() =>
loadMap(RENAMED_KEY)
);
const isPinned = useCallback(
(teamName: string, taskId: string): boolean =>
pinnedIds.has(makeCompositeKey(teamName, taskId)),
[pinnedIds]
);
const isArchived = useCallback(
(teamName: string, taskId: string): boolean =>
archivedIds.has(makeCompositeKey(teamName, taskId)),
[archivedIds]
);
const getRenamedSubject = useCallback(
(teamName: string, taskId: string): string | undefined =>
renamedSubjects.get(makeCompositeKey(teamName, taskId)),
[renamedSubjects]
);
const togglePin = useCallback((teamName: string, taskId: string): void => {
const key = makeCompositeKey(teamName, taskId);
setPinnedIds((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
saveSet(PINNED_KEY, next);
return next;
});
}, []);
const toggleArchive = useCallback((teamName: string, taskId: string): void => {
const key = makeCompositeKey(teamName, taskId);
setArchivedIds((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
saveSet(ARCHIVED_KEY, next);
return next;
});
}, []);
const renameTask = useCallback((teamName: string, taskId: string, newSubject: string): void => {
const key = makeCompositeKey(teamName, taskId);
setRenamedSubjects((prev) => {
const next = new Map(prev);
const trimmed = newSubject.trim();
if (trimmed) {
next.set(key, trimmed);
} else {
next.delete(key);
}
saveMap(RENAMED_KEY, next);
return next;
});
}, []);
return {
pinnedIds,
archivedIds,
renamedSubjects,
isPinned,
isArchived,
getRenamedSubject,
togglePin,
toggleArchive,
renameTask,
};
}