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:
iliya 2026-02-22 21:29:09 +02:00 committed by Илия
parent 5f2bd36aca
commit da25703935
18 changed files with 483 additions and 83 deletions

View file

@ -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));
}

View file

@ -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}`,
});
}

View file

@ -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) {

View file

@ -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">

View file

@ -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);

View 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>
);
};

View file

@ -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"

View file

@ -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>

View file

@ -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}

View file

@ -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}

View file

@ -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} />

View file

@ -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}

View file

@ -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>

View 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;
}

View 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 ?? []);
}

View 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);
}
}

View file

@ -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(() => {

View file

@ -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({