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:
parent
bd781aed2f
commit
9b27378087
9 changed files with 676 additions and 108 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -43,6 +43,7 @@ notification_example/
|
||||||
temp/
|
temp/
|
||||||
.claude/*.local.json
|
.claude/*.local.json
|
||||||
.claude/agent-memory/*
|
.claude/agent-memory/*
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
|
|
||||||
eslint-fix/
|
eslint-fix/
|
||||||
|
|
|
||||||
|
|
@ -1002,29 +1002,32 @@ async function main() {
|
||||||
if (notify && task.owner) {
|
if (notify && task.owner) {
|
||||||
const from =
|
const from =
|
||||||
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths);
|
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) + '".'];
|
// Skip inbox notification when lead assigns a task to themselves (solo teams)
|
||||||
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
|
if (task.owner.toLowerCase() !== from.toLowerCase()) {
|
||||||
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
|
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
|
||||||
if (rawDesc && rawDesc !== task.subject) {
|
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
|
||||||
parts.push('\nDescription:\n' + rawDesc);
|
: 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');
|
process.stdout.write(JSON.stringify(task, null, 2) + '\n');
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -779,35 +779,39 @@ export class TeamDataService {
|
||||||
|
|
||||||
if (shouldStart && request.owner) {
|
if (shouldStart && request.owner) {
|
||||||
try {
|
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);
|
const leadName = await this.resolveLeadName(teamName);
|
||||||
await this.sendMessage(teamName, {
|
|
||||||
member: request.owner,
|
// Skip inbox notification when lead assigns a task to themselves (solo teams)
|
||||||
from: leadName,
|
if (!this.isLeadOwner(request.owner, leadName)) {
|
||||||
text: parts.join('\n'),
|
const toolPath = await this.toolsInstaller.ensureInstalled();
|
||||||
summary: `New task #${task.id} assigned`,
|
|
||||||
});
|
// 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 {
|
} catch {
|
||||||
// Best-effort notification — don't fail task creation if message fails
|
// Best-effort notification — don't fail task creation if message fails
|
||||||
}
|
}
|
||||||
|
|
@ -830,24 +834,28 @@ export class TeamDataService {
|
||||||
|
|
||||||
if (task.owner) {
|
if (task.owner) {
|
||||||
try {
|
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);
|
const leadName = await this.resolveLeadName(teamName);
|
||||||
await this.sendMessage(teamName, {
|
|
||||||
member: task.owner,
|
// Skip inbox notification when lead starts their own task (solo teams)
|
||||||
from: leadName,
|
if (!this.isLeadOwner(task.owner, leadName)) {
|
||||||
text: parts.join('\n'),
|
const toolPath = await this.toolsInstaller.ensureInstalled();
|
||||||
summary: `Task #${task.id} started`,
|
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 {
|
} catch {
|
||||||
// Best-effort notification
|
// Best-effort notification
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const STDOUT_RING_LIMIT = 64 * 1024;
|
||||||
const LOG_PROGRESS_THROTTLE_MS = 300;
|
const LOG_PROGRESS_THROTTLE_MS = 300;
|
||||||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||||
const SHELL_ENV_TIMEOUT_MS = 12000;
|
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 PROBE_CACHE_TTL_MS = 60_000;
|
||||||
const PREFLIGHT_TIMEOUT_MS = 30000;
|
const PREFLIGHT_TIMEOUT_MS = 30000;
|
||||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
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_JSON_READ_TIMEOUT_MS = 5_000;
|
||||||
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
|
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
const TEAM_INBOX_MAX_BYTES = 2 * 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);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
|
@ -3873,6 +3876,7 @@ export class TeamProvisioningService {
|
||||||
/**
|
/**
|
||||||
* Two-stage preflight check:
|
* Two-stage preflight check:
|
||||||
* 1. `claude --version` — verifies binary is executable and returns version info.
|
* 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.
|
* 2. `claude -p "ping"` — verifies that `-p` mode is actually authenticated.
|
||||||
* This catches the common case where interactive `claude` works (OAuth/keychain)
|
* This catches the common case where interactive `claude` works (OAuth/keychain)
|
||||||
* but `-p` mode fails with "Not logged in" due to missing env vars.
|
* 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)
|
// Stage 1: verify binary works (awaited first for clearer errors)
|
||||||
// Important: keep this sequential with Stage 2 to avoid auth/credential-store races
|
// Important: keep this sequential with Stage 2 to avoid auth/credential-store races
|
||||||
// when multiple `claude` processes start simultaneously (most visible on Windows).
|
// when multiple `claude` processes start simultaneously (most visible on Windows).
|
||||||
const versionProbe = await this.spawnProbe(
|
// const versionProbe = await this.spawnProbe(
|
||||||
claudePath,
|
// claudePath,
|
||||||
['--version'],
|
// ['--version'],
|
||||||
cwd,
|
// cwd,
|
||||||
env,
|
// env,
|
||||||
CLI_PREPARE_TIMEOUT_MS
|
// CLI_PREPARE_TIMEOUT_MS
|
||||||
);
|
// );
|
||||||
if (versionProbe.exitCode !== 0) {
|
// if (versionProbe.exitCode !== 0) {
|
||||||
const errorText =
|
// const errorText =
|
||||||
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
|
// buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
|
||||||
`Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
|
// `Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
|
||||||
throw new Error(`Failed to warm up Claude CLI: ${errorText}`);
|
// 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)
|
// 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++) {
|
for (let attempt = 1; attempt <= PREFLIGHT_AUTH_MAX_RETRIES; attempt++) {
|
||||||
|
|
@ -3905,7 +3909,7 @@ export class TeamProvisioningService {
|
||||||
try {
|
try {
|
||||||
pingProbe = await this.spawnProbe(
|
pingProbe = await this.spawnProbe(
|
||||||
claudePath,
|
claudePath,
|
||||||
['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'],
|
[...PREFLIGHT_PING_ARGS],
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
PREFLIGHT_TIMEOUT_MS
|
PREFLIGHT_TIMEOUT_MS
|
||||||
|
|
@ -3956,7 +3960,7 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim();
|
const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim();
|
||||||
const isPong = pongCandidate.toUpperCase() === 'PONG';
|
const isPong = pongCandidate.toUpperCase() === PREFLIGHT_EXPECTED;
|
||||||
if (!isPong) {
|
if (!isPong) {
|
||||||
return {
|
return {
|
||||||
warning:
|
warning:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
|
@ -10,12 +12,13 @@ import {
|
||||||
groupTasksByProject,
|
groupTasksByProject,
|
||||||
sortTasksByFreshness,
|
sortTasksByFreshness,
|
||||||
} from '@renderer/utils/taskGrouping';
|
} 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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { Combobox, type ComboboxOption } from '../ui/combobox';
|
import { Combobox, type ComboboxOption } from '../ui/combobox';
|
||||||
|
|
||||||
import { SidebarTaskItem } from './SidebarTaskItem';
|
import { SidebarTaskItem } from './SidebarTaskItem';
|
||||||
|
import { TaskContextMenu } from './TaskContextMenu';
|
||||||
import { TaskFiltersPopover } from './TaskFiltersPopover';
|
import { TaskFiltersPopover } from './TaskFiltersPopover';
|
||||||
import {
|
import {
|
||||||
defaultTaskFiltersState,
|
defaultTaskFiltersState,
|
||||||
|
|
@ -118,9 +121,12 @@ export const GlobalTaskList = ({
|
||||||
const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
|
const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [groupingMode, setGroupingModeState] = useState<TaskGroupingMode>(loadGroupingMode);
|
const [groupingMode, setGroupingModeState] = useState<TaskGroupingMode>(loadGroupingMode);
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const hasFetchedRef = useRef(false);
|
const hasFetchedRef = useRef(false);
|
||||||
const readState = useReadStateSnapshot();
|
const readState = useReadStateSnapshot();
|
||||||
|
const taskLocalState = useTaskLocalState();
|
||||||
|
|
||||||
// Local project filter (independent from sessions tab)
|
// Local project filter (independent from sessions tab)
|
||||||
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(null);
|
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(null);
|
||||||
|
|
@ -130,6 +136,11 @@ export const GlobalTaskList = ({
|
||||||
saveGroupingMode(mode);
|
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
|
// Fetch tasks on mount — loading guard in the store action prevents
|
||||||
// duplicate IPC calls when the centralized init chain is already fetching.
|
// duplicate IPC calls when the centralized init chain is already fetching.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -181,6 +192,12 @@ export const GlobalTaskList = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
result = applySearch(result, searchQuery);
|
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;
|
return result;
|
||||||
}, [
|
}, [
|
||||||
globalTasks,
|
globalTasks,
|
||||||
|
|
@ -190,19 +207,32 @@ export const GlobalTaskList = ({
|
||||||
filters.unreadOnly,
|
filters.unreadOnly,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
readState,
|
readState,
|
||||||
|
showArchived,
|
||||||
|
taskLocalState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sortedFlat = useMemo(() => sortTasksByFreshness(filtered), [filtered]);
|
// Split into pinned and normal (non-pinned) tasks
|
||||||
const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]);
|
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 categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
|
||||||
const projectGroups = useMemo(() => groupTasksByProject(filtered), [filtered]);
|
const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]);
|
||||||
|
|
||||||
const hasContent =
|
const hasContent =
|
||||||
groupingMode === 'none'
|
pinnedTasks.length > 0 ||
|
||||||
|
(groupingMode === 'none'
|
||||||
? sortedFlat.length > 0
|
? sortedFlat.length > 0
|
||||||
: groupingMode === 'time'
|
: groupingMode === 'time'
|
||||||
? categories.length > 0
|
? categories.length > 0
|
||||||
: projectGroups.some((g) => g.tasks.length > 0);
|
: projectGroups.some((g) => g.tasks.length > 0));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full min-w-0 flex-col">
|
<div className="flex size-full min-w-0 flex-col">
|
||||||
|
|
@ -266,6 +296,35 @@ export const GlobalTaskList = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Grouping mode — compact segmented toggle */}
|
||||||
<div className="flex shrink-0 items-center gap-1.5 px-2 py-1">
|
<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>
|
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
|
||||||
|
|
@ -293,6 +352,28 @@ export const GlobalTaskList = ({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|
@ -316,7 +397,23 @@ export const GlobalTaskList = ({
|
||||||
|
|
||||||
{groupingMode === 'none' &&
|
{groupingMode === 'none' &&
|
||||||
sortedFlat.map((task) => (
|
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' &&
|
{groupingMode === 'project' &&
|
||||||
|
|
@ -347,7 +444,24 @@ export const GlobalTaskList = ({
|
||||||
Team: {task.teamDisplayName}
|
Team: {task.teamDisplayName}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -380,7 +494,23 @@ export const GlobalTaskList = ({
|
||||||
Team: {task.teamDisplayName}
|
Team: {task.teamDisplayName}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
|
@ -56,16 +56,49 @@ interface SidebarTaskItemProps {
|
||||||
task: GlobalTask;
|
task: GlobalTask;
|
||||||
hideTeamName?: boolean;
|
hideTeamName?: boolean;
|
||||||
showTeamName?: 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 = ({
|
export const SidebarTaskItem = ({
|
||||||
task,
|
task,
|
||||||
hideTeamName,
|
hideTeamName,
|
||||||
showTeamName,
|
showTeamName,
|
||||||
|
renamingKey,
|
||||||
|
onRenameComplete,
|
||||||
|
onRenameCancel,
|
||||||
|
getDisplaySubject,
|
||||||
}: SidebarTaskItemProps): React.JSX.Element => {
|
}: SidebarTaskItemProps): React.JSX.Element => {
|
||||||
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
||||||
const teamMembers = useStore((s) => s.teamByName[task.teamName]?.members);
|
const teamMembers = useStore((s) => s.teamByName[task.teamName]?.members);
|
||||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
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 =
|
const cfg =
|
||||||
task.kanbanColumn === 'approved'
|
task.kanbanColumn === 'approved'
|
||||||
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
||||||
|
|
@ -105,25 +138,66 @@ export const SidebarTaskItem = ({
|
||||||
type="button"
|
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' : ''}`}
|
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)' }}
|
style={{ borderColor: 'var(--color-border)' }}
|
||||||
onClick={() => openGlobalTaskDetail(task.teamName, task.id)}
|
onClick={() => {
|
||||||
|
if (!isRenaming) {
|
||||||
|
openGlobalTaskDetail(task.teamName, task.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Row 1: status + subject */}
|
{/* Row 1: status + subject */}
|
||||||
<div className="flex w-full items-start gap-1.5 overflow-hidden">
|
<div className="flex w-full items-start gap-1.5 overflow-hidden">
|
||||||
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
|
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
|
||||||
<Tooltip>
|
{isRenaming ? (
|
||||||
<TooltipTrigger asChild>
|
<input
|
||||||
<span
|
ref={inputRef}
|
||||||
className="line-clamp-2 text-[13px] font-medium leading-tight"
|
type="text"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
value={editValue}
|
||||||
>
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
{task.subject}
|
onKeyDown={(e) => {
|
||||||
</span>
|
if (e.key === 'Enter') {
|
||||||
</TooltipTrigger>
|
e.preventDefault();
|
||||||
<TooltipContent side="right" sideOffset={6}>
|
const trimmed = editValue.trim();
|
||||||
{task.subject}
|
if (trimmed && trimmed !== task.subject) {
|
||||||
</TooltipContent>
|
onRenameComplete?.(task.teamName, task.id, trimmed);
|
||||||
</Tooltip>
|
} else {
|
||||||
{unreadCount > 0 && (
|
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
|
<span
|
||||||
className="size-1.5 shrink-0 rounded-full bg-blue-400"
|
className="size-1.5 shrink-0 rounded-full bg-blue-400"
|
||||||
title={`${unreadCount} unread`}
|
title={`${unreadCount} unread`}
|
||||||
|
|
|
||||||
74
src/renderer/components/sidebar/TaskContextMenu.tsx
Normal file
74
src/renderer/components/sidebar/TaskContextMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
124
src/renderer/components/ui/context-menu.tsx
Normal file
124
src/renderer/components/ui/context-menu.tsx
Normal 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 */
|
||||||
150
src/renderer/hooks/useTaskLocalState.ts
Normal file
150
src/renderer/hooks/useTaskLocalState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue