From 9b273780875e6df95599af091ae907d03e09f6b1 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 4 Mar 2026 16:15:40 +0200 Subject: [PATCH] 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. --- .gitignore | 1 + .../services/team/TeamAgentToolsInstaller.ts | 47 +++--- src/main/services/team/TeamDataService.ts | 98 ++++++------ .../services/team/TeamProvisioningService.ts | 36 +++-- .../components/sidebar/GlobalTaskList.tsx | 148 +++++++++++++++-- .../components/sidebar/SidebarTaskItem.tsx | 106 +++++++++++-- .../components/sidebar/TaskContextMenu.tsx | 74 +++++++++ src/renderer/components/ui/context-menu.tsx | 124 +++++++++++++++ src/renderer/hooks/useTaskLocalState.ts | 150 ++++++++++++++++++ 9 files changed, 676 insertions(+), 108 deletions(-) create mode 100644 src/renderer/components/sidebar/TaskContextMenu.tsx create mode 100644 src/renderer/components/ui/context-menu.tsx create mode 100644 src/renderer/hooks/useTaskLocalState.ts diff --git a/.gitignore b/.gitignore index 203313a7..dbfec4cf 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ notification_example/ temp/ .claude/*.local.json .claude/agent-memory/* +.claude/worktrees/ eslint-fix/ diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 76dc58c0..908a2aef 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -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; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index fea162c5..64fd6c37 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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 } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index c2f8d23d..c4278739 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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: diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index be34b50e..b3526bcf 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -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(loadGroupingMode); + const [showArchived, setShowArchived] = useState(false); + const [renamingTaskKey, setRenamingTaskKey] = useState(null); const searchInputRef = useRef(null); const hasFetchedRef = useRef(false); const readState = useReadStateSnapshot(); + const taskLocalState = useTaskLocalState(); // Local project filter (independent from sessions tab) const [localProjectFilter, setLocalProjectFilter] = useState(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 (
@@ -266,6 +296,35 @@ export const GlobalTaskList = ({ />
+ {/* Pinned tasks section */} + {pinnedTasks.length > 0 && !showArchived && ( +
+
+ + Pinned +
+ {sortTasksByFreshness(pinnedTasks).map((task) => ( + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + > + taskLocalState.getRenamedSubject(t.teamName, t.id)} + /> + + ))} +
+ )} + {/* Grouping mode — compact segmented toggle */}
Group by: @@ -293,6 +352,28 @@ export const GlobalTaskList = ({ ); })}
+ {/* Archive toggle */} +
+ + + + + + {showArchived ? 'Hide archived' : 'Show archived'} + + +
{/* Content */} @@ -316,7 +397,23 @@ export const GlobalTaskList = ({ {groupingMode === 'none' && sortedFlat.map((task) => ( - + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + > + taskLocalState.getRenamedSubject(t.teamName, t.id)} + /> + ))} {groupingMode === 'project' && @@ -347,7 +444,24 @@ export const GlobalTaskList = ({ Team: {task.teamDisplayName} )} - + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + ); })} @@ -380,7 +494,23 @@ export const GlobalTaskList = ({ Team: {task.teamDisplayName} )} - + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + ); })} diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index b8c644aa..557f4ced 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -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(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 */}
- - - - {task.subject} - - - - {task.subject} - - - {unreadCount > 0 && ( + {isRenaming ? ( + 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()} + /> + ) : ( + + + + {displaySubject} + + + + {displaySubject} + + + )} + {unreadCount > 0 && !isRenaming && ( void; + onToggleArchive: () => void; + onRename: () => void; + children: React.ReactNode; +} + +export const TaskContextMenu = ({ + task: _task, + isPinned, + isArchived, + onTogglePin, + onToggleArchive, + onRename, + children, +}: TaskContextMenuProps): React.JSX.Element => { + return ( + + +
{children}
+
+ + + {isPinned ? ( + <> + + Unpin + + ) : ( + <> + + Pin + + )} + + + + + Rename + + + + + + {isArchived ? ( + <> + + Unarchive + + ) : ( + <> + + Archive + + )} + + +
+ ); +}; diff --git a/src/renderer/components/ui/context-menu.tsx b/src/renderer/components/ui/context-menu.tsx new file mode 100644 index 00000000..fbc6eeec --- /dev/null +++ b/src/renderer/components/ui/context-menu.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuSeparator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuSubTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuLabel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +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 */ diff --git a/src/renderer/hooks/useTaskLocalState.ts b/src/renderer/hooks/useTaskLocalState.ts new file mode 100644 index 00000000..8f454db3 --- /dev/null +++ b/src/renderer/hooks/useTaskLocalState.ts @@ -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 { + 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): void { + try { + localStorage.setItem(key, JSON.stringify([...set])); + } catch { + /* ignore */ + } +} + +function loadMap(key: string): Map { + 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).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ); + } + } catch { + /* ignore */ + } + return new Map(); +} + +function saveMap(key: string, map: Map): void { + try { + localStorage.setItem(key, JSON.stringify(Object.fromEntries(map))); + } catch { + /* ignore */ + } +} + +export interface TaskLocalState { + pinnedIds: Set; + archivedIds: Set; + renamedSubjects: Map; + + 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>(() => loadSet(PINNED_KEY)); + const [archivedIds, setArchivedIds] = useState>(() => loadSet(ARCHIVED_KEY)); + const [renamedSubjects, setRenamedSubjects] = useState>(() => + 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, + }; +}