feat: add task commenting functionality and enhance UI interactions
- Updated the TeamAgentToolsInstaller to version 5, introducing a new command for adding comments to tasks. - Implemented the addTaskComment function to handle comment creation, including validation and notification to task owners. - Enhanced the TeamDataService to support fetching tasks alongside tool installation. - Updated UI components to display unread comment counts and improve user interaction with task comments. - Refactored various components to integrate task comments seamlessly into the existing workflow. These changes aim to improve collaboration and communication within teams by facilitating task-related discussions.
This commit is contained in:
parent
5f2bd36aca
commit
da25703935
18 changed files with 483 additions and 83 deletions
|
|
@ -5,7 +5,7 @@ import * as path from 'path';
|
|||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
const TOOL_FILE_NAME = 'teamctl.js';
|
||||
const TOOL_VERSION = 4;
|
||||
const TOOL_VERSION = 5;
|
||||
|
||||
function buildTeamCtlScript(): string {
|
||||
const script = String.raw`#!/usr/bin/env node
|
||||
|
|
@ -190,6 +190,31 @@ function setTaskStatus(paths, taskId, status) {
|
|||
writeTask(taskPath, task);
|
||||
}
|
||||
|
||||
function addTaskComment(paths, taskId, flags) {
|
||||
var text = typeof flags.text === 'string' ? flags.text.trim() : '';
|
||||
if (!text) die('Missing --text');
|
||||
var from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'agent';
|
||||
|
||||
var ref = readTask(paths, taskId);
|
||||
var task = ref.task;
|
||||
var taskPath = ref.taskPath;
|
||||
|
||||
var existing = Array.isArray(task.comments) ? task.comments : [];
|
||||
var commentId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()) + '-' + String(Math.random());
|
||||
var comment = {
|
||||
id: commentId,
|
||||
author: from,
|
||||
text: text,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
task.comments = existing.concat([comment]);
|
||||
writeTask(taskPath, task);
|
||||
|
||||
return { commentId: commentId, taskId: String(taskId), subject: task.subject, owner: task.owner };
|
||||
}
|
||||
|
||||
function listTaskIds(tasksDir) {
|
||||
let entries = [];
|
||||
try {
|
||||
|
|
@ -396,6 +421,7 @@ function printHelp() {
|
|||
' node teamctl.js task complete <id> [--team <team>]',
|
||||
' node teamctl.js task start <id> [--team <team>]',
|
||||
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--notify --from "member"] [--team <team>]',
|
||||
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
|
||||
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
|
||||
' node teamctl.js kanban clear <id> [--team <team>]',
|
||||
' node teamctl.js review approve <id> [--notify-owner --from "member" --note "..."] [--team <team>]',
|
||||
|
|
@ -498,6 +524,25 @@ async function main() {
|
|||
process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
if (action === 'comment') {
|
||||
const id = rest[0] || args.flags.id;
|
||||
if (!id) die('Usage: task comment <id> --text "..."');
|
||||
const result = addTaskComment(paths, String(id), args.flags);
|
||||
const from = typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'agent';
|
||||
// Notify task owner via inbox — but SKIP self-notification to prevent loop
|
||||
if (result.owner && result.owner !== from) {
|
||||
try {
|
||||
sendInboxMessage(paths, teamName, {
|
||||
to: result.owner,
|
||||
text: 'Comment on task #' + String(result.taskId) + ' "' + String(result.subject) + '":\n\n' + (typeof args.flags.text === 'string' ? args.flags.text.trim() : ''),
|
||||
summary: 'Comment on #' + String(result.taskId),
|
||||
from: from,
|
||||
});
|
||||
} catch (e) { /* best-effort */ }
|
||||
}
|
||||
process.stdout.write('OK comment added to task #' + String(id) + '\n');
|
||||
return;
|
||||
}
|
||||
die('Unknown task action: ' + String(action));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -319,12 +319,22 @@ export class TeamDataService {
|
|||
const comment = await this.taskWriter.addComment(teamName, taskId, text);
|
||||
|
||||
try {
|
||||
const tasks = await this.taskReader.getTasks(teamName);
|
||||
const [tasks, toolPath] = await Promise.all([
|
||||
this.taskReader.getTasks(teamName),
|
||||
this.toolsInstaller.ensureInstalled(),
|
||||
]);
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task?.owner) {
|
||||
const parts = [
|
||||
`Comment on task #${taskId} "${task.subject}":\n\n${text}`,
|
||||
`\n${AGENT_BLOCK_OPEN}`,
|
||||
`Reply to this comment using:`,
|
||||
`node "${toolPath}" --team ${teamName} task comment ${taskId} --text "<your reply>" --from "<your-name>"`,
|
||||
AGENT_BLOCK_CLOSE,
|
||||
];
|
||||
await this.sendMessage(teamName, {
|
||||
member: task.owner,
|
||||
text: `Comment on task #${taskId} "${task.subject}":\n\n${text}`,
|
||||
text: parts.join('\n'),
|
||||
summary: `Comment on #${taskId}`,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */
|
||||
import {
|
||||
encodePath,
|
||||
extractBaseDir,
|
||||
getAutoDetectedClaudeBasePath,
|
||||
getClaudeBasePath,
|
||||
getProjectsBasePath,
|
||||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
|
|
@ -246,6 +249,8 @@ function buildTaskStatusProtocol(teamName: string): string {
|
|||
4. If review fails and changes are needed:
|
||||
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review request-changes <taskId> --comment \\"<what to fix>\\"
|
||||
5. NEVER skip status updates. A task is NOT done until completed status is written.
|
||||
6. To reply to a comment on a task:
|
||||
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment <taskId> --text \\"<your reply>\\" --from \\"<your-name>\\"
|
||||
Failure to follow this protocol means the task board will show incorrect status.`;
|
||||
}
|
||||
|
||||
|
|
@ -754,6 +759,39 @@ export class TeamProvisioningService {
|
|||
} = await this.resolveLaunchExpectedMembers(request.teamName, configRaw);
|
||||
const expectedMembers = expectedMemberSpecs.map((m) => m.name);
|
||||
|
||||
// Extract leadSessionId for session resume on reconnect.
|
||||
// If a valid JSONL file exists for the previous session, we can resume it
|
||||
// so the lead retains full context of prior work.
|
||||
let previousSessionId: string | undefined;
|
||||
try {
|
||||
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
|
||||
if (
|
||||
typeof configParsed.leadSessionId === 'string' &&
|
||||
configParsed.leadSessionId.trim().length > 0
|
||||
) {
|
||||
const candidateId = configParsed.leadSessionId.trim();
|
||||
const projectPath =
|
||||
typeof configParsed.projectPath === 'string' && configParsed.projectPath.trim().length > 0
|
||||
? configParsed.projectPath.trim()
|
||||
: request.cwd;
|
||||
const projectId = encodePath(projectPath);
|
||||
const baseDir = extractBaseDir(projectId);
|
||||
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
|
||||
if (await this.pathExists(jsonlPath)) {
|
||||
previousSessionId = candidateId;
|
||||
logger.info(
|
||||
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.debug(`[${request.teamName}] Failed to extract leadSessionId from config for resume`);
|
||||
}
|
||||
|
||||
// IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json.
|
||||
// Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names.
|
||||
await this.normalizeTeamConfigForLaunch(request.teamName, configRaw);
|
||||
|
|
@ -827,35 +865,40 @@ export class TeamProvisioningService {
|
|||
'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.'
|
||||
);
|
||||
}
|
||||
try {
|
||||
child = spawn(
|
||||
claudePath,
|
||||
[
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--setting-sources',
|
||||
'user,project,local',
|
||||
'--disallowedTools',
|
||||
'TeamDelete,TodoWrite',
|
||||
],
|
||||
{
|
||||
cwd: request.cwd,
|
||||
env: {
|
||||
...shellEnv,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
const launchArgs = [
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--setting-sources',
|
||||
'user,project,local',
|
||||
'--disallowedTools',
|
||||
'TeamDelete,TodoWrite',
|
||||
];
|
||||
if (previousSessionId) {
|
||||
launchArgs.push('--resume', previousSessionId);
|
||||
logger.info(
|
||||
`[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
child = spawn(claudePath, launchArgs, {
|
||||
cwd: request.cwd,
|
||||
env: {
|
||||
...shellEnv,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
this.runs.delete(runId);
|
||||
this.activeByTeam.delete(request.teamName);
|
||||
throw error;
|
||||
}
|
||||
|
||||
updateProgress(run, 'spawning', 'Starting Claude CLI process for team launch', {
|
||||
const resumeHint = previousSessionId ? ' (resuming previous session)' : '';
|
||||
updateProgress(run, 'spawning', `Starting Claude CLI process for team launch${resumeHint}`, {
|
||||
pid: child.pid ?? undefined,
|
||||
});
|
||||
run.onProgress(run.progress);
|
||||
|
|
@ -918,8 +961,11 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
|
||||
// Filesystem monitor — config already exists, start from 'waiting_members'
|
||||
this.startFilesystemMonitor(run, syntheticRequest);
|
||||
// For launch, skip the filesystem monitor — files (config, inboxes, tasks)
|
||||
// already exist from the previous run and would trigger immediate false
|
||||
// completion on the first poll. Rely on stream-json result.success instead.
|
||||
updateProgress(run, 'monitoring', 'CLI running — reconnecting with teammates');
|
||||
run.onProgress(run.progress);
|
||||
|
||||
run.timeoutHandle = setTimeout(() => {
|
||||
if (!run.processKilled && !run.provisioningComplete) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { format, isThisYear, isToday, isYesterday } from 'date-fns';
|
||||
import { CheckCircle2, Circle, Loader2 } from 'lucide-react';
|
||||
|
|
@ -28,6 +29,7 @@ interface SidebarTaskItemProps {
|
|||
|
||||
export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Element => {
|
||||
const openTeamTab = useStore((s) => s.openTeamTab);
|
||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
||||
const cfg = statusConfig[task.status] ?? statusConfig.pending;
|
||||
const StatusIcon = cfg.icon;
|
||||
const dateLabel = formatTaskDate(task.createdAt);
|
||||
|
|
@ -40,6 +42,12 @@ export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Eleme
|
|||
>
|
||||
<div className="flex w-full items-center gap-1.5 overflow-hidden">
|
||||
<span className="truncate text-[13px] leading-tight text-text">{task.subject}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full bg-blue-400"
|
||||
title={`${unreadCount} unread`}
|
||||
/>
|
||||
)}
|
||||
<StatusIcon className={`size-3 shrink-0 ${cfg.color}`} />
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] leading-tight text-text-muted">
|
||||
|
|
|
|||
|
|
@ -524,6 +524,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
</div>
|
||||
<KanbanBoard
|
||||
tasks={kanbanDisplayTasks}
|
||||
teamName={teamName}
|
||||
kanbanState={data.kanbanState}
|
||||
filter={kanbanFilter}
|
||||
sessions={teamSessions}
|
||||
|
|
@ -540,7 +541,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
setRequestChangesTaskId(taskId);
|
||||
}}
|
||||
onMoveBackToDone={(taskId) => {
|
||||
void updateKanban(teamName, taskId, { op: 'remove' });
|
||||
void (async () => {
|
||||
await updateKanban(teamName, taskId, { op: 'remove' });
|
||||
await updateTaskStatus(teamName, taskId, 'completed');
|
||||
})();
|
||||
}}
|
||||
onStartTask={(taskId) => {
|
||||
void (async () => {
|
||||
|
|
@ -585,9 +589,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
defaultOpen
|
||||
action={
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
className="h-7 gap-1.5 px-2.5 text-xs font-medium text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSendDialogRecipient(undefined);
|
||||
|
|
@ -648,6 +652,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
setSelectedMember(null);
|
||||
openCreateTaskDialog('', '', name);
|
||||
}}
|
||||
onTaskClick={(task) => {
|
||||
setSelectedMember(null);
|
||||
setSelectedTask(task);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CreateTaskDialog
|
||||
|
|
@ -704,6 +712,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
teamName={teamName}
|
||||
kanbanTaskState={selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined}
|
||||
taskMap={taskMap}
|
||||
members={data?.members ?? []}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
onScrollToTask={(taskId) => {
|
||||
setSelectedTask(null);
|
||||
|
|
|
|||
27
src/renderer/components/team/UnreadCommentsBadge.tsx
Normal file
27
src/renderer/components/team/UnreadCommentsBadge.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
interface UnreadCommentsBadgeProps {
|
||||
unreadCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export const UnreadCommentsBadge = ({
|
||||
unreadCount,
|
||||
totalCount,
|
||||
}: UnreadCommentsBadgeProps): React.JSX.Element | null => {
|
||||
if (totalCount === 0) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 rounded-full px-1.5 py-0 text-[10px] font-medium ${
|
||||
unreadCount > 0
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-[var(--color-surface-raised)] text-[var(--color-text-muted)]'
|
||||
}`}
|
||||
title={unreadCount > 0 ? `${unreadCount} unread` : 'All read'}
|
||||
>
|
||||
<MessageSquare size={10} />
|
||||
{totalCount}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { MessageSquare, Send } from 'lucide-react';
|
||||
|
||||
import type { TaskComment } from '@shared/types';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
|
||||
|
||||
const MAX_COMMENT_LENGTH = 2000;
|
||||
|
||||
|
|
@ -13,20 +18,33 @@ interface TaskCommentsSectionProps {
|
|||
teamName: string;
|
||||
taskId: string;
|
||||
comments: TaskComment[];
|
||||
members: ResolvedTeamMember[];
|
||||
}
|
||||
|
||||
export const TaskCommentsSection = ({
|
||||
teamName,
|
||||
taskId,
|
||||
comments,
|
||||
members,
|
||||
}: TaskCommentsSectionProps): React.JSX.Element => {
|
||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||
const addingComment = useStore((s) => s.addingComment);
|
||||
const commentsRef = useMarkCommentsRead(teamName, taskId, comments);
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||
|
||||
const trimmed = text.trim();
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members.map((m) => ({
|
||||
id: m.name,
|
||||
name: m.name,
|
||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||
color: m.color,
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
|
||||
const trimmed = draft.value.trim();
|
||||
const remaining = MAX_COMMENT_LENGTH - trimmed.length;
|
||||
const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_COMMENT_LENGTH && !addingComment;
|
||||
|
||||
|
|
@ -34,24 +52,14 @@ export const TaskCommentsSection = ({
|
|||
if (!canSubmit) return;
|
||||
try {
|
||||
await addTaskComment(teamName, taskId, trimmed);
|
||||
setText('');
|
||||
draft.clearDraft();
|
||||
} catch {
|
||||
// Error is stored in addCommentError via store
|
||||
}
|
||||
}, [canSubmit, addTaskComment, teamName, taskId, trimmed]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
}, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={commentsRef}>
|
||||
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
|
||||
<MessageSquare size={12} />
|
||||
Comments
|
||||
|
|
@ -70,7 +78,16 @@ export const TaskCommentsSection = ({
|
|||
className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
<span className="font-medium text-[var(--color-text-secondary)]">
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
color:
|
||||
comment.author === 'user'
|
||||
? 'var(--color-text-secondary)'
|
||||
: (members.find((m) => m.name === comment.author)?.color ??
|
||||
'var(--color-text-secondary)'),
|
||||
}}
|
||||
>
|
||||
{comment.author}
|
||||
</span>
|
||||
<span>{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}</span>
|
||||
|
|
@ -84,25 +101,32 @@ export const TaskCommentsSection = ({
|
|||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2 text-xs text-[var(--color-text)] placeholder:text-zinc-500 focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
||||
<MentionableTextarea
|
||||
id={`task-comment-${taskId}`}
|
||||
placeholder="Add a comment... (Cmd+Enter to send)"
|
||||
rows={3}
|
||||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={addingComment}
|
||||
footerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
{remaining < 200 ? (
|
||||
<span
|
||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||
>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
) : null}
|
||||
{draft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-[10px] ${
|
||||
remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'
|
||||
}`}
|
||||
>
|
||||
{remaining < 200 ? `${remaining} chars remaining` : ''}
|
||||
</span>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md bg-blue-600 px-2.5 py-1 text-[11px] font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { ArrowLeftFromLine, ArrowRightFromLine, Clock, FileText, User } from 'lu
|
|||
|
||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||
|
||||
import type { KanbanTaskState, TeamTask } from '@shared/types';
|
||||
import type { KanbanTaskState, ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||
|
||||
interface TaskDetailDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -25,6 +25,7 @@ interface TaskDetailDialogProps {
|
|||
teamName: string;
|
||||
kanbanTaskState?: KanbanTaskState;
|
||||
taskMap: Map<string, TeamTask>;
|
||||
members: ResolvedTeamMember[];
|
||||
onClose: () => void;
|
||||
onScrollToTask?: (taskId: string) => void;
|
||||
}
|
||||
|
|
@ -35,6 +36,7 @@ export const TaskDetailDialog = ({
|
|||
teamName,
|
||||
kanbanTaskState,
|
||||
taskMap,
|
||||
members,
|
||||
onClose,
|
||||
onScrollToTask,
|
||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||
|
|
@ -65,7 +67,7 @@ export const TaskDetailDialog = ({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogContent className="max-h-[85vh] min-w-0 overflow-y-auto overflow-x-hidden sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||
|
|
@ -193,13 +195,14 @@ export const TaskDetailDialog = ({
|
|||
teamName={teamName}
|
||||
taskId={currentTask.id}
|
||||
comments={currentTask.comments ?? []}
|
||||
members={members}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-[var(--color-border)]" />
|
||||
|
||||
{/* Session Logs */}
|
||||
<div>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<h4 className="mb-2 text-xs font-medium text-[var(--color-text-muted)]">
|
||||
Execution Logs
|
||||
</h4>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '
|
|||
|
||||
interface KanbanBoardProps {
|
||||
tasks: TeamTask[];
|
||||
teamName: string;
|
||||
kanbanState: KanbanState;
|
||||
filter: KanbanFilterState;
|
||||
sessions: Session[];
|
||||
|
|
@ -60,6 +61,7 @@ function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId
|
|||
|
||||
export const KanbanBoard = ({
|
||||
tasks,
|
||||
teamName,
|
||||
kanbanState,
|
||||
filter,
|
||||
sessions,
|
||||
|
|
@ -106,6 +108,7 @@ export const KanbanBoard = ({
|
|||
<KanbanTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
teamName={teamName}
|
||||
columnId={columnId}
|
||||
kanbanTaskState={kanbanState.tasks[task.id]}
|
||||
hasReviewers={kanbanState.reviewers.length > 0}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play } from 'lucide-react';
|
||||
|
||||
import { ReviewBadge } from './ReviewBadge';
|
||||
|
|
@ -8,6 +10,7 @@ import type { KanbanColumnId, KanbanTaskState, TeamTask } from '@shared/types';
|
|||
|
||||
interface KanbanTaskCardProps {
|
||||
task: TeamTask;
|
||||
teamName: string;
|
||||
columnId: KanbanColumnId;
|
||||
kanbanTaskState?: KanbanTaskState;
|
||||
hasReviewers: boolean;
|
||||
|
|
@ -57,6 +60,7 @@ const DependencyBadge = ({
|
|||
|
||||
export const KanbanTaskCard = ({
|
||||
task,
|
||||
teamName,
|
||||
columnId,
|
||||
kanbanTaskState,
|
||||
hasReviewers,
|
||||
|
|
@ -70,6 +74,7 @@ export const KanbanTaskCard = ({
|
|||
onScrollToTask,
|
||||
onTaskClick,
|
||||
}: KanbanTaskCardProps): React.JSX.Element => {
|
||||
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
|
||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||
const hasBlockedBy = blockedByIds.length > 0;
|
||||
|
|
@ -95,9 +100,15 @@ export const KanbanTaskCard = ({
|
|||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<Badge variant="secondary" className="mb-1 px-1.5 py-0 text-[10px] font-normal">
|
||||
#{task.id}
|
||||
</Badge>
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||
#{task.id}
|
||||
</Badge>
|
||||
<UnreadCommentsBadge
|
||||
unreadCount={unreadCount}
|
||||
totalCount={task.comments?.length ?? 0}
|
||||
/>
|
||||
</div>
|
||||
<h5 className="text-sm font-medium text-[var(--color-text)]">{task.subject}</h5>
|
||||
</div>
|
||||
{columnId === 'review' ? <ReviewBadge status={kanbanTaskState?.reviewStatus} /> : null}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ interface MemberDetailDialogProps {
|
|||
onClose: () => void;
|
||||
onSendMessage: () => void;
|
||||
onAssignTask: () => void;
|
||||
onTaskClick: (task: TeamTask) => void;
|
||||
}
|
||||
|
||||
export const MemberDetailDialog = ({
|
||||
|
|
@ -34,6 +35,7 @@ export const MemberDetailDialog = ({
|
|||
onClose,
|
||||
onSendMessage,
|
||||
onAssignTask,
|
||||
onTaskClick,
|
||||
}: MemberDetailDialogProps): React.JSX.Element | null => {
|
||||
const memberTasks = useMemo(
|
||||
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
||||
|
|
@ -100,7 +102,7 @@ export const MemberDetailDialog = ({
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tasks">
|
||||
<MemberTasksTab tasks={memberTasks} />
|
||||
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
|
||||
</TabsContent>
|
||||
<TabsContent value="messages">
|
||||
<MemberMessagesTab messages={memberMessages} />
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-[400px] space-y-1.5 overflow-y-auto pr-1">
|
||||
<div className="max-h-[400px] min-w-0 space-y-1.5 overflow-y-auto overflow-x-hidden pr-1">
|
||||
{logs.map((log) => (
|
||||
<LogCard
|
||||
key={
|
||||
|
|
@ -171,9 +171,9 @@ const LogCard = ({
|
|||
const timeAgo = formatRelativeTime(log.startTime);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div className="min-w-0 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)]"
|
||||
className="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? (
|
||||
|
|
@ -181,8 +181,10 @@ const LogCard = ({
|
|||
) : (
|
||||
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[var(--color-text)]">{log.description}</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="truncate text-[var(--color-text)]" title={log.description}>
|
||||
{log.description}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
|
|
@ -214,7 +216,7 @@ const LogCard = ({
|
|||
</div>
|
||||
)}
|
||||
{!detailLoading && detailChunks && (
|
||||
<div className="max-h-[360px] overflow-y-auto pr-1">
|
||||
<div className="max-h-[360px] min-w-0 overflow-y-auto overflow-x-hidden pr-1">
|
||||
<MemberExecutionLog
|
||||
chunks={detailChunks}
|
||||
memberName={log.kind === 'lead_session' ? (log.memberName ?? undefined) : undefined}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { TeamTask } from '@shared/types';
|
|||
|
||||
interface MemberTasksTabProps {
|
||||
tasks: TeamTask[];
|
||||
onTaskClick?: (task: TeamTask) => void;
|
||||
}
|
||||
|
||||
const STATUS_ORDER: Record<string, number> = {
|
||||
|
|
@ -15,7 +16,7 @@ const STATUS_ORDER: Record<string, number> = {
|
|||
completed: 2,
|
||||
};
|
||||
|
||||
export const MemberTasksTab = ({ tasks }: MemberTasksTabProps): React.JSX.Element => {
|
||||
export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): React.JSX.Element => {
|
||||
const visibleTasks = useMemo(
|
||||
() =>
|
||||
tasks
|
||||
|
|
@ -38,9 +39,11 @@ export const MemberTasksTab = ({ tasks }: MemberTasksTabProps): React.JSX.Elemen
|
|||
{visibleTasks.map((task) => {
|
||||
const style = TASK_STATUS_STYLES[task.status];
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={task.id}
|
||||
className="flex items-center gap-2 rounded-md px-2.5 py-2 hover:bg-[var(--color-surface-raised)]"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={() => onTaskClick?.(task)}
|
||||
>
|
||||
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px] font-normal">
|
||||
#{task.id}
|
||||
|
|
@ -53,7 +56,7 @@ export const MemberTasksTab = ({ tasks }: MemberTasksTabProps): React.JSX.Elemen
|
|||
>
|
||||
{TASK_STATUS_LABELS[task.status]}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
61
src/renderer/hooks/useMarkCommentsRead.ts
Normal file
61
src/renderer/hooks/useMarkCommentsRead.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||
|
||||
import type { TaskComment } from '@shared/types';
|
||||
|
||||
export function useMarkCommentsRead(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
comments: TaskComment[]
|
||||
): (node: HTMLElement | null) => void {
|
||||
const isVisibleRef = useRef(false);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
// Mark comments as read if section is visible
|
||||
const markIfVisible = useCallback(() => {
|
||||
if (!isVisibleRef.current || comments.length === 0) return;
|
||||
const latest = Math.max(...comments.map((c) => new Date(c.createdAt).getTime()));
|
||||
if (latest > 0) markAsRead(teamName, taskId, latest);
|
||||
}, [teamName, taskId, comments]);
|
||||
|
||||
// Re-mark when new comments arrive while section is visible
|
||||
useEffect(() => {
|
||||
markIfVisible();
|
||||
}, [markIfVisible]);
|
||||
|
||||
// IntersectionObserver ref callback
|
||||
const refCallback = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
// Cleanup previous
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
isVisibleRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisibleRef.current = entry.isIntersecting;
|
||||
if (entry.isIntersecting) markIfVisible();
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
observerRef.current.observe(node);
|
||||
},
|
||||
[markIfVisible]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
observerRef.current?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return refCallback;
|
||||
}
|
||||
14
src/renderer/hooks/useUnreadCommentCount.ts
Normal file
14
src/renderer/hooks/useUnreadCommentCount.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
|
||||
|
||||
import type { TaskComment } from '@shared/types';
|
||||
|
||||
export function useUnreadCommentCount(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
comments: TaskComment[] | undefined
|
||||
): number {
|
||||
const readState = useSyncExternalStore(subscribe, getSnapshot);
|
||||
return getUnreadCount(readState, teamName, taskId, comments ?? []);
|
||||
}
|
||||
112
src/renderer/services/commentReadStorage.ts
Normal file
112
src/renderer/services/commentReadStorage.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { get, set } from 'idb-keyval';
|
||||
|
||||
const IDB_KEY = 'comment-read-state';
|
||||
const SAVE_DEBOUNCE_MS = 300;
|
||||
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
type ReadState = Record<string, number>; // key = "teamName/taskId", value = timestamp
|
||||
|
||||
let cache: ReadState = {};
|
||||
let loaded = false;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
// --- useSyncExternalStore API ---
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
if (!loaded) void loadFromIdb();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getSnapshot(): ReadState {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
export function markAsRead(teamName: string, taskId: string, latestTimestamp: number): void {
|
||||
const key = `${teamName}/${taskId}`;
|
||||
const prev = cache[key] ?? 0;
|
||||
if (latestTimestamp <= prev) return;
|
||||
cache = { ...cache, [key]: latestTimestamp };
|
||||
notify();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
export function getUnreadCount(
|
||||
readState: ReadState,
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
comments: { createdAt: string }[]
|
||||
): number {
|
||||
if (!comments || comments.length === 0) return 0;
|
||||
const key = `${teamName}/${taskId}`;
|
||||
const lastRead = readState[key] ?? 0;
|
||||
return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length;
|
||||
}
|
||||
|
||||
// --- Internal ---
|
||||
function hasIndexedDB(): boolean {
|
||||
return typeof indexedDB !== 'undefined';
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function scheduleSave(): void {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
void saveToIdb();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function loadFromIdb(): Promise<void> {
|
||||
if (loaded) return;
|
||||
if (!hasIndexedDB()) {
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stored = await get<ReadState>(IDB_KEY);
|
||||
if (stored && typeof stored === 'object') {
|
||||
cache = { ...stored, ...cache }; // merge: in-memory wins over stale IDB
|
||||
notify();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[commentReadStorage] load failed:', e);
|
||||
}
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
async function saveToIdb(): Promise<void> {
|
||||
if (!hasIndexedDB()) return;
|
||||
try {
|
||||
await set(IDB_KEY, cache);
|
||||
} catch (e) {
|
||||
console.error('[commentReadStorage] save failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupStale(): Promise<void> {
|
||||
if (!hasIndexedDB()) return;
|
||||
try {
|
||||
const stored = await get<ReadState>(IDB_KEY);
|
||||
if (!stored) return;
|
||||
const now = Date.now();
|
||||
const cleaned: ReadState = {};
|
||||
let changed = false;
|
||||
for (const [k, v] of Object.entries(stored)) {
|
||||
if (now - v < STALE_THRESHOLD_MS) {
|
||||
cleaned[k] = v;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) await set(IDB_KEY, cleaned);
|
||||
} catch (e) {
|
||||
console.error('[commentReadStorage] cleanup failed:', e);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { createConfigSlice } from './slices/configSlice';
|
||||
|
|
@ -62,6 +63,7 @@ export const useStore = create<AppState>()((...args) => ({
|
|||
* Call this once when the app starts (e.g., in App.tsx useEffect).
|
||||
*/
|
||||
export function initializeNotificationListeners(): () => void {
|
||||
void cleanupCommentReadState();
|
||||
const cleanupFns: (() => void)[] = [];
|
||||
useStore.getState().subscribeProvisioningProgress();
|
||||
cleanupFns.push(() => {
|
||||
|
|
|
|||
|
|
@ -404,7 +404,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
},
|
||||
|
||||
createTeam: async (request: TeamCreateRequest) => {
|
||||
set({ provisioningError: null });
|
||||
// Clear stale provisioning runs for this team so the banner starts fresh
|
||||
set((state) => {
|
||||
const cleaned = { ...state.provisioningRuns };
|
||||
for (const [runId, run] of Object.entries(cleaned)) {
|
||||
if (run.teamName === request.teamName) {
|
||||
delete cleaned[runId];
|
||||
}
|
||||
}
|
||||
return { provisioningError: null, provisioningRuns: cleaned };
|
||||
});
|
||||
try {
|
||||
if (typeof api.teams.createTeam !== 'function') {
|
||||
throw new Error(
|
||||
|
|
@ -432,7 +441,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
},
|
||||
|
||||
launchTeam: async (request: TeamLaunchRequest) => {
|
||||
set({ provisioningError: null });
|
||||
// Clear stale provisioning runs for this team so the banner starts fresh
|
||||
set((state) => {
|
||||
const cleaned = { ...state.provisioningRuns };
|
||||
for (const [runId, run] of Object.entries(cleaned)) {
|
||||
if (run.teamName === request.teamName) {
|
||||
delete cleaned[runId];
|
||||
}
|
||||
}
|
||||
return { provisioningError: null, provisioningRuns: cleaned };
|
||||
});
|
||||
try {
|
||||
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
|
||||
set({
|
||||
|
|
|
|||
Loading…
Reference in a new issue