From b6ec4084511b772c3af5b5984ebb095dc8d30db3 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 13:28:44 +0200 Subject: [PATCH] feat: enhance error handling and reporting in ErrorBoundary component - Added functionality to copy error details to clipboard and create GitHub issue reports directly from the error boundary. - Introduced a new state variable to manage the copy confirmation status. - Enhanced UI with buttons for copying error details and reporting bugs, improving user experience during error handling. - Updated the rendering logic to display additional context about the error and the copied status. - Refactored the component to ensure proper cleanup of timeouts on unmount. --- .../components/common/ErrorBoundary.tsx | 106 +++- src/renderer/components/team/TaskTooltip.tsx | 46 +- .../components/team/TeamDetailView.tsx | 371 +++---------- .../components/team/activity/ActivityItem.tsx | 25 +- .../team/activity/LeadThoughtsGroup.tsx | 3 +- .../team/dialogs/CreateTaskDialog.tsx | 4 + .../team/dialogs/CreateTeamDialog.tsx | 273 ++++++---- .../team/dialogs/LaunchTeamDialog.tsx | 292 ++++++---- .../team/dialogs/OptionalSettingsSection.tsx | 91 ++++ .../team/dialogs/ProjectPathSelector.tsx | 218 ++++---- .../components/team/dialogs/ReviewDialog.tsx | 3 + .../team/dialogs/SendMessageDialog.tsx | 3 + .../team/dialogs/TaskCommentInput.tsx | 3 + .../team/dialogs/TaskCommentsSection.tsx | 9 +- .../components/team/kanban/KanbanBoard.tsx | 5 +- .../team/messages/MessageComposer.tsx | 3 + .../team/messages/MessagesPanel.tsx | 506 ++++++++++++++++++ .../components/ui/MentionSuggestionList.tsx | 48 +- .../components/ui/MentionableTextarea.tsx | 241 +++++---- .../ui/TaskReferenceInteractionLayer.tsx | 89 +++ src/renderer/hooks/useMentionDetection.ts | 111 ++-- src/renderer/hooks/useResizablePanel.ts | 122 +++++ src/renderer/hooks/useTaskSuggestions.ts | 123 +++++ src/renderer/store/slices/teamSlice.ts | 12 + src/renderer/types/mention.ts | 22 +- src/renderer/utils/bugReportUtils.ts | 157 ++++++ src/renderer/utils/chipUtils.ts | 109 ++-- src/renderer/utils/mentionSuggestions.ts | 27 + src/renderer/utils/taskReferenceUtils.ts | 62 +++ 29 files changed, 2255 insertions(+), 829 deletions(-) create mode 100644 src/renderer/components/team/dialogs/OptionalSettingsSection.tsx create mode 100644 src/renderer/components/team/messages/MessagesPanel.tsx create mode 100644 src/renderer/components/ui/TaskReferenceInteractionLayer.tsx create mode 100644 src/renderer/hooks/useResizablePanel.ts create mode 100644 src/renderer/hooks/useTaskSuggestions.ts create mode 100644 src/renderer/utils/bugReportUtils.ts create mode 100644 src/renderer/utils/mentionSuggestions.ts create mode 100644 src/renderer/utils/taskReferenceUtils.ts diff --git a/src/renderer/components/common/ErrorBoundary.tsx b/src/renderer/components/common/ErrorBoundary.tsx index bf885222..a8904129 100644 --- a/src/renderer/components/common/ErrorBoundary.tsx +++ b/src/renderer/components/common/ErrorBoundary.tsx @@ -1,7 +1,14 @@ import React, { Component, type ErrorInfo, type ReactNode } from 'react'; +import { useStore } from '@renderer/store'; import { createLogger } from '@shared/utils/logger'; -import { AlertTriangle, RefreshCw } from 'lucide-react'; +import { AlertTriangle, Bug, Check, Copy, RefreshCw } from 'lucide-react'; + +import { + buildBugReportText, + buildGitHubBugReportUrl, + type BugReportContext, +} from '@renderer/utils/bugReportUtils'; const logger = createLogger('Component:ErrorBoundary'); @@ -12,15 +19,19 @@ interface Props { interface State { hasError: boolean; + copiedReport: boolean; error: Error | null; errorInfo: ErrorInfo | null; } export class ErrorBoundary extends Component { + private copyResetTimeout: ReturnType | null = null; + constructor(props: Props) { super(props); this.state = { hasError: false, + copiedReport: false, error: null, errorInfo: null, }; @@ -40,16 +51,83 @@ export class ErrorBoundary extends Component { }; handleReset = (): void => { + if (this.copyResetTimeout) { + clearTimeout(this.copyResetTimeout); + this.copyResetTimeout = null; + } + this.setState({ hasError: false, + copiedReport: false, error: null, errorInfo: null, }); }; + componentWillUnmount(): void { + if (this.copyResetTimeout) { + clearTimeout(this.copyResetTimeout); + this.copyResetTimeout = null; + } + } + + getBugReportContext = (): BugReportContext => { + const state = useStore.getState(); + const activeTab = state.getActiveTab(); + + return { + activeTabType: activeTab?.type ?? null, + activeTabLabel: activeTab?.label ?? null, + activeTeamName: activeTab?.teamName ?? null, + selectedTeamName: state.selectedTeamName, + taskId: state.globalTaskDetail?.taskId ?? state.pendingReviewRequest?.taskId ?? null, + sessionId: activeTab?.sessionId ?? null, + projectId: activeTab?.projectId ?? state.activeProjectId, + }; + }; + + handleCreateGitHubIssue = (): void => { + const issueUrl = buildGitHubBugReportUrl({ + error: this.state.error, + componentStack: this.state.errorInfo?.componentStack ?? null, + context: this.getBugReportContext(), + }); + + if (window.electronAPI?.openExternal) { + void window.electronAPI.openExternal(issueUrl); + return; + } + + window.open(issueUrl, '_blank', 'noopener,noreferrer'); + }; + + handleCopyErrorDetails = async (): Promise => { + try { + await navigator.clipboard.writeText( + buildBugReportText({ + error: this.state.error, + componentStack: this.state.errorInfo?.componentStack ?? null, + context: this.getBugReportContext(), + }) + ); + + if (this.copyResetTimeout) { + clearTimeout(this.copyResetTimeout); + } + + this.setState({ copiedReport: true }); + this.copyResetTimeout = setTimeout(() => { + this.setState({ copiedReport: false }); + this.copyResetTimeout = null; + }, 2000); + } catch (error) { + logger.warn('Failed to copy error details:', error); + } + }; + // eslint-disable-next-line sonarjs/function-return-type -- Error boundaries inherently return different content based on error state render(): ReactNode { - const { hasError, error, errorInfo } = this.state; + const { hasError, copiedReport, error, errorInfo } = this.state; const { children, fallback } = this.props; if (hasError) { @@ -85,13 +163,31 @@ export class ErrorBoundary extends Component { )} -
+
+ +
+

+ GitHub bug reports and copied diagnostics include the error message, stack traces, app + version, active tab, selected team, task context, and environment details. +

); } diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index b8dd6466..b761ad76 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -50,6 +50,8 @@ function getStatusLabel(column: string): string { interface TaskTooltipProps { /** Canonical task id or short display id reference. */ taskId: string; + /** Optional owning team for cross-team task references. */ + teamName?: string; /** Rendered trigger element. */ children: React.ReactElement; /** Tooltip placement. */ @@ -62,11 +64,34 @@ interface TaskTooltipProps { */ export const TaskTooltip = ({ taskId, + teamName, children, side = 'top', }: TaskTooltipProps): React.JSX.Element => { - const tasks = useStore((s) => s.selectedTeamData?.tasks); - const members = useStore((s) => s.selectedTeamData?.members); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const selectedTeamData = useStore((s) => s.selectedTeamData); + const globalTasks = useStore((s) => s.globalTasks); + const teamByName = useStore((s) => s.teamByName); + + const tasks = useMemo(() => { + if (teamName && selectedTeamName === teamName) { + return selectedTeamData?.tasks ?? []; + } + if (teamName) { + return globalTasks.filter((task) => task.teamName === teamName); + } + const currentTasks = selectedTeamData?.tasks ?? []; + const currentMatch = currentTasks.find((task) => taskMatchesRef(task, taskId)); + if (currentMatch) return currentTasks; + return globalTasks; + }, [globalTasks, selectedTeamData, selectedTeamName, teamName, taskId]); + + const members = useMemo(() => { + if (teamName && selectedTeamName === teamName) { + return selectedTeamData?.members ?? []; + } + return []; + }, [selectedTeamData, selectedTeamName, teamName]); const task = useMemo(() => tasks?.find((t) => taskMatchesRef(t, taskId)), [tasks, taskId]); @@ -81,11 +106,24 @@ export const TaskTooltip = ({ const column = getEffectiveColumn(task); const statusColor = STATUS_COLORS[column] ?? STATUS_COLORS.pending; const label = getStatusLabel(column); + const taskTeamName = + typeof (task as unknown as { teamName?: unknown }).teamName === 'string' + ? (task as unknown as { teamName: string }).teamName + : undefined; + const resolvedTeamName = teamName ?? taskTeamName; + const resolvedTeamDisplayName = resolvedTeamName + ? teamByName[resolvedTeamName]?.displayName + : null; return ( {children} + {resolvedTeamName ? ( +
+ {resolvedTeamDisplayName || resolvedTeamName} +
+ ) : null} {/* Subject */}
{formatTaskDisplayLabel(task)}{' '} @@ -109,8 +147,10 @@ export const TaskTooltip = ({ ) : null} {/* Owner */} - {task.owner ? ( + {task.owner && members.length > 0 ? ( + ) : task.owner ? ( + {task.owner} ) : ( Unassigned )} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index b2561a3b..58104332 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -15,37 +15,27 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; +import { useResizablePanel } from '@renderer/hooks/useResizablePanel'; import { useTabUI } from '@renderer/hooks/useTabUI'; -import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; -import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; -import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; -import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; -import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, - Bell, - CheckCheck, - ChevronsDownUp, - ChevronsUpDown, - ChevronRight, Clock, Code, Columns3, FolderOpen, GitBranch, History, - MessageSquare, Pencil, Play, Plus, @@ -59,9 +49,6 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { ActiveTasksBlock } from './activity/ActiveTasksBlock'; -import { ActivityTimeline } from './activity/ActivityTimeline'; -import { PendingRepliesBlock } from './activity/PendingRepliesBlock'; import { AddMemberDialog } from './dialogs/AddMemberDialog'; import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; @@ -78,8 +65,7 @@ const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) ); import { MemberList } from './members/MemberList'; -import { MessageComposer } from './messages/MessageComposer'; -import { MessagesFilterPopover } from './messages/MessagesFilterPopover'; +import { MessagesPanel } from './messages/MessagesPanel'; import { ChangeReviewDialog } from './review/ChangeReviewDialog'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; @@ -90,16 +76,10 @@ import { TeamSessionsSection } from './TeamSessionsSection'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanSortState } from './kanban/KanbanSortPopover'; -import type { MessagesFilterState } from './messages/MessagesFilterPopover'; import type { ContextInjection } from '@renderer/types/contextInjection'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; -import type { - InboxMessage, - MemberSpawnStatusEntry, - ResolvedTeamMember, - TeamTaskWithKanban, -} from '@shared/types'; +import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; interface TeamDetailViewProps { @@ -223,7 +203,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele updateTaskStatus, updateTaskOwner, sendTeamMessage, - sendCrossTeamMessage, requestReview, createTeamTask, startTask, @@ -252,6 +231,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele fetchDeletedTasks, deletedTasks, launchParams, + messagesPanelMode, + messagesPanelWidth, + setMessagesPanelMode, + setMessagesPanelWidth, } = useStore( useShallow((s) => ({ data: s.selectedTeamData, @@ -268,7 +251,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele updateTaskStatus: s.updateTaskStatus, updateTaskOwner: s.updateTaskOwner, sendTeamMessage: s.sendTeamMessage, - sendCrossTeamMessage: s.sendCrossTeamMessage, requestReview: s.requestReview, createTeamTask: s.createTeamTask, startTask: s.startTask, @@ -299,6 +281,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele fetchDeletedTasks: s.fetchDeletedTasks, deletedTasks: s.deletedTasks, launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + setMessagesPanelMode: s.setMessagesPanelMode, + setMessagesPanelWidth: s.setMessagesPanelWidth, })) ); @@ -312,6 +298,20 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele } = useTabUI(); const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); + // Messages panel resize + const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = + useResizablePanel({ + width: messagesPanelWidth, + onWidthChange: setMessagesPanelWidth, + minWidth: 280, + maxWidth: 600, + side: 'left', + }); + + const toggleMessagesPanelMode = useCallback(() => { + setMessagesPanelMode(messagesPanelMode === 'sidebar' ? 'inline' : 'sidebar'); + }, [messagesPanelMode, setMessagesPanelMode]); + useEffect(() => { if (tabId) { initTabUIState(tabId); @@ -344,15 +344,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }, [memberSpawnStatuses]); const [kanbanSearch, setKanbanSearch] = useState(''); - const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); - const [messagesFilter, setMessagesFilter] = useState({ - from: new Set(), - to: new Set(), - showNoise: false, - }); - const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); - const [messagesCollapsed, setMessagesCollapsed] = useState(true); - const [statusBlockCollapsed, setStatusBlockCollapsed] = useState(false); // Open editor overlay when a file reveal is requested (e.g. from chip click) const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); @@ -633,32 +624,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele [data?.members] ); - const filteredMessages = useMemo(() => { - if (!data) return []; - return filterTeamMessages(data.messages, { - timeWindow, - filter: messagesFilter, - searchQuery: messagesSearchQuery, - }); - }, [data, timeWindow, messagesFilter, messagesSearchQuery]); - - const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? ''); - const { expandedSet, toggle: toggleExpandOverride } = useTeamMessagesExpanded(teamName ?? ''); - const messagesUnreadCount = useMemo( - () => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length, - [filteredMessages, readSet] - ); - const handleMessageVisible = useCallback( - (message: InboxMessage) => markRead(toMessageKey(message)), - [markRead] - ); - const handleMarkAllRead = useCallback(() => { - const keys = filteredMessages - .filter((m) => !m.read && !readSet.has(toMessageKey(m))) - .map((m) => toMessageKey(m)); - markAllRead(keys); - }, [filteredMessages, readSet, markAllRead]); - const kanbanDisplayTasks = useMemo(() => { const query = kanbanSearch.trim(); if (!query) return filteredTasks; @@ -673,50 +638,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); - const pendingCrossTeamReplies = useMemo( - () => computePendingCrossTeamReplies(data?.messages ?? []), - [data?.messages] - ); - - /** Whether the Status block has any visible items (pending replies or active tasks). */ - const hasStatusItems = useMemo(() => { - const members = data?.members ?? []; - const tasks = data?.tasks ?? []; - - // Check pending replies (mirrors PendingRepliesBlock logic) - const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) => - members.some((m) => m.name === name) - ); - if (hasPendingReplies) return true; - if (pendingCrossTeamReplies.length > 0) return true; - - // Check active tasks (mirrors ActiveTasksBlock logic) - const tMap = new Map(tasks.map((t) => [t.id, t])); - return members.some((m) => { - if (!m.currentTaskId) return false; - const task = tMap.get(m.currentTaskId); - if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false; - return true; - }); - }, [data?.members, data?.tasks, pendingRepliesByMember, pendingCrossTeamReplies.length]); - - useEffect(() => { - if (!data || Object.keys(pendingRepliesByMember).length === 0) return; - const next = { ...pendingRepliesByMember }; - let changed = false; - for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) { - const hasReply = data.messages.some((m) => { - if (m.from !== memberName) return false; - const ts = Date.parse(m.timestamp); - return Number.isFinite(ts) && ts > sentAtMs; - }); - if (hasReply) { - delete next[memberName]; - changed = true; - } - } - if (changed) setPendingRepliesByMember(next); - }, [data, pendingRepliesByMember]); const openCreateTaskDialog = ( subject = '', @@ -1007,6 +928,53 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
)} + {/* Messages sidebar (left, after context panel) */} + {messagesPanelMode === 'sidebar' && ( +
+ { + openCreateTaskDialog(subject, description); + }} + onReplyToMessage={(message) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }} + onRestartTeam={() => setLaunchDialogOpen(true)} + onTaskIdClick={(taskId) => { + const task = + taskMap.get(taskId) ?? + data.tasks.find((candidate) => candidate.displayId === taskId); + if (task) setSelectedTask(task); + }} + /> + {/* Resize handle */} +
+
+ )} +
- } - badge={filteredMessages.length} - secondaryBadge={ - filteredMessages.length > 0 && messagesUnreadCount > 0 - ? messagesUnreadCount - : undefined - } - afterBadge={ - messagesUnreadCount > 0 ? ( - - - - - Mark all as read - - ) : undefined - } - headerExtra={ - - - - - Desktop notifications plugin - - } - defaultOpen - action={ -
-
- - setMessagesSearchQuery(e.target.value)} - onPointerDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" - /> - {messagesSearchQuery && ( - - )} -
- - - - - - - {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} - - -
- } - > - { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - void sendTeamMessage(teamName, { - member, - text, - summary, - attachments, - actionMode, - }).catch(() => { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - }); - }} - onCrossTeamSend={(toTeam, text, summary, actionMode) => { - void sendCrossTeamMessage({ - fromTeam: teamName, - fromMember: 'user', - toTeam, - text, - actionMode, - summary, - }); - }} - /> - {/* Status block: button floats right (absolute, no layout impact); - expanded content renders full-width in normal flow. */} - {hasStatusItems && ( - <> -
- -
- {!statusBlockCollapsed && ( -
- - -
- )} - - )} - { openCreateTaskDialog(subject, description); }} @@ -1755,7 +1551,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); setSendDialogOpen(true); }} - onMessageVisible={handleMessageVisible} onRestartTeam={() => setLaunchDialogOpen(true)} onTaskIdClick={(taskId) => { const task = @@ -1764,7 +1559,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele if (task) setSelectedTask(task); }} /> -
+ )} ` in plain text to markdown links with task:// protocol. */ -export function linkifyTaskIdsInMarkdown(text: string): string { - return text.replace(/#([A-Za-z0-9-]+)\b/g, '[#$1](task://$1)'); -} - /** Render `#` in plain text as clickable inline elements with TaskTooltip. */ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { return text.split(/(#[A-Za-z0-9-]+\b)/g).map((part, i) => { @@ -304,7 +300,9 @@ export const ActivityItem = ({ }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const { isLight } = useTheme(); - const formattedRole = formatAgentRole(memberRole); + // Hide role when it matches the sender name (avoids "lead" badge + "Team Lead" text duplication) + const formattedRole = + memberRole && memberRole !== message.from ? formatAgentRole(memberRole) : null; const teams = useStore((s) => s.teams); const teamNames = useMemo( @@ -312,9 +310,18 @@ export const ActivityItem = ({ [teams] ); - const timestamp = Number.isNaN(Date.parse(message.timestamp)) - ? message.timestamp - : new Date(message.timestamp).toLocaleString(); + const timestamp = useMemo(() => { + if (Number.isNaN(Date.parse(message.timestamp))) return message.timestamp; + const date = new Date(message.timestamp); + const now = new Date(); + const isToday = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + return isToday + ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : date.toLocaleString(); + }, [message.timestamp]); const structured = parseStructuredAgentMessage(message.text); // Only flag agent messages as rate-limited, not user's own quotes diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index a35827fa..10db9c8f 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -15,12 +15,11 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; +import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react'; - -import { linkifyTaskIdsInMarkdown } from './ActivityItem'; import { AnimatedHeightReveal, ENTRY_REVEAL_ANIMATION_MS, diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 02f3b59d..6e6b7b83 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -24,6 +24,7 @@ import { import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useStore } from '@renderer/store'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; @@ -78,6 +79,7 @@ export const CreateTaskDialog = ({ }: CreateTaskDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const [subject, setSubject] = useState(defaultSubject); const descriptionDraft = useDraftPersistence({ key: `createTask:${teamName}:description`, @@ -291,6 +293,7 @@ export const CreateTaskDialog = ({ value={descriptionDraft.value} onValueChange={descriptionDraft.setValue} suggestions={mentionSuggestions} + taskSuggestions={taskSuggestions} chips={descChipDraft.chips} onChipRemove={handleDescChipRemove} projectPath={projectPath} @@ -315,6 +318,7 @@ export const CreateTaskDialog = ({ value={promptDraft.value} onValueChange={promptDraft.setValue} suggestions={mentionSuggestions} + taskSuggestions={taskSuggestions} projectPath={projectPath} minRows={3} maxRows={12} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 5bd3cae5..af2e5448 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -34,6 +34,7 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; import { ExtendedContextCheckbox } from './ExtendedContextCheckbox'; +import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; @@ -561,6 +562,34 @@ export const CreateTeamDialog = ({ return args; }, [skipPermissions, effectiveModel, selectedEffort]); + const launchOptionalSummary = useMemo(() => { + const summary: string[] = []; + if (prompt.trim()) summary.push('Lead prompt'); + if (selectedModel) summary.push(`Model: ${selectedModel}`); + if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); + if (extendedContext) summary.push('Extended context'); + if (skipPermissions) summary.push('Auto-approve tools'); + if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); + if (customArgs.trim()) summary.push('Custom CLI args'); + return summary; + }, [ + prompt, + selectedModel, + selectedEffort, + extendedContext, + skipPermissions, + worktreeEnabled, + worktreeName, + customArgs, + ]); + + const teamDetailsSummary = useMemo(() => { + const summary: string[] = []; + if (description.trim()) summary.push('Description'); + if (teamColor) summary.push(`Color: ${teamColor}`); + return summary; + }, [description, teamColor]); + const activeError = localError ?? provisioningError; const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; @@ -810,16 +839,21 @@ export const CreateTeamDialog = ({ />
-
-
+
+
setLaunchTeam(checked === true)} /> - +
+ +

+ Start the team immediately via local Claude CLI. +

+
{launchTeam ? ( @@ -837,119 +871,136 @@ export const CreateTeamDialog = ({ fieldError={fieldErrors.cwd} /> -
- - - Draft saved - - ) : null - } - /> -
+ +
+
+ + + Draft saved + + ) : null + } + /> +
-
- - - - {launchTeam && ( - + + + + +
+ + - )} -
- +
+
) : null}
-
- - descriptionDraft.setValue(event.target.value)} - placeholder="Brief description of the team purpose" - /> - {descriptionDraft.isSaved ? ( - Draft saved - ) : null} -
+
+ +
+
+ + descriptionDraft.setValue(event.target.value)} + placeholder="Brief description of the team purpose" + /> + {descriptionDraft.isSaved ? ( + Draft saved + ) : null} +
-
- -
- {TEAM_COLOR_NAMES.map((colorName) => { - const colorSet = getTeamColorSet(colorName); - const isSelected = teamColor === colorName; - return ( - - ); - })} -
+
+ +
+ {TEAM_COLOR_NAMES.map((colorName) => { + const colorSet = getTeamColorSet(colorName); + const isSelected = teamColor === colorName; + return ( + + ); + })} +
+
+
+
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 47f8dace..c89f32cb 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -39,6 +39,7 @@ import { import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; +import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import { CronScheduleInput } from '../schedule/CronScheduleInput'; @@ -506,6 +507,32 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return args; }, [isLaunch, skipPermissions, selectedModel, extendedContext, selectedEffort, clearContext]); + const launchOptionalSummary = useMemo(() => { + if (!isLaunch) return []; + + const summary: string[] = []; + if (promptDraft.value.trim()) summary.push('Lead prompt'); + if (selectedModel) summary.push(`Model: ${selectedModel}`); + if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); + if (extendedContext) summary.push('Extended context'); + if (skipPermissions) summary.push('Auto-approve tools'); + if (clearContext) summary.push('Fresh session'); + if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); + if (customArgs.trim()) summary.push('Custom CLI args'); + return summary; + }, [ + isLaunch, + promptDraft.value, + selectedModel, + selectedEffort, + extendedContext, + skipPermissions, + clearContext, + worktreeEnabled, + worktreeName, + customArgs, + ]); + // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- @@ -794,7 +821,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen Schedule-only: Schedule configuration section ═══════════════════════════════════════════════════════════════════ */} {isSchedule ? ( -
+
+ + {isOpen ? ( +
+ {children} +
+ ) : null} +
+ ); +}; diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index 60a49eb9..b4abadcd 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -73,116 +73,122 @@ export const ProjectPathSelector = ({
-
- - -
+
+
+ + +
- {cwdMode === 'project' ? ( -
-
- - ({ - value: project.path, - label: project.name, - description: project.path, - }))} - value={selectedProjectPath} - onValueChange={onSelectedProjectPathChange} - placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} - searchPlaceholder="Search project by name or path" - emptyMessage="Nothing found" - disabled={projectsLoading || projects.length === 0} - renderOption={(option, isSelected, query) => ( - <> - + {cwdMode === 'project' ? ( +
+
+ +
+ ({ + value: project.path, + label: project.name, + description: project.path, + }))} + value={selectedProjectPath} + onValueChange={onSelectedProjectPathChange} + placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} + searchPlaceholder="Search project by name or path" + emptyMessage="Nothing found" + disabled={projectsLoading || projects.length === 0} + renderOption={(option, isSelected, query) => ( + <> + +
+

+ {renderHighlightedText(option.label, query)} +

+

+ {renderHighlightedText(option.description ?? '', query)} +

+
+ )} /> -
-

- {renderHighlightedText(option.label, query)} -

-

- {renderHighlightedText(option.description ?? '', query)} -

-
- - )} - /> -
- {!selectedProjectPath ? ( -

- Select a project from the list -

- ) : null} - {projectsError ?

{projectsError}

: null} - {!projectsLoading && projects.length === 0 ? ( -

- No projects found, switch to custom path. -

- ) : null} +
+
+ {!selectedProjectPath ? ( +

+ Select a project from the list +

+ ) : null} + {projectsError ?

{projectsError}

: null} + {!projectsLoading && projects.length === 0 ? ( +

+ No projects found, switch to custom path. +

+ ) : null} +
+ ) : ( +
+
+ + onCustomCwdChange(event.target.value)} + placeholder="/absolute/path/to/project" + /> + +
+

+ If the directory does not exist, it will be created automatically. +

+
+ )}
- ) : ( -
-
- - onCustomCwdChange(event.target.value)} - placeholder="/absolute/path/to/project" - /> - -
-

- If the directory does not exist, it will be created automatically. -

-
- )} +
{fieldError ?

{fieldError}

: null}
diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx index 6f0c2f17..6e1c67bb 100644 --- a/src/renderer/components/team/dialogs/ReviewDialog.tsx +++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx @@ -9,6 +9,7 @@ import { } from '@renderer/components/ui/dialog'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -37,6 +38,7 @@ export const ReviewDialog = ({ onSubmit, }: ReviewDialogProps): React.JSX.Element => { const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const draft = useDraftPersistence({ key: `requestChanges:${teamName}:${taskId ?? ''}`, enabled: Boolean(teamName && taskId), @@ -85,6 +87,7 @@ export const ReviewDialog = ({ onValueChange={draft.setValue} placeholder="Describe what needs to change... (Enter to submit)" suggestions={mentionSuggestions} + taskSuggestions={taskSuggestions} projectPath={projectPath} onModEnter={handleSubmit} minRows={4} diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 9d9bd376..c2820f9a 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { useAttachments } from '@renderer/hooks/useAttachments'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useStore } from '@renderer/store'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; @@ -209,6 +210,7 @@ export const SendMessageDialog = ({ ); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; @@ -465,6 +467,7 @@ export const SendMessageDialog = ({ onValueChange={textDraft.setValue} suggestions={mentionSuggestions} teamSuggestions={teamMentionSuggestions} + taskSuggestions={taskSuggestions} chips={chipDraft.chips} onChipRemove={handleChipRemove} projectPath={projectPath} diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index a232c76f..e24b99f3 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useStore } from '@renderer/store'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; @@ -55,6 +56,7 @@ export const TaskCommentInput = ({ const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const [pendingAttachments, setPendingAttachments] = useState([]); const [attachError, setAttachError] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null); @@ -279,6 +281,7 @@ export const TaskCommentInput = ({ onValueChange={draft.setValue} suggestions={mentionSuggestions} teamSuggestions={teamMentionSuggestions} + taskSuggestions={taskSuggestions} projectPath={projectPath} chips={chipDraft.chips} onFileChipInsert={chipDraft.addChip} diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 3789a2d2..02c89396 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -13,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; @@ -21,6 +22,7 @@ import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; +import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { formatDistanceToNow } from 'date-fns'; @@ -61,11 +63,6 @@ interface TaskCommentsSectionProps { unreadCommentIds?: Set; } -/** Convert `#` in plain text to markdown links with task:// protocol. */ -function linkifyTaskIdsInMarkdown(text: string): string { - return text.replace(/#([A-Za-z0-9-]+)\b/g, '[#$1](task://$1)'); -} - export const TaskCommentsSection = ({ teamName, taskId, @@ -103,6 +100,7 @@ export const TaskCommentsSection = ({ const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const teamNamesForLinkify = useMemo( () => teamMentionSuggestions.map((t) => t.name), [teamMentionSuggestions] @@ -394,6 +392,7 @@ export const TaskCommentsSection = ({ onValueChange={draft.setValue} suggestions={mentionSuggestions} teamSuggestions={teamMentionSuggestions} + taskSuggestions={taskSuggestions} projectPath={projectPath} chips={chipDraft.chips} onFileChipInsert={chipDraft.addChip} diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index fed458ca..f6b747ef 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -541,7 +541,10 @@ export const KanbanBoard = ({
{viewMode === 'grid' ? ( -
+
{visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index b9982cc5..8d3814c4 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -8,6 +8,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useComposerDraft } from '@renderer/hooks/useComposerDraft'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; @@ -133,6 +134,7 @@ export const MessageComposer = ({ ); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const trimmed = draft.text.trim(); @@ -757,6 +759,7 @@ export const MessageComposer = ({ onValueChange={draft.setText} suggestions={mentionSuggestions} teamSuggestions={teamMentionSuggestions} + taskSuggestions={taskSuggestions} chips={draft.chips} onChipRemove={draft.removeChip} projectPath={projectPath} diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx new file mode 100644 index 00000000..255f8ecc --- /dev/null +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -0,0 +1,506 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; +import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; +import { useStore } from '@renderer/store'; +import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies'; +import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { + Bell, + CheckCheck, + ChevronsDownUp, + ChevronsUpDown, + ChevronRight, + MessageSquare, + PanelLeftClose, + PanelLeft, + Search, + X, +} from 'lucide-react'; + +import { ActiveTasksBlock } from '../activity/ActiveTasksBlock'; +import { ActivityTimeline } from '../activity/ActivityTimeline'; +import { PendingRepliesBlock } from '../activity/PendingRepliesBlock'; +import { CollapsibleTeamSection } from '../CollapsibleTeamSection'; +import { MessageComposer } from './MessageComposer'; +import { MessagesFilterPopover } from './MessagesFilterPopover'; + +import type { MessagesFilterState } from './MessagesFilterPopover'; +import type { ActionMode } from './ActionModeSelector'; +import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; + +interface TimeWindow { + start: number; + end: number; +} + +interface MessagesPanelProps { + teamName: string; + position: 'sidebar' | 'inline'; + onTogglePosition: () => void; + /** Active (non-removed) members. */ + members: ResolvedTeamMember[]; + /** All team tasks. */ + tasks: TeamTaskWithKanban[]; + /** All raw messages from team data. */ + messages: InboxMessage[]; + /** Whether the team is alive. */ + isTeamAlive?: boolean; + /** Time window for filtering. */ + timeWindow: TimeWindow | null; + /** Team session IDs for timeline. */ + teamSessionIds: Set; + /** Current lead session ID. */ + currentLeadSessionId?: string; + /** Pending replies tracker (shared with parent for MemberList). */ + pendingRepliesByMember: Record; + /** Update pending replies tracker. */ + onPendingReplyChange: (updater: (prev: Record) => Record) => void; + /** Callback when a member is clicked in the timeline. */ + onMemberClick?: (member: ResolvedTeamMember) => void; + /** Callback when a task is clicked from timeline or status block. */ + onTaskClick?: (task: TeamTaskWithKanban) => void; + /** Callback to open create task dialog from a message. */ + onCreateTaskFromMessage?: (subject: string, description: string) => void; + /** Callback to open reply dialog for a message. */ + onReplyToMessage?: (message: InboxMessage) => void; + /** Callback when "Restart team" is clicked. */ + onRestartTeam?: () => void; + /** Callback when a task ID link is clicked. */ + onTaskIdClick?: (taskId: string) => void; +} + +export const MessagesPanel = ({ + teamName, + position, + onTogglePosition, + members, + tasks, + messages, + isTeamAlive, + timeWindow, + teamSessionIds, + currentLeadSessionId, + pendingRepliesByMember, + onPendingReplyChange, + onMemberClick, + onTaskClick, + onCreateTaskFromMessage, + onReplyToMessage, + onRestartTeam, + onTaskIdClick, +}: MessagesPanelProps): React.JSX.Element => { + const sendTeamMessage = useStore((s) => s.sendTeamMessage); + const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage); + const sendingMessage = useStore((s) => s.sendingMessage); + const sendMessageError = useStore((s) => s.sendMessageError); + const lastSendMessageResult = useStore((s) => s.lastSendMessageResult); + + const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); + const [messagesFilter, setMessagesFilter] = useState({ + from: new Set(), + to: new Set(), + showNoise: false, + }); + const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); + const [messagesCollapsed, setMessagesCollapsed] = useState(true); + const [statusBlockCollapsed, setStatusBlockCollapsed] = useState(false); + + const filteredMessages = useMemo(() => { + return filterTeamMessages(messages, { + timeWindow, + filter: messagesFilter, + searchQuery: messagesSearchQuery, + }); + }, [messages, timeWindow, messagesFilter, messagesSearchQuery]); + + const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName); + const { expandedSet, toggle: toggleExpandOverride } = useTeamMessagesExpanded(teamName); + + const messagesUnreadCount = useMemo( + () => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length, + [filteredMessages, readSet] + ); + + const handleMessageVisible = useCallback( + (message: InboxMessage) => markRead(toMessageKey(message)), + [markRead] + ); + + const handleMarkAllRead = useCallback(() => { + const keys = filteredMessages + .filter((m) => !m.read && !readSet.has(toMessageKey(m))) + .map((m) => toMessageKey(m)); + markAllRead(keys); + }, [filteredMessages, readSet, markAllRead]); + + const pendingCrossTeamReplies = useMemo( + () => computePendingCrossTeamReplies(messages), + [messages] + ); + + /** Whether the Status block has any visible items (pending replies or active tasks). */ + const hasStatusItems = useMemo(() => { + const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) => + members.some((m) => m.name === name) + ); + if (hasPendingReplies) return true; + if (pendingCrossTeamReplies.length > 0) return true; + + const tMap = new Map(tasks.map((t) => [t.id, t])); + return members.some((m) => { + if (!m.currentTaskId) return false; + const task = tMap.get(m.currentTaskId); + if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false; + return true; + }); + }, [members, tasks, pendingRepliesByMember, pendingCrossTeamReplies.length]); + + // Auto-clear pending replies when a member actually responds + useEffect(() => { + if (Object.keys(pendingRepliesByMember).length === 0) return; + const next = { ...pendingRepliesByMember }; + let changed = false; + for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) { + const hasReply = messages.some((m) => { + if (m.from !== memberName) return false; + const ts = Date.parse(m.timestamp); + return Number.isFinite(ts) && ts > sentAtMs; + }); + if (hasReply) { + delete next[memberName]; + changed = true; + } + } + if (changed) onPendingReplyChange(() => next); + }, [messages, pendingRepliesByMember, onPendingReplyChange]); + + const handleSend = useCallback( + ( + member: string, + text: string, + summary?: string, + attachments?: Parameters[1] extends { attachments?: infer A } + ? A + : never, + actionMode?: ActionMode + ) => { + const sentAtMs = Date.now(); + onPendingReplyChange((prev) => ({ ...prev, [member]: sentAtMs })); + void sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + }).catch(() => { + onPendingReplyChange((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + }); + }, + [teamName, sendTeamMessage, onPendingReplyChange] + ); + + const handleCrossTeamSend = useCallback( + (toTeam: string, text: string, summary?: string, actionMode?: ActionMode) => { + void sendCrossTeamMessage({ + fromTeam: teamName, + fromMember: 'user', + toTeam, + text, + actionMode, + summary, + }); + }, + [teamName, sendCrossTeamMessage] + ); + + // ---- Shared content (used in both modes) ---- + const searchAndFilterBar = ( +
+
+ + setMessagesSearchQuery(e.target.value)} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" + /> + {messagesSearchQuery && ( + + )} +
+ + + + + + + {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} + + +
+ ); + + const messagesContent = ( + <> + + {/* Status block: button floats right (absolute, no layout impact); + expanded content renders full-width in normal flow. */} + {hasStatusItems && ( + <> +
+ +
+ {!statusBlockCollapsed && ( +
+ + +
+ )} + + )} + + + ); + + // ---- Sidebar mode ---- + if (position === 'sidebar') { + return ( +
+ {/* Header */} +
+ + Messages + {filteredMessages.length > 0 && ( + + {filteredMessages.length} + + )} + {messagesUnreadCount > 0 && ( + + + + {messagesUnreadCount} new + + + {messagesUnreadCount} unread + + )} + {messagesUnreadCount > 0 && ( + + + + + Mark all as read + + )} + + + + + Desktop notifications plugin + +
+ + + + + Move to inline + +
+
+ {/* Search & filter bar */} +
+ {searchAndFilterBar} +
+ {/* Scrollable content */} +
{messagesContent}
+
+ ); + } + + // ---- Inline mode (wrapped in CollapsibleTeamSection) ---- + return ( + } + badge={filteredMessages.length} + secondaryBadge={ + filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined + } + afterBadge={ + messagesUnreadCount > 0 ? ( + + + + + Mark all as read + + ) : undefined + } + headerExtra={ + <> + + + + + Desktop notifications plugin + + + + + + Move to sidebar + + + } + defaultOpen + action={
{searchAndFilterBar}
} + > + {messagesContent} +
+ ); +}; diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index 49190bc8..64ef3bbd 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { nameColorSet } from '@renderer/utils/projectColor'; -import { Folder, Loader2, UsersRound } from 'lucide-react'; +import { Folder, Hash, Loader2, UsersRound } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -67,17 +67,23 @@ export const MentionSuggestionList = ({ }, [selectedIndex]); if (suggestions.length === 0) { + const emptyStateText = filesLoading + ? 'Searching...' + : hasFileSearch + ? 'No matching suggestions' + : 'No matching suggestions'; return (
- {hasFileSearch ? 'No matching members, teams, or files' : 'No matching members'} + {emptyStateText}
); } // Categorize suggestions (folders are grouped with files) - type Section = 'member' | 'team' | 'file'; + type Section = 'member' | 'team' | 'task' | 'file'; const getSuggestionSection = (s: MentionSuggestion): Section => { if (s.type === 'file' || s.type === 'folder') return 'file'; + if (s.type === 'task') return 'task'; if (s.type === 'team') return 'team'; return 'member'; }; @@ -85,6 +91,7 @@ export const MentionSuggestionList = ({ const sectionLabel: Record = { member: 'Members', team: 'Teams', + task: 'Tasks', file: 'Files', }; @@ -103,6 +110,7 @@ export const MentionSuggestionList = ({ const isFolder = s.type === 'folder'; const isFileOrFolder = isFile || isFolder; const isTeam = section === 'team'; + const isTask = section === 'task'; // Insert section header on transition if (showSections && section !== currentSection) { @@ -141,6 +149,8 @@ export const MentionSuggestionList = ({ ) : isFile ? ( + ) : isTask ? ( + ) : isTeam ? ( )} - - - +
+
+ + + + {isTask && !s.isCurrentTeamTask && s.teamDisplayName ? ( + + {s.teamDisplayName} + + ) : null} +
+ {isTask && s.subtitle ? ( +
{s.subtitle}
+ ) : null} +
{isTeam && s.isOnline !== undefined ? ( ) : null} - {s.subtitle ? ( + {s.subtitle && !isTask ? ( {s.subtitle} ) : null} diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index a25b1456..c0cdb145 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -1,12 +1,18 @@ import * as React from 'react'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { PROSE_LINK } from '@renderer/constants/cssVariables'; import { useFileSuggestions } from '@renderer/hooks/useFileSuggestions'; import { useMentionDetection } from '@renderer/hooks/useMentionDetection'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { chipToken } from '@renderer/types/inlineChip'; +import { + doesSuggestionMatchQuery, + getSuggestionInsertionText, +} from '@renderer/utils/mentionSuggestions'; import { nameColorSet } from '@renderer/utils/projectColor'; +import { findTaskReferenceMatches } from '@renderer/utils/taskReferenceUtils'; import { createChipFromSelection, findChipBoundary, @@ -18,6 +24,7 @@ import { AutoResizeTextarea } from './auto-resize-textarea'; import { ChipInteractionLayer } from './ChipInteractionLayer'; import { CodeChipBadge } from './CodeChipBadge'; import { MentionSuggestionList } from './MentionSuggestionList'; +import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer'; import type { AutoResizeTextareaProps } from './auto-resize-textarea'; import type { InlineChip } from '@renderer/types/inlineChip'; @@ -38,13 +45,19 @@ interface MentionSegment { suggestion: MentionSuggestion; } +interface TaskSegment { + type: 'task'; + value: string; + suggestion: MentionSuggestion; +} + interface ChipSegment { type: 'chip'; value: string; chip: InlineChip; } -type Segment = TextSegment | MentionSegment | ChipSegment; +type Segment = TextSegment | MentionSegment | TaskSegment | ChipSegment; // --------------------------------------------------------------------------- // Mention segment parsing (splits text into plain text + @mention segments) @@ -63,7 +76,9 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S if (!text || suggestions.length === 0) return [{ type: 'text', value: text }]; // Sort by name length descending for greedy matching - const sorted = [...suggestions].sort((a, b) => b.name.length - a.name.length); + const sorted = [...suggestions] + .filter((suggestion) => suggestion.type !== 'task') + .sort((a, b) => b.name.length - a.name.length); const segments: Segment[] = []; let i = 0; @@ -86,9 +101,10 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S let matched = false; for (const suggestion of sorted) { - const end = i + 1 + suggestion.name.length; + const insertionText = getSuggestionInsertionText(suggestion); + const end = i + 1 + insertionText.length; if (end > text.length) continue; - if (text.slice(i + 1, end).toLowerCase() !== suggestion.name.toLowerCase()) continue; + if (text.slice(i + 1, end).toLowerCase() !== insertionText.toLowerCase()) continue; // Character after name must be boundary if (end < text.length) { @@ -119,6 +135,40 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S return segments; } +function parseSuggestionSegments( + text: string, + mentionSuggestions: MentionSuggestion[], + taskSuggestions: MentionSuggestion[] +): Segment[] { + if (!text) return [{ type: 'text', value: text }]; + + const taskMatches = findTaskReferenceMatches(text, taskSuggestions); + if (taskMatches.length === 0) { + return parseMentionSegments(text, mentionSuggestions); + } + + const segments: Segment[] = []; + let lastEnd = 0; + + for (const match of taskMatches) { + if (match.start > lastEnd) { + segments.push(...parseMentionSegments(text.slice(lastEnd, match.start), mentionSuggestions)); + } + segments.push({ + type: 'task', + value: match.raw, + suggestion: match.suggestion, + }); + lastEnd = match.end; + } + + if (lastEnd < text.length) { + segments.push(...parseMentionSegments(text.slice(lastEnd), mentionSuggestions)); + } + + return segments; +} + // --------------------------------------------------------------------------- // Extended segment parser: chips + mentions // --------------------------------------------------------------------------- @@ -129,11 +179,12 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S */ function parseSegments( text: string, - suggestions: MentionSuggestion[], + mentionSuggestions: MentionSuggestion[], + taskSuggestions: MentionSuggestion[], chips: InlineChip[] ): Segment[] { if (!text) return [{ type: 'text', value: text }]; - if (chips.length === 0) return parseMentionSegments(text, suggestions); + if (chips.length === 0) return parseSuggestionSegments(text, mentionSuggestions, taskSuggestions); // Build a map of chip tokens for fast lookup const chipTokenMap = new Map(); @@ -154,7 +205,9 @@ function parseSegments( } chipPositions.sort((a, b) => a.start - b.start); - if (chipPositions.length === 0) return parseMentionSegments(text, suggestions); + if (chipPositions.length === 0) { + return parseSuggestionSegments(text, mentionSuggestions, taskSuggestions); + } const segments: Segment[] = []; let lastEnd = 0; @@ -163,7 +216,7 @@ function parseSegments( // Text before this chip → parse for mentions if (pos.start > lastEnd) { const fragment = text.slice(lastEnd, pos.start); - segments.push(...parseMentionSegments(fragment, suggestions)); + segments.push(...parseSuggestionSegments(fragment, mentionSuggestions, taskSuggestions)); } segments.push({ type: 'chip', value: pos.token, chip: pos.chip }); lastEnd = pos.end; @@ -171,7 +224,9 @@ function parseSegments( // Remaining text after last chip → parse for mentions if (lastEnd < text.length) { - segments.push(...parseMentionSegments(text.slice(lastEnd), suggestions)); + segments.push( + ...parseSuggestionSegments(text.slice(lastEnd), mentionSuggestions, taskSuggestions) + ); } return segments; @@ -210,6 +265,8 @@ interface MentionableTextareaProps extends Omit< onFileChipInsert?: (chip: InlineChip) => void; /** Team suggestions for cross-team @mentions */ teamSuggestions?: MentionSuggestion[]; + /** Task suggestions for #task references */ + taskSuggestions?: MentionSuggestion[]; /** Called when Enter (without Shift) is pressed. */ onModEnter?: () => void; } @@ -230,6 +287,7 @@ export const MentionableTextarea = React.forwardRef 0; const setRefs = React.useCallback( (node: HTMLTextAreaElement | null) => { @@ -260,9 +319,10 @@ export const MentionableTextarea = React.forwardRef 0, + triggerChars: enableTaskSearch ? ['@', '#'] : ['@'], + isTriggerEnabled: (triggerChar) => { + if (triggerChar === '#') return enableTaskSearch; + return suggestions.length > 0 || enableFiles || teamSuggestions.length > 0; + }, }); // --- File suggestions --- const { suggestions: fileSuggestions, loading: filesLoading } = useFileSuggestions( enableFiles ? projectPath : null, - query, - isOpen && enableFiles + activeTriggerChar === '@' ? query : '', + isOpen && enableFiles && activeTriggerChar === '@' ); + const isAtTrigger = activeTriggerChar !== '#'; + + const memberSuggestions = React.useMemo(() => { + if (!isOpen || !isAtTrigger) return []; + if (!query) return suggestions; + return suggestions.filter((member) => doesSuggestionMatchQuery(member, query)); + }, [isAtTrigger, isOpen, query, suggestions]); + // --- Team suggestions filtered by query --- const filteredTeamSuggestions = React.useMemo(() => { - if (teamSuggestions.length === 0 || !isOpen) return []; + if (teamSuggestions.length === 0 || !isOpen || !isAtTrigger) return []; if (!query) return teamSuggestions; - const lower = query.toLowerCase(); - return teamSuggestions.filter((t) => t.name.toLowerCase().includes(lower)); - }, [teamSuggestions, isOpen, query]); + return teamSuggestions.filter((team) => doesSuggestionMatchQuery(team, query)); + }, [teamSuggestions, isAtTrigger, isOpen, query]); + + const filteredTaskSuggestions = React.useMemo(() => { + if (taskSuggestions.length === 0 || !isOpen || activeTriggerChar !== '#') return []; + if (!query) return taskSuggestions; + return taskSuggestions.filter((task) => doesSuggestionMatchQuery(task, query)); + }, [taskSuggestions, activeTriggerChar, isOpen, query]); // Merged suggestion list: members → online teams → offline teams → files - const allSuggestions = React.useMemo(() => { + const atSuggestions = React.useMemo(() => { const onlineTeams = filteredTeamSuggestions.filter((t) => t.isOnline); const offlineTeams = filteredTeamSuggestions.filter((t) => !t.isOnline); const merged = [...memberSuggestions, ...onlineTeams, ...offlineTeams]; @@ -302,21 +378,19 @@ export const MentionableTextarea = React.forwardRef { - setMergedIndex(0); - }, [query, allSuggestions.length]); - - // Use merged index when we have extra suggestion types (teams or files) - const hasMergedSuggestions = enableFiles || teamSuggestions.length > 0; - - // Effective index: use merged when extra types present, hook's index otherwise - const effectiveIndex = hasMergedSuggestions ? mergedIndex : selectedIndex; - const effectiveSuggestions = hasMergedSuggestions ? allSuggestions : memberSuggestions; + if (!isOpen) return; + if (effectiveSuggestions.length === 0) { + setSelectedIndex(0); + return; + } + if (selectedIndex >= effectiveSuggestions.length) { + setSelectedIndex(0); + } + }, [effectiveSuggestions.length, isOpen, selectedIndex, setSelectedIndex]); // --- File selection handler --- const handleFileSelect = React.useCallback( @@ -436,8 +510,8 @@ export const MentionableTextarea = React.forwardRef { if (s.type === 'file') { handleFileSelect(s); @@ -465,17 +539,22 @@ export const MentionableTextarea = React.forwardRef 0 || teamSuggestions.length > 0 || chips.length > 0; + const hasOverlay = + suggestions.length > 0 || + teamSuggestions.length > 0 || + taskSuggestions.length > 0 || + chips.length > 0; // Combine member + team suggestions for overlay parsing - const allOverlaySuggestions = React.useMemo( + const mentionOverlaySuggestions = React.useMemo( () => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions), [suggestions, teamSuggestions] ); const segments = React.useMemo( - () => (hasOverlay ? parseSegments(value, allOverlaySuggestions, chips) : []), - [hasOverlay, value, allOverlaySuggestions, chips] + () => + hasOverlay ? parseSegments(value, mentionOverlaySuggestions, taskSuggestions, chips) : [], + [hasOverlay, value, mentionOverlaySuggestions, taskSuggestions, chips] ); // Sync backdrop scroll with textarea scroll + track scrollTop for interaction layer @@ -561,47 +640,15 @@ export const MentionableTextarea = React.forwardRef) => { - if (!isOpen || allSuggestions.length === 0) return; - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setMergedIndex((prev) => (prev + 1) % allSuggestions.length); - break; - case 'ArrowUp': - e.preventDefault(); - setMergedIndex((prev) => (prev - 1 + allSuggestions.length) % allSuggestions.length); - break; - case 'Enter': - if (!e.shiftKey) { - e.preventDefault(); - if (allSuggestions[mergedIndex]) { - handleMergedSelect(allSuggestions[mergedIndex]); - } - } - break; - case 'Escape': - e.preventDefault(); - dismiss(); - break; - } - }, - [isOpen, allSuggestions, mergedIndex, handleMergedSelect, dismiss] - ); - - // Composed key handler: mention logic first (when open) → Mod+Enter submit → chip logic → mention fallback + // Composed key handler: suggestion logic first (when open) → Mod+Enter submit → chip logic const composedHandleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { - // When mention dropdown is open, let mention handler consume Enter/Arrow keys first + // When the suggestion dropdown is open, let it consume Enter/Arrow keys first if (isOpen && effectiveSuggestions.length > 0) { - if (hasMergedSuggestions) { - fileMentionHandleKeyDown(e); - } else { - mentionHandleKeyDown(e); - } + mentionHandleKeyDown(e, effectiveSuggestions.length, (index) => { + const next = effectiveSuggestions[index]; + if (next) handleActiveSelect(next); + }); if (e.defaultPrevented) return; } // Enter (without Shift) → submit; Shift+Enter → newline @@ -611,22 +658,15 @@ export const MentionableTextarea = React.forwardRef [ - 'Tip: Use @ to mention team members or search files', + 'Tip: Use @ for members/files and # for tasks', 'Tip: Mention "create a task" to add it to the kanban', "Tip: Don't overload the team lead with tasks — ask them to delegate to teammates", ], @@ -731,7 +771,8 @@ export const MentionableTextarea = React.forwardRef 0 || enableFiles || teamSuggestions.length > 0); + showHint && + (suggestions.length > 0 || enableFiles || teamSuggestions.length > 0 || enableTaskSearch); const showFooter = showHintRow || footerRight; return ( @@ -759,6 +800,17 @@ export const MentionableTextarea = React.forwardRef; } + if (seg.type === 'task') { + return ( + + {seg.value} + + ); + } // mention (member or team) const isTeamMention = seg.suggestion.type === 'team'; const colorSet = seg.suggestion.color @@ -785,6 +837,15 @@ export const MentionableTextarea = React.forwardRef ) : null} + {taskSuggestions.length > 0 ? ( + + ) : null} +
) : null} diff --git a/src/renderer/components/ui/TaskReferenceInteractionLayer.tsx b/src/renderer/components/ui/TaskReferenceInteractionLayer.tsx new file mode 100644 index 00000000..9e2a969f --- /dev/null +++ b/src/renderer/components/ui/TaskReferenceInteractionLayer.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; + +import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; +import { useStore } from '@renderer/store'; +import { calculateInlineMatchPositions } from '@renderer/utils/chipUtils'; +import { findTaskReferenceMatches } from '@renderer/utils/taskReferenceUtils'; + +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { InlineMatchPosition } from '@renderer/utils/chipUtils'; + +interface TaskReferenceInteractionLayerProps { + taskSuggestions: MentionSuggestion[]; + value: string; + textareaRef: React.RefObject; + scrollTop: number; +} + +type PositionedTaskReference = InlineMatchPosition; + +export const TaskReferenceInteractionLayer = ({ + taskSuggestions, + value, + textareaRef, + scrollTop, +}: TaskReferenceInteractionLayerProps): React.JSX.Element | null => { + const [positions, setPositions] = React.useState([]); + const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); + + React.useLayoutEffect(() => { + if (taskSuggestions.length === 0 || !value.includes('#')) { + setPositions([]); + return; + } + + const textarea = textareaRef.current; + if (!textarea) return; + + const matches = findTaskReferenceMatches(value, taskSuggestions).map((match) => ({ + item: match.suggestion, + start: match.start, + end: match.end, + token: match.raw, + })); + + setPositions(calculateInlineMatchPositions(textarea, value, matches)); + }, [taskSuggestions, textareaRef, value]); + + if (positions.length === 0) return null; + + return ( +
+
+ {positions.map((position, index) => { + const suggestion = position.item; + const taskId = suggestion.taskId; + const teamName = suggestion.teamName; + if (!taskId) return null; + + return ( + +
+
+ ); +}; diff --git a/src/renderer/hooks/useMentionDetection.ts b/src/renderer/hooks/useMentionDetection.ts index a283aa26..461bf6c0 100644 --- a/src/renderer/hooks/useMentionDetection.ts +++ b/src/renderer/hooks/useMentionDetection.ts @@ -1,14 +1,17 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from 'react'; + +import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions'; import type { MentionSuggestion } from '@renderer/types/mention'; interface UseMentionDetectionOptions { - suggestions: MentionSuggestion[]; value: string; onValueChange: (v: string) => void; textareaRef: React.RefObject; - /** When true, detect @-trigger even if suggestions list is empty (e.g. for file-only search) */ - enableTriggerAlways?: boolean; + /** Supported trigger characters, e.g. ['@', '#'] */ + triggerChars?: string[]; + /** Enable or disable individual triggers dynamically. */ + isTriggerEnabled?: (triggerChar: string) => boolean; } export interface DropdownPosition { @@ -18,13 +21,18 @@ export interface DropdownPosition { interface UseMentionDetectionResult { isOpen: boolean; + activeTriggerChar: string | null; query: string; - filteredSuggestions: MentionSuggestion[]; selectedIndex: number; + setSelectedIndex: Dispatch>; dropdownPosition: DropdownPosition | null; selectSuggestion: (s: MentionSuggestion) => void; dismiss: () => void; - handleKeyDown: (e: React.KeyboardEvent) => void; + handleKeyDown: ( + e: React.KeyboardEvent, + suggestionCount: number, + onSelectSuggestion: (index: number) => void + ) => void; handleChange: (e: React.ChangeEvent) => void; handleSelect: (e: React.SyntheticEvent) => void; /** Getter for trigger index — use at call time to avoid stale closure (returns -1 if no active trigger) */ @@ -33,6 +41,7 @@ interface UseMentionDetectionResult { interface MentionTrigger { triggerIndex: number; + triggerChar: string; query: string; } @@ -117,27 +126,32 @@ export function getCaretCoordinates( } /** - * Scans backwards from cursor position to find an @ trigger. + * Scans backwards from cursor position to find an active trigger. * Returns null if no valid trigger found. * * Rules: - * - @ must be at start of text or preceded by whitespace - * - Text between @ and cursor must not contain spaces + * - trigger must be at start of text or preceded by whitespace + * - Text between trigger and cursor must not contain spaces */ -export function findMentionTrigger(text: string, cursorPos: number): MentionTrigger | null { +export function findMentionTrigger( + text: string, + cursorPos: number, + triggerChars: string[] = ['@'] +): MentionTrigger | null { if (cursorPos <= 0) return null; const beforeCursor = text.slice(0, cursorPos); + const allowedTriggerChars = new Set(triggerChars); // Scan backwards to find @ for (let i = beforeCursor.length - 1; i >= 0; i--) { const char = beforeCursor[i]; - // If we hit whitespace or newline before finding @, no valid trigger + // If we hit whitespace or newline before finding a trigger, no valid trigger if (char === ' ' || char === '\t' || char === '\n' || char === '\r') return null; - if (char === '@') { - // @ must be at start or after whitespace/newline + if (allowedTriggerChars.has(char)) { + // trigger must be at start or after whitespace/newline if (i > 0) { const preceding = beforeCursor[i - 1]; if (preceding !== ' ' && preceding !== '\t' && preceding !== '\n' && preceding !== '\r') { @@ -146,7 +160,7 @@ export function findMentionTrigger(text: string, cursorPos: number): MentionTrig } const query = beforeCursor.slice(i + 1); - return { triggerIndex: i, query }; + return { triggerIndex: i, triggerChar: char, query }; } } @@ -154,34 +168,31 @@ export function findMentionTrigger(text: string, cursorPos: number): MentionTrig } export function useMentionDetection({ - suggestions, value, onValueChange, textareaRef, - enableTriggerAlways, + triggerChars = ['@'], + isTriggerEnabled, }: UseMentionDetectionOptions): UseMentionDetectionResult { const [isOpen, setIsOpen] = useState(false); + const [activeTriggerChar, setActiveTriggerChar] = useState(null); const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const [dropdownPosition, setDropdownPosition] = useState(null); const triggerIndexRef = useRef(-1); + const activeTriggerCharRef = useRef(null); // Track current query in a ref so detectTrigger can avoid resetting selectedIndex // on redundant selectionchange events (e.g. after ArrowDown/Up keyboard navigation) const queryRef = useRef(''); - const filteredSuggestions = useMemo(() => { - if (!isOpen) return []; - if (!query) return suggestions; - const lower = query.toLowerCase(); - return suggestions.filter((s) => s.name.toLowerCase().includes(lower)); - }, [isOpen, query, suggestions]); - const dismiss = useCallback(() => { setIsOpen(false); + setActiveTriggerChar(null); setQuery(''); setSelectedIndex(0); setDropdownPosition(null); triggerIndexRef.current = -1; + activeTriggerCharRef.current = null; queryRef.current = ''; }, []); @@ -201,11 +212,12 @@ export function useMentionDetection({ const selectSuggestion = useCallback( (s: MentionSuggestion) => { const textarea = textareaRef.current; - if (!textarea || triggerIndexRef.current < 0) return; + const triggerChar = activeTriggerCharRef.current; + if (!textarea || triggerIndexRef.current < 0 || !triggerChar) return; const before = value.slice(0, triggerIndexRef.current); - const after = value.slice(triggerIndexRef.current + 1 + query.length); - const insertion = `@${s.name} `; + const after = value.slice(triggerIndexRef.current + 1 + queryRef.current.length); + const insertion = `${triggerChar}${getSuggestionInsertionText(s)} `; const newValue = before + insertion + after; const newCursorPos = before.length + insertion.length; @@ -218,11 +230,11 @@ export function useMentionDetection({ textarea.selectionEnd = newCursorPos; }); }, - [value, query, onValueChange, textareaRef, dismiss] + [value, onValueChange, textareaRef, dismiss] ); /** - * Detects whether cursor is inside an @-trigger region and opens/dismisses the dropdown. + * Detects whether cursor is inside a trigger region and opens/dismisses the dropdown. * * Called from handleSelect (selectionchange) — must NOT reset selectedIndex when * the trigger is already active with the same query, otherwise ArrowDown/Up navigation @@ -230,12 +242,17 @@ export function useMentionDetection({ */ const detectTrigger = useCallback( (cursorPos: number) => { - const trigger = findMentionTrigger(value, cursorPos); - if (trigger && (suggestions.length > 0 || enableTriggerAlways)) { + const trigger = findMentionTrigger(value, cursorPos, triggerChars); + const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false; + if (trigger && isEnabled) { const sameQuery = - triggerIndexRef.current === trigger.triggerIndex && queryRef.current === trigger.query; + triggerIndexRef.current === trigger.triggerIndex && + activeTriggerCharRef.current === trigger.triggerChar && + queryRef.current === trigger.query; triggerIndexRef.current = trigger.triggerIndex; + activeTriggerCharRef.current = trigger.triggerChar; queryRef.current = trigger.query; + setActiveTriggerChar(trigger.triggerChar); setQuery(trigger.query); setIsOpen(true); // Only reset selection when trigger/query actually changed — @@ -248,7 +265,7 @@ export function useMentionDetection({ dismiss(); } }, - [value, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition] + [value, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition] ); const handleChange = useCallback( @@ -258,10 +275,13 @@ export function useMentionDetection({ // Detect trigger based on cursor position after the change const cursorPos = e.target.selectionStart; - const trigger = findMentionTrigger(newValue, cursorPos); - if (trigger && (suggestions.length > 0 || enableTriggerAlways)) { + const trigger = findMentionTrigger(newValue, cursorPos, triggerChars); + const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false; + if (trigger && isEnabled) { triggerIndexRef.current = trigger.triggerIndex; + activeTriggerCharRef.current = trigger.triggerChar; queryRef.current = trigger.query; + setActiveTriggerChar(trigger.triggerChar); setQuery(trigger.query); setIsOpen(true); // Text changed — always reset selection to first item @@ -271,7 +291,7 @@ export function useMentionDetection({ dismiss(); } }, - [onValueChange, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition] + [onValueChange, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition] ); const handleSelect = useCallback( @@ -283,24 +303,26 @@ export function useMentionDetection({ ); const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (!isOpen || filteredSuggestions.length === 0) return; + ( + e: React.KeyboardEvent, + suggestionCount: number, + onSelectSuggestion: (index: number) => void + ) => { + if (!isOpen || suggestionCount === 0) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); - setSelectedIndex((prev) => (prev + 1) % filteredSuggestions.length); + setSelectedIndex((prev) => (prev + 1) % suggestionCount); break; case 'ArrowUp': e.preventDefault(); - setSelectedIndex( - (prev) => (prev - 1 + filteredSuggestions.length) % filteredSuggestions.length - ); + setSelectedIndex((prev) => (prev - 1 + suggestionCount) % suggestionCount); break; case 'Enter': if (!e.shiftKey) { e.preventDefault(); - selectSuggestion(filteredSuggestions[selectedIndex]); + onSelectSuggestion(selectedIndex); } break; case 'Escape': @@ -309,16 +331,17 @@ export function useMentionDetection({ break; } }, - [isOpen, filteredSuggestions, selectedIndex, selectSuggestion, dismiss] + [isOpen, selectedIndex, dismiss] ); const getTriggerIndex = useCallback(() => triggerIndexRef.current, []); return { isOpen, + activeTriggerChar, query, - filteredSuggestions, selectedIndex, + setSelectedIndex, dropdownPosition, selectSuggestion, dismiss, diff --git a/src/renderer/hooks/useResizablePanel.ts b/src/renderer/hooks/useResizablePanel.ts new file mode 100644 index 00000000..9400ba9c --- /dev/null +++ b/src/renderer/hooks/useResizablePanel.ts @@ -0,0 +1,122 @@ +/** + * useResizablePanel - Reusable hook for mouse-based panel resizing. + * + * Extracted from the resize pattern in Sidebar.tsx. + * Handles mousedown/mousemove/mouseup on document, cursor and userSelect overrides. + * + * @param options.width Current panel width (controlled) + * @param options.onWidthChange Callback when width changes during drag + * @param options.minWidth Minimum allowed width (default 280) + * @param options.maxWidth Maximum allowed width (default 500) + * @param options.side Which side the panel is on: + * 'left' → panel is on the left, resize handle on right edge + * 'right' → panel is on the right, resize handle on left edge + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +const DEFAULT_MIN_WIDTH = 280; +const DEFAULT_MAX_WIDTH = 500; + +interface UseResizablePanelOptions { + width: number; + onWidthChange: (width: number) => void; + minWidth?: number; + maxWidth?: number; + side: 'left' | 'right'; +} + +interface ResizeHandleProps { + onMouseDown: (e: React.MouseEvent) => void; +} + +interface UseResizablePanelReturn { + isResizing: boolean; + handleProps: ResizeHandleProps; +} + +export function useResizablePanel({ + width, + onWidthChange, + minWidth = DEFAULT_MIN_WIDTH, + maxWidth = DEFAULT_MAX_WIDTH, + side, +}: UseResizablePanelOptions): UseResizablePanelReturn { + const [isResizing, setIsResizing] = useState(false); + + // Store the panel's left offset for 'left' side panels. + // Updated on resize start so the formula stays correct if layout shifts. + const panelLeftRef = useRef(0); + + // Keep callbacks in refs to avoid stale closures in mousemove listener + const onWidthChangeRef = useRef(onWidthChange); + onWidthChangeRef.current = onWidthChange; + + const minWidthRef = useRef(minWidth); + minWidthRef.current = minWidth; + + const maxWidthRef = useRef(maxWidth); + maxWidthRef.current = maxWidth; + + const sideRef = useRef(side); + sideRef.current = side; + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isResizing) return; + + let newWidth: number; + if (sideRef.current === 'left') { + // Panel on the left: width = cursor position - panel left edge + newWidth = e.clientX - panelLeftRef.current; + } else { + // Panel on the right: width = viewport width - cursor position + newWidth = window.innerWidth - e.clientX; + } + + if (newWidth >= minWidthRef.current && newWidth <= maxWidthRef.current) { + onWidthChangeRef.current(newWidth); + } + }, + [isResizing] + ); + + const handleMouseUp = useCallback(() => { + setIsResizing(false); + }, []); + + useEffect(() => { + if (isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isResizing, handleMouseMove, handleMouseUp]); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + + if (side === 'left') { + // Calculate the left edge of the panel from cursor position minus current width + panelLeftRef.current = e.clientX - width; + } + + setIsResizing(true); + }, + [side, width] + ); + + return { + isResizing, + handleProps: { onMouseDown }, + }; +} diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts new file mode 100644 index 00000000..a5b8d277 --- /dev/null +++ b/src/renderer/hooks/useTaskSuggestions.ts @@ -0,0 +1,123 @@ +import { useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { getTaskDisplayId } from '@shared/utils/taskIdentity'; + +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { GlobalTask, TeamTaskWithKanban } from '@shared/types'; + +export interface UseTaskSuggestionsResult { + suggestions: MentionSuggestion[]; +} + +interface TaskWithTeamContext { + task: TeamTaskWithKanban | GlobalTask; + teamName: string; + teamDisplayName: string; + teamColor?: string; + isCurrentTeamTask: boolean; + ownerColor?: string; +} + +function getTaskTimestamp(task: TeamTaskWithKanban | GlobalTask): number { + const value = task.updatedAt ?? task.createdAt; + return value ? Date.parse(value) || 0 : 0; +} + +function buildTaskSuggestion({ + task, + teamName, + teamDisplayName, + teamColor, + isCurrentTeamTask, + ownerColor, +}: TaskWithTeamContext): MentionSuggestion { + const displayId = getTaskDisplayId(task); + return { + id: `task:${teamName}:${task.id}`, + name: displayId, + insertText: displayId, + subtitle: task.subject, + color: teamColor, + type: 'task', + taskId: task.id, + teamName, + teamDisplayName, + isCurrentTeamTask, + ownerName: task.owner, + ownerColor, + searchText: [task.subject, teamDisplayName, teamName, task.owner].filter(Boolean).join(' '), + }; +} + +function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean { + return task.status !== 'deleted' && !task.deletedAt; +} + +export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult { + const globalTasks = useStore((s) => s.globalTasks); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const selectedTeamData = useStore((s) => s.selectedTeamData); + const teamByName = useStore((s) => s.teamByName); + + const suggestions = useMemo(() => { + const tasks: TaskWithTeamContext[] = []; + const seenTaskIds = new Set(); + + if (currentTeamName) { + const currentTeamSummary = teamByName[currentTeamName]; + const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName; + const currentTeamMembers = + selectedTeamName === currentTeamName && selectedTeamData + ? selectedTeamData.members + : (currentTeamSummary?.members ?? []); + const currentTeamTasks = + selectedTeamName === currentTeamName && selectedTeamData + ? selectedTeamData.tasks + : globalTasks.filter((task) => task.teamName === currentTeamName); + + for (const task of currentTeamTasks) { + if (!isVisibleTask(task)) continue; + seenTaskIds.add(task.id); + tasks.push({ + task, + teamName: currentTeamName, + teamDisplayName: currentTeamDisplayName, + teamColor: currentTeamSummary?.color, + isCurrentTeamTask: true, + ownerColor: currentTeamMembers.find((member) => member.name === task.owner)?.color, + }); + } + } + + for (const task of globalTasks) { + if (!isVisibleTask(task)) continue; + if (seenTaskIds.has(task.id)) continue; + const teamSummary = teamByName[task.teamName]; + tasks.push({ + task, + teamName: task.teamName, + teamDisplayName: task.teamDisplayName, + teamColor: teamSummary?.color, + isCurrentTeamTask: task.teamName === currentTeamName, + ownerColor: teamSummary?.members?.find((member) => member.name === task.owner)?.color, + }); + } + + tasks.sort((a, b) => { + if (a.isCurrentTeamTask !== b.isCurrentTeamTask) { + return a.isCurrentTeamTask ? -1 : 1; + } + + const timeDelta = getTaskTimestamp(b.task) - getTaskTimestamp(a.task); + if (timeDelta !== 0) return timeDelta; + + if (a.teamName !== b.teamName) return a.teamName.localeCompare(b.teamName); + return getTaskDisplayId(a.task).localeCompare(getTaskDisplayId(b.task)); + }); + + return tasks.map(buildTaskSuggestion); + }, [currentTeamName, globalTasks, selectedTeamData, selectedTeamName, teamByName]); + + return { suggestions }; +} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 691d5409..9593923f 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -415,6 +415,12 @@ export interface TeamSlice { allow: boolean, message?: string ) => Promise; + + // Messages panel UI state + messagesPanelMode: 'sidebar' | 'inline'; + messagesPanelWidth: number; + setMessagesPanelMode: (mode: 'sidebar' | 'inline') => void; + setMessagesPanelWidth: (width: number) => void; } // --- Per-team launch params persistence --- @@ -563,6 +569,12 @@ export const createTeamSlice: StateCreator = (set, pendingApprovals: [], toolApprovalSettings: loadToolApprovalSettings(), + // Messages panel UI state + messagesPanelMode: 'sidebar' as const, + messagesPanelWidth: 340, + setMessagesPanelMode: (mode: 'sidebar' | 'inline') => set({ messagesPanelMode: mode }), + setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }), + fetchBranches: async (paths: string[]) => { const results: Record = {}; for (const p of paths) { diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts index 0f402767..fd4bdca0 100644 --- a/src/renderer/types/mention.ts +++ b/src/renderer/types/mention.ts @@ -1,18 +1,34 @@ export interface MentionSuggestion { /** Unique key (name or draft.id) */ id: string; - /** Name to insert: @name */ + /** Human-readable primary label (for tasks: short display id without `#`) */ name: string; /** Role displayed in suggestion list */ subtitle?: string; /** Color name from TeamColorSet palette */ color?: string; - /** Suggestion type — 'member' (default), 'team', 'file', or 'folder' */ - type?: 'member' | 'team' | 'file' | 'folder'; + /** Suggestion type — 'member' (default), 'team', 'file', 'folder', or 'task' */ + type?: 'member' | 'team' | 'file' | 'folder' | 'task'; /** Whether the team is currently online (team suggestions only) */ isOnline?: boolean; /** Absolute file/folder path (file/folder suggestions only) */ filePath?: string; /** Relative display path (file/folder suggestions only) */ relativePath?: string; + /** Optional exact text inserted after the trigger (defaults to `name`) */ + insertText?: string; + /** Optional extra searchable text (subject, team name, path, etc.) */ + searchText?: string; + /** Canonical task id (task suggestions only) */ + taskId?: string; + /** Owning team name (task suggestions only) */ + teamName?: string; + /** Owning team display name (task suggestions only) */ + teamDisplayName?: string; + /** Whether the task belongs to the currently active team */ + isCurrentTeamTask?: boolean; + /** Owning task owner name (task suggestions only) */ + ownerName?: string; + /** Owning task owner color (task suggestions only) */ + ownerColor?: string; } diff --git a/src/renderer/utils/bugReportUtils.ts b/src/renderer/utils/bugReportUtils.ts new file mode 100644 index 00000000..ee66f3a3 --- /dev/null +++ b/src/renderer/utils/bugReportUtils.ts @@ -0,0 +1,157 @@ +import packageJson from '../../../package.json'; + +const GITHUB_BUG_REPORT_URL = 'https://github.com/777genius/claude_agent_teams_ui/issues/new'; +const MAX_TITLE_LENGTH = 120; +const URL_MAX_STACK_LENGTH = 1800; +const URL_MAX_COMPONENT_STACK_LENGTH = 1200; +const COPY_MAX_STACK_LENGTH = 12000; +const COPY_MAX_COMPONENT_STACK_LENGTH = 8000; + +export interface BugReportContext { + activeTabType?: string | null; + activeTabLabel?: string | null; + activeTeamName?: string | null; + selectedTeamName?: string | null; + taskId?: string | null; + sessionId?: string | null; + projectId?: string | null; +} + +export interface BugReportOptions { + error: Error | null; + componentStack?: string | null; + context?: BugReportContext; +} + +const truncate = (value: string, maxLength: number): string => { + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, maxLength)}\n...[truncated]`; +}; + +const buildIssueTitle = (error: Error | null): string => { + const baseTitle = error ? `[BUG] ${error.name}: ${error.message}` : '[BUG] Application crash'; + return truncate(baseTitle, MAX_TITLE_LENGTH); +}; + +const getRuntimeLabel = (): string => (window.electronAPI ? 'Electron renderer' : 'Web browser'); + +const formatOptional = (value: string | null | undefined): string => { + if (!value) { + return 'Not available'; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : 'Not available'; +}; + +const getOperatingSystemLabel = (): string => { + const { userAgent } = window.navigator; + + if (userAgent.includes('Mac OS X')) return 'macOS'; + if (userAgent.includes('Windows')) return 'Windows'; + if (userAgent.includes('Linux')) return 'Linux'; + + return 'Unknown'; +}; + +const formatActiveTab = (context?: BugReportContext): string => { + if (!context?.activeTabType) { + return 'Not available'; + } + + if (!context.activeTabLabel) { + return context.activeTabType; + } + + return `${context.activeTabType} (${context.activeTabLabel})`; +}; + +const buildBugReportMarkdown = ( + { error, componentStack, context }: BugReportOptions, + stackLimits: { js: number; react: number } +): string => { + const message = error?.message ?? 'Unknown application crash'; + const jsStack = error?.stack ? truncate(error.stack, stackLimits.js) : 'Not available'; + const reactComponentStack = componentStack + ? truncate(componentStack, stackLimits.react) + : 'Not available'; + + return [ + '**Describe the bug**', + 'The app crashed and showed the global error screen.', + '', + '**What happened**', + `- Error: \`${message}\``, + `- Error type: \`${error?.name ?? 'UnknownError'}\``, + `- Active tab: ${formatActiveTab(context)}`, + `- Active team tab: ${formatOptional(context?.activeTeamName)}`, + `- Selected team: ${formatOptional(context?.selectedTeamName)}`, + `- Current task: ${formatOptional(context?.taskId)}`, + `- Session ID: ${formatOptional(context?.sessionId)}`, + `- Project ID: ${formatOptional(context?.projectId)}`, + '', + '**Steps to reproduce**', + '1. Open the app and navigate to the screen where the crash happened.', + '2. Repeat the action that triggered the error.', + '3. Observe the global error screen.', + '', + '**Expected behavior**', + 'The app should continue working instead of crashing.', + '', + '**Screenshots**', + 'Attach a screenshot if you have one.', + '', + '**Environment**', + `- OS: ${getOperatingSystemLabel()}`, + `- Runtime: ${getRuntimeLabel()}`, + `- App version: ${packageJson.version}`, + '', + '**Diagnostics**', + '```text', + `Timestamp: ${new Date().toISOString()}`, + `Current URL: ${window.location.href}`, + `User Agent: ${window.navigator.userAgent}`, + `Error name: ${error?.name ?? 'UnknownError'}`, + `Error message: ${message}`, + `Active tab: ${formatActiveTab(context)}`, + `Active team tab: ${formatOptional(context?.activeTeamName)}`, + `Selected team: ${formatOptional(context?.selectedTeamName)}`, + `Current task: ${formatOptional(context?.taskId)}`, + `Session ID: ${formatOptional(context?.sessionId)}`, + `Project ID: ${formatOptional(context?.projectId)}`, + '```', + '', + '**JavaScript stack trace**', + '```text', + jsStack, + '```', + '', + '**React component stack**', + '```text', + reactComponentStack, + '```', + ].join('\n'); +}; + +export const buildBugReportText = (options: BugReportOptions): string => + buildBugReportMarkdown(options, { + js: COPY_MAX_STACK_LENGTH, + react: COPY_MAX_COMPONENT_STACK_LENGTH, + }); + +export const buildGitHubBugReportUrl = (options: BugReportOptions): string => { + const params = new URLSearchParams({ + template: 'bug_report.md', + labels: 'bug', + title: buildIssueTitle(options.error), + body: buildBugReportMarkdown(options, { + js: URL_MAX_STACK_LENGTH, + react: URL_MAX_COMPONENT_STACK_LENGTH, + }), + }); + + return `${GITHUB_BUG_REPORT_URL}?${params.toString()}`; +}; diff --git a/src/renderer/utils/chipUtils.ts b/src/renderer/utils/chipUtils.ts index d68049d8..ffc11395 100644 --- a/src/renderer/utils/chipUtils.ts +++ b/src/renderer/utils/chipUtils.ts @@ -168,16 +168,26 @@ export interface ChipPosition { height: number; } -/** - * Calculates screen positions of chip tokens in textarea using the mirror div technique. - * Creates a temporary mirror div that replicates textarea layout and measures chip spans. - */ -export function calculateChipPositions( +export interface InlineMatch { + item: T; + start: number; + end: number; + token: string; +} + +export interface InlineMatchPosition extends InlineMatch { + top: number; + left: number; + width: number; + height: number; +} + +export function calculateInlineMatchPositions( textarea: HTMLTextAreaElement, text: string, - chips: InlineChip[] -): ChipPosition[] { - if (chips.length === 0) return []; + matches: InlineMatch[] +): InlineMatchPosition[] { + if (matches.length === 0) return []; const cs = window.getComputedStyle(textarea); const mirror = document.createElement('div'); @@ -210,60 +220,77 @@ export function calculateChipPositions( mirror.style.overflow = 'hidden'; mirror.style.height = 'auto'; - // Build content with chip tokens wrapped in spans - const chipSpans = new Map(); - const tokenPositions: { chip: InlineChip; token: string; index: number }[] = []; + const sortedMatches = [...matches].sort((a, b) => a.start - b.start); + const tokenSpans = new Map(); - // Find all chip token positions in text - for (const chip of chips) { - const token = chipToken(chip); - const idx = text.indexOf(token); - if (idx !== -1) { - tokenPositions.push({ chip, token, index: idx }); - } - } - - // Sort by position in text - tokenPositions.sort((a, b) => a.index - b.index); - - // Build mirror content let lastEnd = 0; - for (const { chip, token, index } of tokenPositions) { - // Text before this chip - if (index > lastEnd) { - const textNode = document.createTextNode(text.slice(lastEnd, index)); - mirror.appendChild(textNode); + sortedMatches.forEach((match, index) => { + if (match.start > lastEnd) { + mirror.appendChild(document.createTextNode(text.slice(lastEnd, match.start))); } - // Chip span const span = document.createElement('span'); - span.textContent = token; + span.textContent = text.slice(match.start, match.end); mirror.appendChild(span); - chipSpans.set(chip.id, span); + tokenSpans.set(index, span); - lastEnd = index + token.length; - } + lastEnd = match.end; + }); - // Text after last chip if (lastEnd < text.length) { mirror.appendChild(document.createTextNode(text.slice(lastEnd))); } document.body.appendChild(mirror); - const positions: ChipPosition[] = []; - for (const { chip } of tokenPositions) { - const span = chipSpans.get(chip.id); - if (!span) continue; + const positions: InlineMatchPosition[] = []; + sortedMatches.forEach((match, index) => { + const span = tokenSpans.get(index); + if (!span) return; positions.push({ - chip, + ...match, top: span.offsetTop, left: span.offsetLeft, width: span.offsetWidth, height: span.offsetHeight, }); - } + }); document.body.removeChild(mirror); return positions; } + +/** + * Calculates screen positions of chip tokens in textarea using the mirror div technique. + * Creates a temporary mirror div that replicates textarea layout and measures chip spans. + */ +export function calculateChipPositions( + textarea: HTMLTextAreaElement, + text: string, + chips: InlineChip[] +): ChipPosition[] { + if (chips.length === 0) return []; + const tokenMatches: InlineMatch[] = []; + for (const chip of chips) { + const token = chipToken(chip); + let searchFrom = 0; + while (searchFrom < text.length) { + const idx = text.indexOf(token, searchFrom); + if (idx === -1) break; + tokenMatches.push({ + item: chip, + start: idx, + end: idx + token.length, + token, + }); + searchFrom = idx + token.length; + } + } + return calculateInlineMatchPositions(textarea, text, tokenMatches).map((position) => ({ + chip: position.item, + top: position.top, + left: position.left, + width: position.width, + height: position.height, + })); +} diff --git a/src/renderer/utils/mentionSuggestions.ts b/src/renderer/utils/mentionSuggestions.ts new file mode 100644 index 00000000..c8c37d4f --- /dev/null +++ b/src/renderer/utils/mentionSuggestions.ts @@ -0,0 +1,27 @@ +import type { MentionSuggestion } from '@renderer/types/mention'; + +export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' { + return suggestion.type === 'task' ? '#' : '@'; +} + +export function getSuggestionInsertionText(suggestion: MentionSuggestion): string { + return suggestion.insertText ?? suggestion.name; +} + +export function doesSuggestionMatchQuery(suggestion: MentionSuggestion, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return true; + + const haystacks = [ + suggestion.name, + suggestion.subtitle, + suggestion.relativePath, + suggestion.searchText, + suggestion.teamDisplayName, + suggestion.teamName, + ] + .filter(Boolean) + .map((value) => value!.toLowerCase()); + + return haystacks.some((value) => value.includes(normalizedQuery)); +} diff --git a/src/renderer/utils/taskReferenceUtils.ts b/src/renderer/utils/taskReferenceUtils.ts new file mode 100644 index 00000000..db1d1d99 --- /dev/null +++ b/src/renderer/utils/taskReferenceUtils.ts @@ -0,0 +1,62 @@ +import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions'; + +import type { MentionSuggestion } from '@renderer/types/mention'; + +const TASK_REF_REGEX = /#([A-Za-z0-9-]+)\b/g; + +export interface TaskReferenceMatch { + start: number; + end: number; + raw: string; + ref: string; + suggestion: MentionSuggestion; +} + +export function linkifyTaskIdsInMarkdown(text: string): string { + return text.replace(TASK_REF_REGEX, '[#$1](task://$1)'); +} + +export function findTaskReferenceMatches( + text: string, + taskSuggestions: MentionSuggestion[] +): TaskReferenceMatch[] { + if (!text || taskSuggestions.length === 0) return []; + + const suggestionByRef = new Map(); + for (const suggestion of taskSuggestions) { + if (suggestion.type !== 'task') continue; + const ref = getSuggestionInsertionText(suggestion).trim().toLowerCase(); + if (!ref || suggestionByRef.has(ref)) continue; + suggestionByRef.set(ref, suggestion); + } + + if (suggestionByRef.size === 0) return []; + + const matches: TaskReferenceMatch[] = []; + for (const match of text.matchAll(TASK_REF_REGEX)) { + const raw = match[0]; + const ref = match[1]; + const start = match.index ?? -1; + if (start < 0) continue; + + if (start > 0) { + const preceding = text[start - 1]; + if (preceding !== ' ' && preceding !== '\t' && preceding !== '\n' && preceding !== '\r') { + continue; + } + } + + const suggestion = suggestionByRef.get(ref.toLowerCase()); + if (!suggestion) continue; + + matches.push({ + start, + end: start + raw.length, + raw, + ref, + suggestion, + }); + } + + return matches; +}