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/
|
||||
.claude/*.local.json
|
||||
.claude/agent-memory/*
|
||||
.claude/worktrees/
|
||||
|
||||
|
||||
eslint-fix/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
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