diff --git a/docs/research/local-image-storage.md b/docs/research/local-image-storage.md index b7c8e5c3..85997c47 100644 --- a/docs/research/local-image-storage.md +++ b/docs/research/local-image-storage.md @@ -8,7 +8,7 @@ This document evaluates approaches for storing images/attachments locally in our ## Approach 1: Filesystem + SQLite Metadata (Recommended) -**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app://attachments/...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table. +**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app-img://...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table. ### Pros - Best I/O performance — direct filesystem reads, no serialization overhead. diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 29206169..2133abdb 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -937,6 +937,13 @@ async function handleSendMessage( } if (stdinSent) { + const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + })); + // Persistence is best-effort — stdin already delivered the message let result: SendMessageResult; try { @@ -944,20 +951,14 @@ async function handleSendMessage( tn, leadName, payload.text!, - payload.summary + payload.summary, + attachmentMeta ); } catch (persistError) { logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`); result = { deliveredToInbox: false, messageId: `stdin-${Date.now()}` }; } - const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({ - id: a.id, - filename: a.filename, - mimeType: a.mimeType, - size: a.size, - })); - // Save attachment binary data to disk (best-effort) if (validatedAttachments?.length && result.messageId) { void attachmentStore diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index cd4d36d7..a6a93eb1 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -10,6 +10,7 @@ import type { AttachmentFileData, AttachmentPayload } from '@shared/types'; const logger = createLogger('Service:TeamAttachmentStore'); const ATTACHMENTS_DIR = 'attachments'; +const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap export class TeamAttachmentStore { private assertSafePathSegment(label: string, value: string): void { @@ -58,6 +59,10 @@ export class TeamAttachmentStore { let raw: string; try { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile() || stat.size > MAX_ATTACHMENTS_FILE_BYTES) { + return []; + } raw = await fs.promises.readFile(filePath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 2f9cc319..7fa82f5d 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -32,6 +32,7 @@ import { TeamTaskWriter } from './TeamTaskWriter'; import type { AddMemberRequest, + AttachmentMeta, CreateTaskRequest, GlobalTask, InboxMessage, @@ -631,6 +632,7 @@ export class TeamDataService { const newMember: TeamMember = { name: request.name, role: request.role?.trim() || undefined, + workflow: request.workflow?.trim() || undefined, agentType: 'general-purpose', color: getMemberColor(members.filter((m) => !m.removedAt).length), joinedAt: Date.now(), @@ -1001,6 +1003,8 @@ export class TeamDataService { ]); const task = tasks.find((t) => t.id === taskId); const leadName = this.resolveLeadNameFromConfig(config); + const owner = task?.owner?.trim() || null; + const normalizedOwner = owner?.toLowerCase() ?? null; // Auto-clear needsClarification: "user" on UI comment // UI comments always have author "user" (TeamTaskWriter default) @@ -1008,7 +1012,7 @@ export class TeamDataService { await this.taskWriter.setNeedsClarification(teamName, taskId, null); } - if (task?.owner && !this.isLeadOwner(task.owner, leadName)) { + if (task && owner && !this.isLeadOwner(owner, leadName)) { // Notify non-lead task owner via inbox (lead → member message) const parts = [ `Comment on task #${taskId} "${task.subject}":\n\n${text}`, @@ -1018,12 +1022,12 @@ export class TeamDataService { AGENT_BLOCK_CLOSE, ]; await this.sendMessage(teamName, { - member: task.owner, + member: owner, from: leadName, text: parts.join('\n'), summary: `Comment on #${taskId}`, }); - } else if (task?.owner && this.isLeadOwner(task.owner, leadName)) { + } else if (task && owner && this.isLeadOwner(owner, leadName)) { // Notify lead about user's comment on their own task. // Write to lead's inbox — relay delivers to stdin when process is alive. const parts = [ @@ -1076,7 +1080,8 @@ export class TeamDataService { teamName: string, leadName: string, text: string, - summary?: string + summary?: string, + attachments?: AttachmentMeta[] ): Promise { const messageId = randomUUID(); const msg: InboxMessage = { @@ -1088,6 +1093,7 @@ export class TeamDataService { summary, messageId, source: 'user_sent', + attachments: attachments?.length ? attachments : undefined, }; await this.sentMessagesStore.appendMessage(teamName, msg); return { deliveredToInbox: false, deliveredViaStdin: true, messageId }; @@ -1217,9 +1223,23 @@ export class TeamDataService { // Dedup broadcasts: same sender + same text → process only once const processedTexts = new Set(); + function isAutomatedCommentNotification(msg: InboxMessage): boolean { + const summary = typeof msg.summary === 'string' ? msg.summary : ''; + if (!/^Comment on #\d+/.test(summary)) return false; + const text = typeof msg.text === 'string' ? msg.text : ''; + if (!text) return false; + // These are system-generated inbox messages that already correspond to a real task comment. + // Syncing them into task.comments causes an immediate "duplicate" (lead echo) in the UI. + if (text.includes('Reply to this comment using:')) return true; + if (text.startsWith('Comment on task #')) return true; + if (text.startsWith('New comment from user on your task #')) return true; + return false; + } + for (const msg of messages) { if (!msg.messageId || !msg.summary || msg.from === 'user') continue; if (msg.source === 'lead_session' || msg.source === 'lead_process') continue; + if (isAutomatedCommentNotification(msg)) continue; const textKey = `${msg.from}\0${msg.text}`; if (processedTexts.has(textKey)) continue; diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index cfb606be..fd04a317 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -13,6 +13,13 @@ export class TeamInboxWriter { const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`); const messageId = randomUUID(); + const attachmentMeta = request.attachments?.map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + })); + const payload: InboxMessage = { from: request.from ?? 'user', to: request.member, @@ -21,6 +28,7 @@ export class TeamInboxWriter { read: false, summary: request.summary, messageId, + attachments: attachmentMeta?.length ? attachmentMeta : undefined, }; await withInboxLock(inboxPath, async () => { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a6a44fe8..8f7a1fe6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1028,7 +1028,8 @@ export class TeamProvisioningService { const run = this.runs.get(runId); if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) return null; const { currentTokens, contextWindow } = run.leadContextUsage; - const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percent = Math.max(0, Math.min(100, percentRaw)); return { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() }; } @@ -1055,7 +1056,8 @@ export class TeamProvisioningService { } run.leadContextUsage.lastEmittedAt = now; const { currentTokens, contextWindow } = run.leadContextUsage; - const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percent = Math.max(0, Math.min(100, percentRaw)); const payload: LeadContextUsage = { currentTokens, contextWindow, @@ -2584,11 +2586,18 @@ export class TeamProvisioningService { typeof modelData.contextWindow === 'number' && modelData.contextWindow > 0 ) { - if (run.leadContextUsage) { + if (!run.leadContextUsage) { + run.leadContextUsage = { + currentTokens: 0, + contextWindow: modelData.contextWindow, + lastUsageMessageId: null, + lastEmittedAt: 0, + }; + } else { run.leadContextUsage.contextWindow = modelData.contextWindow; run.leadContextUsage.lastEmittedAt = 0; // force re-emit - this.emitLeadContextUsage(run); } + this.emitLeadContextUsage(run); break; } } @@ -2610,9 +2619,18 @@ export class TeamProvisioningService { ? resultUsage.cache_read_input_tokens : 0; const total = inp + cc + cr; - if (total > 0 && run.leadContextUsage) { - run.leadContextUsage.currentTokens = total; - run.leadContextUsage.lastEmittedAt = 0; + if (total > 0) { + if (!run.leadContextUsage) { + run.leadContextUsage = { + currentTokens: total, + contextWindow: 0, + lastUsageMessageId: null, + lastEmittedAt: 0, + }; + } else { + run.leadContextUsage.currentTokens = total; + run.leadContextUsage.lastEmittedAt = 0; + } this.emitLeadContextUsage(run); } } diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts index 9661df70..0d71dcd4 100644 --- a/src/main/services/team/TeamTaskAttachmentStore.ts +++ b/src/main/services/team/TeamTaskAttachmentStore.ts @@ -21,6 +21,9 @@ export class TeamTaskAttachmentStore { private assertSafePathSegment(label: string, value: string): void { if ( value.length === 0 || + value.trim().length === 0 || + value === '.' || + value === '..' || value.includes('/') || value.includes('\\') || value.includes('..') || diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 7b7246f6..e6b8912c 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -172,8 +172,12 @@ export class TeamTaskReader { : 'pending', workIntervals, statusHistory, - blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined, - blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined, + blocks: Array.isArray(parsed.blocks) + ? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined, + blockedBy: Array.isArray(parsed.blockedBy) + ? (parsed.blockedBy as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined, related: Array.isArray(parsed.related) ? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string') : undefined, @@ -197,19 +201,30 @@ export class TeamTaskReader { ? c.type : ('regular' as const), attachments: Array.isArray(c.attachments) - ? (c.attachments as unknown[]).filter( - (a): a is TaskAttachmentMeta => - Boolean(a) && - typeof a === 'object' && - typeof (a as Record).id === 'string' && - typeof (a as Record).filename === 'string' && - typeof (a as Record).mimeType === 'string' && - VALID_ATTACHMENT_MIME_TYPES.has( - (a as Record).mimeType as string - ) && - typeof (a as Record).size === 'number' && - typeof (a as Record).addedAt === 'string' - ) + ? (() => { + const filtered = (c.attachments as unknown[]) + .filter( + (a): a is TaskAttachmentMeta => + Boolean(a) && + typeof a === 'object' && + typeof (a as Record).id === 'string' && + typeof (a as Record).filename === 'string' && + typeof (a as Record).mimeType === 'string' && + VALID_ATTACHMENT_MIME_TYPES.has( + (a as Record).mimeType as string + ) && + typeof (a as Record).size === 'number' && + typeof (a as Record).addedAt === 'string' + ) + .map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + addedAt: a.addedAt, + })); + return filtered.length > 0 ? filtered : undefined; + })() : undefined, })) : undefined, @@ -256,6 +271,9 @@ export class TeamTaskReader { } } + // Sort by numeric ID so kanban default order is deterministic (#1, #2, ..., #10, #11) + tasks.sort((a, b) => Number(a.id) - Number(b.id)); + return tasks; } diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index f7670baf..bc681d64 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import ReactMarkdown, { type Components } from 'react-markdown'; +import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'; import { api } from '@renderer/api'; import { CopyButton } from '@renderer/components/common/CopyButton'; +import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { CODE_BG, @@ -60,6 +61,15 @@ interface MarkdownViewerProps { // Helpers // ============================================================================= +/** + * Custom URL transform that preserves task:// and mention:// protocols. + * react-markdown v10 strips non-standard protocols by default. + */ +function allowCustomProtocols(url: string): string { + if (url.startsWith('task://') || url.startsWith('mention://')) return url; + return defaultUrlTransform(url); +} + /** Check if a URL is relative (not absolute, not data, not mailto, not hash) */ function isRelativeUrl(url: string): boolean { return ( @@ -200,13 +210,18 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon ), // Links — inline element, no hl(); parent block element's hl() descends here - // task:// links are handled by ancestor onClickCapture handlers (e.g. ActivityItem) + // task:// links render with TaskTooltip + are clickable via ancestor onClickCapture // mention:// links render as colored inline badges a: ({ href, children }) => { if (href?.startsWith('mention://')) { const path = href.slice('mention://'.length); const slashIdx = path.indexOf('/'); - const color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : ''; + let color = ''; + try { + color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : ''; + } catch { + // malformed percent-encoding — use empty color + } const colorSet = getTeamColorSet(color); const bg = colorSet.badge; return ( @@ -223,6 +238,21 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon ); } + if (href?.startsWith('task://')) { + const taskId = href.slice('task://'.length); + return ( + + e.preventDefault()} + > + {children} + + + ); + } return ( { e.preventDefault(); - if (href && !href.startsWith('task://')) { + if (href) { void api.openExternal(href); } }} @@ -629,6 +659,7 @@ export const MarkdownViewer: React.FC = ({ remarkPlugins={[remarkGfm]} rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS} components={components} + urlTransform={allowCustomProtocols} > {content} diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx index 8b8016cd..d7206e22 100644 --- a/src/renderer/components/settings/SettingsView.tsx +++ b/src/renderer/components/settings/SettingsView.tsx @@ -3,7 +3,7 @@ * Provides UI for managing notifications, display settings, and advanced options. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useStore } from '@renderer/store'; import { Loader2 } from 'lucide-react'; @@ -23,15 +23,13 @@ export const SettingsView = (): React.JSX.Element | null => { const pendingSettingsSection = useStore((s) => s.pendingSettingsSection); const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection); - // Consume pending section during render (React-recommended pattern for adjusting state on prop change) - const [prevPending, setPrevPending] = useState(null); - if (pendingSettingsSection !== prevPending) { - setPrevPending(pendingSettingsSection); + // Consume pending section (avoid setState during render) + useEffect(() => { if (pendingSettingsSection) { setActiveSection(pendingSettingsSection as SettingsSection); clearPendingSettingsSection(); } - } + }, [pendingSettingsSection, clearPendingSettingsSection]); const { config, diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 06afc588..620108fc 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -150,7 +150,7 @@ type VirtualItem = * Mismatch causes items to overlap! */ const HEADER_HEIGHT = 28; -const SESSION_HEIGHT = 48; // Must match h-[48px] in SessionItem.tsx +const SESSION_HEIGHT = 58; // Must match h-[58px] in SessionItem.tsx const LOADER_HEIGHT = 36; const OVERSCAN = 5; diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index e54479f3..9695f22e 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -240,13 +240,13 @@ export const SessionItem = ({ } }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - // Height must match SESSION_HEIGHT (48px) in DateGroupedSessions.tsx for virtual scroll + // Height must match SESSION_HEIGHT (58px) in DateGroupedSessions.tsx for virtual scroll return ( <> + )} m.name)} + existingMembers={data.members} + projectPath={data.config.projectPath} adding={addingMemberLoading} onClose={() => setAddMemberDialogOpen(false)} - onAdd={(name, role) => { + onAdd={(name, role, workflow) => { setAddingMemberLoading(true); void (async () => { try { - await addMember(teamName, { name, role }); + await addMember(teamName, { name, role, workflow }); setAddMemberDialogOpen(false); } catch { // error shown via store diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index bf03e0a8..14416cd2 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -3,6 +3,8 @@ import { useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; +import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { CARD_BG, @@ -175,24 +177,25 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map` in plain text as clickable inline elements. */ +/** Render `#` in plain text as clickable inline elements with TaskTooltip. */ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { return text.split(/(#\d+)/g).map((part, i) => { const match = /^#(\d+)$/.exec(part); if (!match) return {part}; const taskId = match[1]; return ( - + + + ); }); } @@ -233,25 +236,30 @@ export const ActivityItem = ({ const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; const [isExpanded, setIsExpanded] = useState(!systemLabel); - // Strip agent-only blocks from displayed text + linkify task IDs + @mentions - const displayText = useMemo(() => { + // Strip agent-only blocks + normalize escape sequences (before linkification) + const strippedText = useMemo(() => { if (structured) return null; const stripped = stripAgentBlocks(message.text).trim(); if (!stripped) return null; // All content was agent-only blocks → show summary instead // Normalize literal \n from CLI tools (teamctl.js) to real newlines - const normalized = stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); - let result = normalized; - if (onTaskIdClick) result = linkifyTaskIdsInMarkdown(result); + return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); + }, [structured, message.text]); + + // Parse reply BEFORE linkification — linkifyMentionsInMarkdown transforms @name + // into markdown links which breaks the reply regex matcher + const parsedReply = useMemo( + () => (strippedText ? parseMessageReply(strippedText) : null), + [strippedText] + ); + + // Linkify task IDs (always, for TaskTooltip) + @mentions for display + const displayText = useMemo(() => { + if (!strippedText) return null; + let result = linkifyTaskIdsInMarkdown(strippedText); if (memberColorMap && memberColorMap.size > 0) result = linkifyMentionsInMarkdown(result, memberColorMap); return result; - }, [structured, message.text, onTaskIdClick, memberColorMap]); - - // Check if this is a reply message - const parsedReply = useMemo( - () => (displayText ? parseMessageReply(displayText) : null), - [displayText] - ); + }, [strippedText, memberColorMap]); const rawSummary = message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; @@ -276,11 +284,13 @@ export const ActivityItem = ({ }; const isHeaderClickable = Boolean(systemLabel); + const isUserSent = message.source === 'user_sent'; return (
) : parsedReply ? ( - + ) : displayText ? ( - { - const link = (e.target as HTMLElement).closest( - 'a[href^="task://"]' - ); - if (link) { - e.preventDefault(); - e.stopPropagation(); - const taskId = link.getAttribute('href')?.replace('task://', ''); - if (taskId) onTaskIdClick(taskId); + + { + const link = (e.target as HTMLElement).closest( + 'a[href^="task://"]' + ); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const taskId = link.getAttribute('href')?.replace('task://', ''); + if (taskId) onTaskIdClick(taskId); + } } - } - : undefined - } - > - - + : undefined + } + > + + + ) : summaryText ? (

{summaryText} diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx index 29efede0..6949671b 100644 --- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx +++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx @@ -1,29 +1,71 @@ +import { useMemo, useState } from 'react'; + import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { MemberBadge } from '@renderer/components/team/MemberBadge'; import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting'; interface ReplyQuoteBlockProps { reply: ParsedMessageReply; + /** Color name for the quoted agent (resolved from memberColorMap). */ + memberColor?: string; /** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */ bodyMaxHeight?: string; } +/** Threshold (characters) above which the "more/less" toggle is shown. */ +const LONG_QUOTE_THRESHOLD = 200; + export const ReplyQuoteBlock = ({ reply, + memberColor, bodyMaxHeight = 'max-h-56', -}: ReplyQuoteBlockProps): React.JSX.Element => ( -

-
- - @{reply.agentName} - -
- +}: ReplyQuoteBlockProps): React.JSX.Element => { + const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD; + const [expanded, setExpanded] = useState(false); + + const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]'; + + return ( +
+ {/* Quote block — styled like SendMessageDialog */} +
+ {/* Decorative quotation mark */} + + “ + + + {/* "Replying to" + MemberBadge */} +
+ Replying to + +
+ + {/* Quote text */} +
+ +
+ + {/* More/less toggle */} + {isLong ? ( + + ) : null}
+ + {/* Reply text */} +
- -
-); + ); +}; diff --git a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx index 0a97c7c2..75c7cd4a 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx @@ -1,5 +1,5 @@ import { formatFileSize } from '@renderer/utils/attachmentUtils'; -import { X } from 'lucide-react'; +import { Ban, X } from 'lucide-react'; import { AttachmentThumbnail } from './AttachmentThumbnail'; @@ -8,16 +8,23 @@ import type { AttachmentPayload } from '@shared/types'; interface AttachmentPreviewItemProps { attachment: AttachmentPayload; onRemove: (id: string) => void; + disabled?: boolean; } export const AttachmentPreviewItem = ({ attachment, onRemove, + disabled, }: AttachmentPreviewItemProps): React.JSX.Element => { const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`; return (
+ {disabled ? ( +
+ +
+ ) : null}
diff --git a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx index d2571ed1..164afb47 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx @@ -8,12 +8,18 @@ interface AttachmentPreviewListProps { attachments: AttachmentPayload[]; onRemove: (id: string) => void; error?: string | null; + /** When true, previews are overlaid with a disabled indicator (recipient doesn't support attachments). */ + disabled?: boolean; + /** Hint text shown when disabled and attachments are present. */ + disabledHint?: string; } export const AttachmentPreviewList = ({ attachments, onRemove, error, + disabled, + disabledHint, }: AttachmentPreviewListProps): React.JSX.Element | null => { if (attachments.length === 0 && !error) return null; @@ -22,10 +28,21 @@ export const AttachmentPreviewList = ({ {attachments.length > 0 ? (
{attachments.map((att) => ( - + ))}
) : null} + {disabled && disabledHint && attachments.length > 0 ? ( +
+ +

{disabledHint}

+
+ ) : null} {error ? (
diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 23fa6ae2..1549cd70 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { @@ -11,16 +11,16 @@ import { } from '@renderer/components/ui/dialog'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@renderer/components/ui/select'; -import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; +import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; +import { RoleSelect } from '@renderer/components/team/RoleSelect'; +import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles'; +import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { Loader2 } from 'lucide-react'; +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { ResolvedTeamMember } from '@shared/types'; + const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/; interface AddMemberDialogProps { @@ -28,8 +28,12 @@ interface AddMemberDialogProps { teamName: string; existingNames: string[]; onClose: () => void; - onAdd: (name: string, role?: string) => void; + onAdd: (name: string, role?: string, workflow?: string) => void; adding?: boolean; + /** Project path for @file mentions in workflow field. */ + projectPath?: string | null; + /** Existing team members for @mention suggestions. */ + existingMembers?: ResolvedTeamMember[]; } export const AddMemberDialog = ({ @@ -39,12 +43,36 @@ export const AddMemberDialog = ({ onClose, onAdd, adding, + projectPath, + existingMembers = [], }: AddMemberDialogProps): React.JSX.Element => { const [name, setName] = useState(''); const [roleSelect, setRoleSelect] = useState(NO_ROLE); const [customRole, setCustomRole] = useState(''); const [error, setError] = useState(null); + const draftKey = `addMember:${teamName}:workflow`; + const workflowDraft = useDraftPersistence({ + key: draftKey, + enabled: open, + }); + + // Pre-warm file list cache for @file mentions + useFileListCacheWarmer(open && projectPath ? projectPath : null); + + const mentionSuggestions = useMemo( + () => + existingMembers + .filter((m) => !m.removedAt) + .map((m) => ({ + id: m.name, + name: m.name, + subtitle: m.role ?? undefined, + color: m.color, + })), + [existingMembers] + ); + const effectiveRole = roleSelect === CUSTOM_ROLE ? customRole.trim() @@ -72,7 +100,9 @@ export const AddMemberDialog = ({ return; } setError(null); - onAdd(name.trim().toLowerCase(), effectiveRole); + const wf = workflowDraft.value.trim() || undefined; + onAdd(name.trim().toLowerCase(), effectiveRole, wf); + workflowDraft.clearDraft(); }; const handleOpenChange = (nextOpen: boolean): void => { @@ -80,11 +110,20 @@ export const AddMemberDialog = ({ setName(''); setRoleSelect(NO_ROLE); setCustomRole(''); + workflowDraft.setValue(''); + workflowDraft.clearDraft(); setError(null); onClose(); } }; + const handleWorkflowChange = useCallback( + (v: string) => { + workflowDraft.setValue(v); + }, + [workflowDraft] + ); + return ( @@ -113,27 +152,31 @@ export const AddMemberDialog = ({
- - {roleSelect === CUSTOM_ROLE && ( - setCustomRole(e.target.value)} - /> - )} + +
+ +
+ + Draft saved + ) : null + } + />
diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 8e5bbc09..d650bef7 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -89,28 +89,40 @@ export const CreateTaskDialog = ({ const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` }); const [blockedBySearch, setBlockedBySearch] = useState(''); const [relatedSearch, setRelatedSearch] = useState(''); - const [prevOpen, setPrevOpen] = useState(false); + const prevOpenRef = useRef(false); - if (open && !prevOpen) { - setSubject(defaultSubject); - if (defaultChip) { - const token = chipToken(defaultChip); - descriptionDraft.setValue(token + '\n'); - descChipDraft.setChips([defaultChip]); - } else if (defaultDescription) { - descriptionDraft.setValue(defaultDescription); + // Reset form when dialog opens (avoid setState during render) + useEffect(() => { + if (open && !prevOpenRef.current) { + setSubject(defaultSubject); + if (defaultChip) { + const token = chipToken(defaultChip); + descriptionDraft.setValue(token + '\n'); + descChipDraft.setChips([defaultChip]); + } else if (defaultDescription) { + descriptionDraft.setValue(defaultDescription); + } + setOwner(defaultOwner); + setBlockedBy([]); + setRelated([]); + setStartImmediately(defaultStartImmediately ?? isTeamAlive); + promptDraft.clearDraft(); + setBlockedBySearch(''); + setRelatedSearch(''); } - setOwner(defaultOwner); - setBlockedBy([]); - setRelated([]); - setStartImmediately(defaultStartImmediately ?? isTeamAlive); - promptDraft.clearDraft(); - setBlockedBySearch(''); - setRelatedSearch(''); - } - if (open !== prevOpen) { - setPrevOpen(open); - } + prevOpenRef.current = open; + }, [ + open, + defaultSubject, + defaultDescription, + defaultOwner, + defaultStartImmediately, + defaultChip, + isTeamAlive, + descriptionDraft, + descChipDraft, + promptDraft, + ]); const mentionSuggestions = useMemo( () => diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 7a662e00..4c5961bd 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -15,13 +15,7 @@ import { import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@renderer/components/ui/select'; +import { Combobox, type ComboboxOption } from '@renderer/components/ui/combobox'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useAttachments } from '@renderer/hooks/useAttachments'; @@ -33,7 +27,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { ImagePlus, X } from 'lucide-react'; +import { Check, ImagePlus, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; @@ -69,8 +63,6 @@ interface SendMessageDialogProps { onClose: () => void; } -const NO_MEMBER = '__none__'; - export const SendMessageDialog = ({ open, teamName, @@ -87,6 +79,15 @@ export const SendMessageDialog = ({ onClose, }: SendMessageDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const recipientOptions = useMemo( + () => + members.map((m) => ({ + value: m.name, + label: m.name, + description: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined, + })), + [members] + ); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const [quote, setQuote] = useState(undefined); const [quoteExpanded, setQuoteExpanded] = useState(false); @@ -94,8 +95,8 @@ export const SendMessageDialog = ({ const textDraft = useDraftPersistence({ key: 'sendMessage:text' }); const chipDraft = useChipDraftPersistence('sendMessage:chips'); const [summary, setSummary] = useState(''); - const [prevOpen, setPrevOpen] = useState(false); - const [prevResult, setPrevResult] = useState(null); + const prevOpenRef = useRef(false); + const prevResultRef = useRef(null); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); @@ -116,33 +117,36 @@ export const SendMessageDialog = ({ const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; const canAttach = isLeadRecipient && isTeamAlive && canAddMore; - // Reset form when dialog opens - if (open && !prevOpen) { - setMember(defaultRecipient ?? ''); - setSummary(''); - setQuote(quotedMessage); - setQuoteExpanded(false); - setPrevResult(lastResult); - if (defaultChip) { - const token = chipToken(defaultChip); - textDraft.setValue(token + '\n'); - chipDraft.setChips([defaultChip]); - } else if (defaultText) { - textDraft.setValue(defaultText); - } - } - if (open !== prevOpen) { - setPrevOpen(open); - } - - // Track whether auto-close is needed (setState in render phase is fine) const [pendingAutoClose, setPendingAutoClose] = useState(false); - if (open && lastResult && lastResult !== prevResult) { - setPrevResult(lastResult); - setMember(''); - setSummary(''); - setPendingAutoClose(true); - } + // Reset form on open transition (avoid setState in render) + useEffect(() => { + if (open && !prevOpenRef.current) { + setMember(defaultRecipient ?? ''); + setSummary(''); + setQuote(quotedMessage); + setQuoteExpanded(false); + prevResultRef.current = lastResult; + if (defaultChip) { + const token = chipToken(defaultChip); + textDraft.setValue(token + '\n'); + chipDraft.setChips([defaultChip]); + } else if (defaultText) { + textDraft.setValue(defaultText); + } + } + prevOpenRef.current = open; + }, [open, defaultRecipient, defaultText, defaultChip, quotedMessage, lastResult, textDraft, chipDraft]); + + // Track whether auto-close is needed (avoid setState in render) + useEffect(() => { + if (!open) return; + if (lastResult && lastResult !== prevResultRef.current) { + prevResultRef.current = lastResult; + setMember(''); + setSummary(''); + setPendingAutoClose(true); + } + }, [open, lastResult]); // Side effects (onClose mutates parent state) must run in useEffect, not render phase useEffect(() => { @@ -170,11 +174,14 @@ export const SendMessageDialog = ({ [members, colorMap] ); + const attachmentsBlocked = attachments.length > 0 && !isLeadRecipient; + const canSend = member.trim().length > 0 && textDraft.value.trim().length > 0 && summary.trim().length > 0 && - !sending; + !sending && + !attachmentsBlocked; const handleChipRemove = (chipId: string): void => { const chip = chipDraft.chips.find((c) => c.id === chipId); @@ -271,40 +278,44 @@ export const SendMessageDialog = ({
- + ) : null} + {isSelected ? ( + + ) : null} + + ); + }} + />
@@ -351,6 +362,8 @@ export const SendMessageDialog = ({ attachments={attachments} onRemove={removeAttachment} error={attachmentError} + disabled={attachmentsBlocked} + disabledHint="Image attachments are only supported when sending to team lead. Remove attachments or switch recipient." />
@@ -409,6 +422,7 @@ export const SendMessageDialog = ({ onChipRemove={handleChipRemove} projectPath={projectPath} onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])} + onModEnter={handleSubmit} minRows={4} maxRows={12} footerRight={ diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index 4d44f30f..a25757a3 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -9,7 +9,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { ImagePlus, Send, Trash2, X } from 'lucide-react'; +import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types'; @@ -86,10 +86,6 @@ export const TaskCommentInput = ({ ); continue; } - if (pendingAttachments.length >= MAX_ATTACHMENTS) { - setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); - break; - } const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; @@ -97,7 +93,10 @@ export const TaskCommentInput = ({ if (!base64) return; const id = crypto.randomUUID(); setPendingAttachments((prev) => { - if (prev.length >= MAX_ATTACHMENTS) return prev; + if (prev.length >= MAX_ATTACHMENTS) { + setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); + return prev; + } return [ ...prev, { @@ -114,7 +113,7 @@ export const TaskCommentInput = ({ reader.readAsDataURL(file); } }, - [pendingAttachments.length] + [] ); const removeAttachment = useCallback((id: string) => { @@ -256,6 +255,7 @@ export const TaskCommentInput = ({ onValueChange={draft.setValue} suggestions={mentionSuggestions} projectPath={projectPath} + onModEnter={() => void handleSubmit()} minRows={2} maxRows={8} maxLength={MAX_COMMENT_LENGTH} @@ -275,6 +275,18 @@ export const TaskCommentInput = ({ Attach image (or paste) + + + + + Voice to text + -
- - )} -
- {showExpandedButton && ( -
- -
+ } + : undefined + } + > + { + let t = linkifyTaskIdsInMarkdown(displayText); + if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); + return t; + })()} + maxHeight="max-h-none" + bare + /> + )} -
+ ); })()} {comment.attachments && comment.attachments.length > 0 ? ( @@ -361,6 +286,7 @@ export const TaskCommentsSection = ({ ) : null}
))} +
{sortedComments.length > visibleComments.length ? (
@@ -431,6 +357,7 @@ export const TaskCommentsSection = ({ value={draft.value} onValueChange={draft.setValue} suggestions={mentionSuggestions} + onModEnter={() => void handleSubmit()} minRows={2} maxRows={8} maxLength={MAX_COMMENT_LENGTH} @@ -556,7 +483,3 @@ const CommentAttachments = ({ ))}
); - -function teamIdKey(teamName: string, taskId: string): string { - return `${teamName}::${taskId}`; -} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 1845b8ed..8be7ad13 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -6,6 +6,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; +import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { Dialog, DialogContent, @@ -545,7 +546,7 @@ export const TaskDetailDialog = ({
{ if (e.key === 'Enter' || e.key === ' ') { @@ -554,7 +555,9 @@ export const TaskDetailDialog = ({ } }} > - + + + - +
+ +
handleDependencyClick(taskId) : undefined} + containerClassName="-mx-6" /> diff --git a/src/renderer/components/team/editor/EditorImagePreview.tsx b/src/renderer/components/team/editor/EditorImagePreview.tsx index eff4b230..f9f6f278 100644 --- a/src/renderer/components/team/editor/EditorImagePreview.tsx +++ b/src/renderer/components/team/editor/EditorImagePreview.tsx @@ -33,15 +33,14 @@ export const EditorImagePreview = ({ const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null); const imgRef = useRef(null); - // Reset state when filePath changes (setState-during-render, React-approved pattern) - const [prevFilePath, setPrevFilePath] = useState(filePath); - if (prevFilePath !== filePath) { - setPrevFilePath(filePath); + // Reset state when filePath changes + useEffect(() => { setLoading(true); setError(null); setDataUrl(null); setDimensions(null); - } + setLightboxOpen(false); + }, [filePath]); useEffect(() => { let cancelled = false; diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx index eaca7c3e..b2a237ae 100644 --- a/src/renderer/components/team/editor/QuickOpenDialog.tsx +++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx @@ -37,18 +37,11 @@ export const QuickOpenDialog = ({ const [allFiles, setAllFiles] = useState([]); const [loading, setLoading] = useState(true); - // Reset loading state when projectPath changes (React-recommended - // "adjusting state when props change" pattern without effects or refs) - const [prevProjectPath, setPrevProjectPath] = useState(projectPath); - if (prevProjectPath !== projectPath) { - setPrevProjectPath(projectPath); - setLoading(true); - } - // Load all project files on mount via backend API useEffect(() => { let cancelled = false; + setLoading(true); window.electronAPI.editor .listFiles() .then((files) => { diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index b8c8d2cf..3caacd0f 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -6,6 +6,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd- import { CSS } from '@dnd-kit/utilities'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useResizableColumns } from '@renderer/hooks/useResizableColumns'; import { cn } from '@renderer/lib/utils'; import { CheckCircle2, @@ -402,6 +403,17 @@ export const KanbanBoard = ({ ); }; + const visibleColumns = useMemo( + () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), + [filter.columns] + ); + + const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); + const { widths: columnWidths, getHandleProps } = useResizableColumns({ + storageKey: teamName, + columnIds: resizableColumnIds, + }); + const boardContent = ( <>
@@ -475,7 +487,7 @@ export const KanbanBoard = ({ {viewMode === 'grid' ? (
- {COLUMNS.map((column) => { + {visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; return ( @@ -493,21 +505,32 @@ export const KanbanBoard = ({ })}
) : ( -
- {COLUMNS.map((column) => { +
+ {visibleColumns.map((column, index) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; + const width = columnWidths.get(column.id) ?? 256; return ( -
- - {renderCards(column.id, columnTasks, true)} - +
+
+ + {renderCards(column.id, columnTasks, true)} + +
+ {index < visibleColumns.length - 1 ? ( +
+
+
+ ) : null}
); })} diff --git a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx index cfd8a163..bd48b8b0 100644 --- a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx +++ b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx @@ -7,15 +7,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { Crown, Filter } from 'lucide-react'; import type { Session } from '@renderer/types/data'; -import type { ResolvedTeamMember } from '@shared/types'; +import type { KanbanColumnId, ResolvedTeamMember } from '@shared/types'; export const UNASSIGNED_OWNER = '__unassigned__'; export interface KanbanFilterState { sessionId: string | null; selectedOwners: Set; + /** When non-empty, only these columns are visible on the kanban board. Empty = all columns. */ + columns: Set; } +/** Column definitions with display labels and accent colors for filter UI. */ +export const KANBAN_COLUMNS: { id: KanbanColumnId; label: string; color: string }[] = [ + { id: 'todo', label: 'TODO', color: 'rgb(59, 130, 246)' }, + { id: 'in_progress', label: 'IN PROGRESS', color: 'rgb(234, 179, 8)' }, + { id: 'done', label: 'DONE', color: 'rgb(34, 197, 94)' }, + { id: 'review', label: 'REVIEW', color: 'rgb(139, 92, 246)' }, + { id: 'approved', label: 'APPROVED', color: 'rgb(22, 163, 74)' }, +]; + interface KanbanFilterPopoverProps { filter: KanbanFilterState; sessions: Session[]; @@ -35,8 +46,9 @@ export const KanbanFilterPopover = ({ let count = 0; if (filter.sessionId !== null) count += 1; if (filter.selectedOwners.size > 0) count += 1; + if (filter.columns.size > 0) count += 1; return count; - }, [filter.sessionId, filter.selectedOwners]); + }, [filter.sessionId, filter.selectedOwners, filter.columns]); const handleSessionSelect = (sessionId: string | null): void => { onFilterChange({ ...filter, sessionId }); @@ -52,8 +64,18 @@ export const KanbanFilterPopover = ({ onFilterChange({ ...filter, selectedOwners: next }); }; + const handleColumnToggle = (columnId: KanbanColumnId): void => { + const next = new Set(filter.columns); + if (next.has(columnId)) { + next.delete(columnId); + } else { + next.add(columnId); + } + onFilterChange({ ...filter, columns: next }); + }; + const handleClearAll = (): void => { - onFilterChange({ sessionId: null, selectedOwners: new Set() }); + onFilterChange({ sessionId: null, selectedOwners: new Set(), columns: new Set() }); }; return ( @@ -148,6 +170,28 @@ export const KanbanFilterPopover = ({
+ {/* Column section */} +
+

+ Column +

+
+ {KANBAN_COLUMNS.map((col) => ( + + ))} +
+
+ {/* Footer */}
- + { + e.preventDefault(); + setRecipientSearch(''); + setTimeout(() => recipientSearchRef.current?.focus(), 0); + }} + > + {members.length > 5 && ( +
+ + setRecipientSearch(e.target.value)} + /> +
+ )}
- {members.map((m) => { - const resolvedColor = colorMap.get(m.name); - const colorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null; - const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); - const isSelected = m.name === recipient; - return ( - - ); - })} + + {role ? ( + + {role} + + ) : null} + {isSelected ? ( + + ) : null} + + ); + }); + })()}
@@ -316,6 +397,8 @@ export const MessageComposer = ({ attachments={attachments} onRemove={removeAttachment} error={attachmentError} + disabled={attachmentsBlocked} + disabledHint="Image attachments are only supported when sending to team lead. Remove attachments or switch recipient." /> - - Send - +
+ {leadContext && leadContext.percent > 0 && } + + + + + Voice to text + + +
} footerRight={
diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index 547193ca..8f0a72aa 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -1,9 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; +import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useStore } from '@renderer/store'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { Filter } from 'lucide-react'; import type { InboxMessage } from '@shared/types'; @@ -57,6 +60,9 @@ export const MessagesFilterPopover = ({ } }, [open, filter.from, filter.to]); + const members = useStore((s) => s.selectedTeamData?.members ?? []); + const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const fromOptions = useMemo(() => collectFromOptions(messages), [messages]); const toOptions = useMemo(() => collectToOptions(messages), [messages]); @@ -132,7 +138,7 @@ export const MessagesFilterPopover = ({ checked={draft.from.has(name)} onCheckedChange={() => toggleFrom(name)} /> - {name} + )) )} @@ -152,7 +158,7 @@ export const MessagesFilterPopover = ({ className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]" > toggleTo(name)} /> - {name} + )) )} diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 13003874..9a162fb0 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -724,14 +724,12 @@ export const ChangeReviewDialog = ({ }); }, [activeChangeSet, initialFilePath, scrollToFile]); - // Clear selection state on close (React-approved setState-during-render pattern) - const [prevOpen, setPrevOpen] = useState(open); - if (prevOpen !== open) { - setPrevOpen(open); + // Clear selection state on close + useEffect(() => { if (!open) { setSelectionInfo(null); } - } + }, [open]); // Cleanup refs/timers on close useEffect(() => { diff --git a/src/renderer/components/ui/ExpandableContent.tsx b/src/renderer/components/ui/ExpandableContent.tsx new file mode 100644 index 00000000..37158459 --- /dev/null +++ b/src/renderer/components/ui/ExpandableContent.tsx @@ -0,0 +1,108 @@ +import { useCallback, useRef, useState } from 'react'; + +import { ChevronDown, ChevronUp } from 'lucide-react'; + +const DEFAULT_COLLAPSED_HEIGHT = 200; // px + +interface ExpandableContentProps { + /** Content to render inside the expandable container. */ + children: React.ReactNode; + /** Maximum height (px) before truncation kicks in. Default: 200. */ + collapsedHeight?: number; + /** Extra className applied to the outermost wrapper. */ + className?: string; +} + +/** + * Generic expand/collapse wrapper with: + * - Collapsed: content clipped at `collapsedHeight`, mask-image fade, "Show more" button + * - Expanded: full content, sticky "Show less" button at viewport bottom + * + * Uses CSS `mask-image` for the fade so it works on any background color + * (zebra stripes, card backgrounds, etc.) without needing to know the bg color. + */ +export const ExpandableContent = ({ + children, + collapsedHeight = DEFAULT_COLLAPSED_HEIGHT, + className, +}: ExpandableContentProps): React.JSX.Element => { + const anchorRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const [needsTruncation, setNeedsTruncation] = useState(false); + + // Measure content height via callback ref — re-runs when children change + const measureRef = useCallback( + (node: HTMLDivElement | null) => { + if (node) { + requestAnimationFrame(() => { + setNeedsTruncation(node.scrollHeight > collapsedHeight); + }); + } + }, + // Re-measure when children identity changes (content prop in callers) + // eslint-disable-next-line react-hooks/exhaustive-deps + [children, collapsedHeight] + ); + + const handleCollapse = useCallback(() => { + setExpanded(false); + anchorRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, []); + + return ( +
+
+ {children} +
+ + {/* Show more */} + {!expanded && needsTruncation ? ( +
+ +
+ ) : null} + + {/* Sticky Show less */} + {expanded && needsTruncation ? ( +
+ +
+ ) : null} +
+ ); +}; diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index 2b304812..d08f7ad9 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -204,6 +204,8 @@ interface MentionableTextareaProps extends Omit< projectPath?: string | null; /** Called when a file chip is created via @ selection. Parent must add chip to state. */ onFileChipInsert?: (chip: InlineChip) => void; + /** Called when Cmd+Enter (Mac) / Ctrl+Enter (Win/Linux) is pressed. */ + onModEnter?: () => void; } export const MentionableTextarea = React.forwardRef( @@ -220,6 +222,7 @@ export const MentionableTextarea = React.forwardRef) => { + // Mod+Enter (Cmd on Mac, Ctrl on Win/Linux) → submit + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && onModEnter) { + e.preventDefault(); + onModEnter(); + return; + } handleChipKeyDown(e); if (!e.defaultPrevented) { if (enableFiles) { @@ -509,7 +518,7 @@ export const MentionableTextarea = React.forwardRef { const raw = await draftStorage.loadDraft(persistenceKey); if (cancelled || raw == null) return; diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts index 6db45f0b..9cedb0e7 100644 --- a/src/renderer/hooks/useFileSuggestions.ts +++ b/src/renderer/hooks/useFileSuggestions.ts @@ -5,7 +5,7 @@ * Returns up to 8 matching files filtered by name or relative path. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getQuickOpenCache, @@ -72,17 +72,15 @@ export function useFileSuggestions( // Bumped on cache invalidation (file create/delete) to trigger refetch const [fetchTrigger, setFetchTrigger] = useState(0); - // Re-seed from cache when projectPath changes (setState-during-render pattern) - const [prevPath, setPrevPath] = useState(projectPath); - if (prevPath !== projectPath) { - setPrevPath(projectPath); - const cached = projectPath ? getQuickOpenCache(projectPath) : null; - if (cached) { - setAllFiles(cached.files); - } else { + // Re-seed from cache when projectPath changes + useEffect(() => { + if (!projectPath) { setAllFiles([]); + return; } - } + const cached = getQuickOpenCache(projectPath); + setAllFiles(cached?.files ?? []); + }, [projectPath]); // React to cache invalidation from EditorFileWatcher (create/delete events) useEffect(() => { @@ -90,13 +88,13 @@ export function useFileSuggestions( }, []); // Lazy refetch: when dropdown opens and cache is stale, trigger a reload - const [prevEnabled, setPrevEnabled] = useState(enabled); - if (enabled && !prevEnabled && projectPath && !getQuickOpenCache(projectPath)) { - setFetchTrigger((n) => n + 1); - } - if (prevEnabled !== enabled) { - setPrevEnabled(enabled); - } + const prevEnabledRef = useRef(enabled); + useEffect(() => { + if (enabled && !prevEnabledRef.current && projectPath && !getQuickOpenCache(projectPath)) { + setFetchTrigger((n) => n + 1); + } + prevEnabledRef.current = enabled; + }, [enabled, projectPath]); // Load files from API when cache is empty. // Uses project:listFiles (not editor:listFiles) — works without editor being open. @@ -126,7 +124,7 @@ export function useFileSuggestions( // Fetch only when cache is empty. Cache seeding is handled by: // - lazy initializer (first mount) - // - setState-during-render (projectPath change) + // - effect (projectPath change) useEffect(() => { if (!projectPath) return; diff --git a/src/renderer/hooks/useResizableColumns.ts b/src/renderer/hooks/useResizableColumns.ts new file mode 100644 index 00000000..332c836f --- /dev/null +++ b/src/renderer/hooks/useResizableColumns.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +const STORAGE_PREFIX = 'kanban-column-widths:'; +const MIN_COLUMN_WIDTH = 180; +const DEFAULT_COLUMN_WIDTH = 256; // w-64 + +interface UseResizableColumnsOptions { + /** Storage key suffix (e.g. teamName). */ + storageKey: string; + /** Column IDs in display order. */ + columnIds: string[]; +} + +interface UseResizableColumnsResult { + /** Width in px for each column ID. */ + widths: Map; + /** Props to spread on the drag handle between columns. */ + getHandleProps: (leftColumnId: string) => { + onPointerDown: (e: React.PointerEvent) => void; + style: React.CSSProperties; + 'aria-label': string; + }; +} + +function loadWidths(key: string): Record { + try { + const raw = localStorage.getItem(STORAGE_PREFIX + key); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}; + const result: Record = {}; + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v === 'number' && v >= MIN_COLUMN_WIDTH) { + result[k] = v; + } + } + return result; + } catch { + return {}; + } +} + +function saveWidths(key: string, widths: Record): void { + try { + localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(widths)); + } catch { + // Quota exceeded — ignore + } +} + +export function useResizableColumns({ + storageKey, + columnIds, +}: UseResizableColumnsOptions): UseResizableColumnsResult { + const [widthRecord, setWidthRecord] = useState>(() => + loadWidths(storageKey) + ); + + // Re-read from localStorage when storageKey changes + useEffect(() => { + setWidthRecord(loadWidths(storageKey)); + }, [storageKey]); + + const draggingRef = useRef<{ + leftId: string; + startX: number; + startWidth: number; + } | null>(null); + + const widths = new Map(); + for (const id of columnIds) { + widths.set(id, widthRecord[id] ?? DEFAULT_COLUMN_WIDTH); + } + + const handlePointerMove = useCallback((e: PointerEvent) => { + const drag = draggingRef.current; + if (!drag) return; + const delta = e.clientX - drag.startX; + const newWidth = Math.max(MIN_COLUMN_WIDTH, drag.startWidth + delta); + setWidthRecord((prev) => ({ ...prev, [drag.leftId]: newWidth })); + }, []); + + const handlePointerUp = useCallback(() => { + const drag = draggingRef.current; + if (!drag) return; + draggingRef.current = null; + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + // Persist + setWidthRecord((current) => { + saveWidths(storageKey, current); + return current; + }); + }, [handlePointerMove, storageKey]); + + const getHandleProps = useCallback( + (leftColumnId: string) => ({ + onPointerDown: (e: React.PointerEvent) => { + e.preventDefault(); + const currentWidth = widthRecord[leftColumnId] ?? DEFAULT_COLUMN_WIDTH; + draggingRef.current = { + leftId: leftColumnId, + startX: e.clientX, + startWidth: currentWidth, + }; + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, + style: { + cursor: 'col-resize' as const, + width: 8, + flexShrink: 0, + alignSelf: 'stretch' as const, + }, + 'aria-label': `Resize column ${leftColumnId}`, + }), + [widthRecord, handlePointerMove, handlePointerUp] + ); + + return { widths, getHandleProps }; +} diff --git a/src/renderer/utils/agentMessageFormatting.ts b/src/renderer/utils/agentMessageFormatting.ts index ab41fb17..17311dc6 100644 --- a/src/renderer/utils/agentMessageFormatting.ts +++ b/src/renderer/utils/agentMessageFormatting.ts @@ -15,20 +15,30 @@ export interface ParsedMessageReply { const REPLY_BLOCK_RE = new RegExp( '```' + MESSAGE_REPLY_TAG + - '\\nReply on @([\\w-]+) original message with text "([\\s\\S]*?)", here is answer: "([\\s\\S]*?)"\\n```' + '\\nReply on @([\\w.-]+) original message with text "([\\s\\S]*?)", here is answer: "([\\s\\S]*?)"\\n```' ); +function encodeReplyField(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function decodeReplyField(value: string): string { + // Backwards-compat: avoid touching legacy content that has no escapes. + if (!value.includes('\\"') && !value.includes('\\\\')) return value; + return value.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); +} + /** * Parses a message_reply_for_agent block from content. * Returns null if no reply block is found. */ export function parseMessageReply(content: string): ParsedMessageReply | null { - const match = REPLY_BLOCK_RE.exec(content); + const match = content.match(REPLY_BLOCK_RE); if (!match) return null; return { agentName: match[1], - originalText: match[2], - replyText: match[3], + originalText: decodeReplyField(match[2]), + replyText: decodeReplyField(match[3]), }; } @@ -43,7 +53,7 @@ export function buildReplyBlock( const tag = MESSAGE_REPLY_TAG; return [ '```' + tag, - `Reply on @${agentName} original message with text "${originalText}", here is answer: "${replyText}"`, + `Reply on @${agentName} original message with text "${encodeReplyField(originalText)}", here is answer: "${encodeReplyField(replyText)}"`, '```', ].join('\n'); } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index f67c6c9c..49100cd6 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -444,6 +444,7 @@ export interface MemberFullStats { export interface AddMemberRequest { name: string; role?: string; + workflow?: string; } export interface RemoveMemberRequest { diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index c1227e30..48ed8954 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -46,6 +46,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({ TEAM_GET_ATTACHMENTS: 'team:getAttachments', TEAM_KILL_PROCESS: 'team:killProcess', TEAM_LEAD_ACTIVITY: 'team:leadActivity', + TEAM_LEAD_CONTEXT: 'team:leadContext', TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask', TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks', TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification', @@ -102,6 +103,14 @@ import { TEAM_ADD_TASK_RELATIONSHIP, TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, + TEAM_UPDATE_TASK_OWNER, + TEAM_UPDATE_TASK_FIELDS, + TEAM_LEAD_CONTEXT, + TEAM_RESTORE_TASK, + TEAM_SHOW_MESSAGE_NOTIFICATION, + TEAM_SAVE_TASK_ATTACHMENT, + TEAM_GET_TASK_ATTACHMENT, + TEAM_DELETE_TASK_ATTACHMENT, } from '../../../src/preload/constants/ipcChannels'; import { initializeTeamHandlers, @@ -231,6 +240,17 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(true); expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(true); expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(true); + expect(handlers.has(TEAM_UPDATE_TASK_OWNER)).toBe(true); + expect(handlers.has(TEAM_UPDATE_TASK_FIELDS)).toBe(true); + expect(handlers.has(TEAM_REPLACE_MEMBERS)).toBe(true); + expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(true); + expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(true); + expect(handlers.has(TEAM_LEAD_CONTEXT)).toBe(true); + expect(handlers.has(TEAM_RESTORE_TASK)).toBe(true); + expect(handlers.has(TEAM_SHOW_MESSAGE_NOTIFICATION)).toBe(true); + expect(handlers.has(TEAM_SAVE_TASK_ATTACHMENT)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_ATTACHMENT)).toBe(true); + expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(true); }); it('returns success false on invalid sendMessage args', async () => { @@ -552,6 +572,15 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(false); expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(false); expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(false); + expect(handlers.has(TEAM_UPDATE_TASK_OWNER)).toBe(false); + expect(handlers.has(TEAM_UPDATE_TASK_FIELDS)).toBe(false); + expect(handlers.has(TEAM_REPLACE_MEMBERS)).toBe(false); + expect(handlers.has(TEAM_LEAD_CONTEXT)).toBe(false); + expect(handlers.has(TEAM_RESTORE_TASK)).toBe(false); + expect(handlers.has(TEAM_SHOW_MESSAGE_NOTIFICATION)).toBe(false); + expect(handlers.has(TEAM_SAVE_TASK_ATTACHMENT)).toBe(false); + expect(handlers.has(TEAM_GET_TASK_ATTACHMENT)).toBe(false); + expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(false); }); describe('addTaskRelationship', () => { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 4fdfa0be..b69b798b 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; -import type { TeamTask } from '../../../../src/shared/types/team'; +import type { InboxMessage, TeamTask } from '../../../../src/shared/types/team'; describe('TeamDataService', () => { it('runs kanban garbage-collect only after tasks are loaded', async () => { @@ -47,6 +47,72 @@ describe('TeamDataService', () => { expect(order).toEqual(['tasks', 'gc']); }); + it('does not sync automated comment notifications into task comments', async () => { + const tasks: TeamTask[] = [ + { + id: '12', + subject: 'Task', + status: 'pending', + }, + ]; + + const addComment = vi.fn(async () => { + throw new Error('Should not be called'); + }); + + const messages: InboxMessage[] = [ + { + from: 'team-lead', + to: 'alice', + summary: 'Comment on #12', + messageId: 'm1', + timestamp: new Date().toISOString(), + read: false, + text: + 'Comment on task #12 "Task":\n\nHello\n\n' + + '\n' + + 'Reply to this comment using:\n' + + 'node "tool.js" --team my-team task comment 12 --text "..." --from "alice"\n' + + '', + }, + ]; + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })), + } as never, + { + getTasks: vi.fn(async () => tasks), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => messages), + } as never, + {} as never, + { + addComment, + } as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + garbageCollect: vi.fn(async () => undefined), + } as never, + {} as never, + { + readMembers: vi.fn(async () => []), + } as never, + { + readMessages: vi.fn(async () => []), + } as never + ); + + await service.getTeamData('my-team'); + expect(addComment).not.toHaveBeenCalled(); + }); + it('skips kanban garbage-collect when tasks fail to load', async () => { const garbageCollect = vi.fn(async () => undefined); const service = new TeamDataService(